Dojo challenge #47 APICrash solution

Article hero image

The solution and the writeup provided were written by the hunter: TekneX.

Introduction

This application is the backend for an API where users can list blog posts with a GET request and edit them with a POST request. This backend is developed in Python and uses GraphQL queries to interact with a TinyDB database file.

This application is vulnerable to Information Exposure throught and Error Message, made by a Race Condition. Let's take a look at it.

Code analysis

The user input is stored in the variable query which is later executed as a GraphQL command.

1query = unquote("xxx") # <- User input
2
3schema = graphene.Schema(query=GraphqlQuery)
4schema.execute(query) # <- Executes the GraphQL query

Note: If the query is invalid, it will not be executed. Therefore, the GraphQL query must be valid.

After that, the program executes a function to retrieve all posts.

1result = schema.execute("{ getPosts { id content } }")

Finally, if an error occurs during the GraphQL execution, the program returns an error response containing the flag. There's also here a clue for the vulnerability : "Random crashes appear time to time with same input".

1# TODO : Random crashes appear time to time with same input, but different error. We working on a fix.
2if result.errors:
3 posts = json.dumps({"FLAG": os.environ["FLAG"]}, indent=2)
4else:
5 posts = json.dumps(result.data, indent=2)

We can understand the goal of the challenge is to trigger an error with a specific GraphQL query that is valid but that trigger an error in the code.

Now let's now examine the GraphqlQuery object.
The database used here is a JSON file managed by the TinyDB library.

1db = tinydb.TinyDB('data.json')

Here we have two possible functions : get_posts (read) and update_post (write). This last one takes two arguments: id and content.

1class GraphqlQuery(graphene.ObjectType):
2
3 get_posts = graphene.List(Post)
4 update_post = graphene.Boolean(id=graphene.Int(), content=graphene.String())

Here is the update function. This is done with a threading function for performance.

1def resolve_update_post(self, info, id, content):
2 t = threading.Thread(target=GraphqlQuery.update_post_in_db, args=[id, content])
3 t.start()
4 threads.append(t)

Each thread checks whether the id exists in the JSON database and, if it does, updates it.

1def update_post_in_db(id, content):
2 node = db.search(tinydb.Query().id == int(id))
3 if node == []:
4 return False
5 else:
6 node[0]['content'] = content
7 db.update(node[0], tinydb.Query().id == int(id))
8 return True

Finally the get function, that returns all records from the database.

1def resolve_get_posts(self, info):
2 return db.all()

With all these information, we can craft a valid query, with the call of the function updatePost. In GraphQL, we have to put the "query" keyword for that, and we have to give the two arguments id and content.

1query {
2 updatePost(id: 1, content: "Post 1 updated")
3}

Now that we have checked all the code, there is a bug vector, which is in the threading function, let's dive deeper.

Vulnerability explaination and Proof-of-Concept

A bug can occur if two or more threads are executed with a significant delay between them.
Here are the actions of the function update_post_in_db :

  1. Check whether the ID exists in the database ;
  2. If it does, update the JSON database file ;
  3. Save the modifications.
    Since the function modifies a file, two instances running in parallel (a race condition) can cause an error. We have two possible outcomes with the same input, as mentioned in the comment.

In GraphQL, we can execute multiple operations in a single request by using aliases.

1query {
2 query1:updatePost(id: 1, content: "Post 1 Updated")
3 query2:updatePost(id: 2, content: "Post 2 Updated")
4}

Since we cannot control the delay between threads, we need to retry the payload several times to observe different outcomes.

First case: the threads are executed almost simultaneously, so both checks on the database file occur at the same time.

In the example above, the second update is delayed, but it could also be the first one. In this scenario, the second modification overwrites the first update, so only the latest change (the second one) is visible.

Below are two examples showing what happens when the first thread is delayed and when the second one is delayed.

We can see that errors already occur sometimes, which means we are on the right track, but we still haven’t obtained the flag.

Second case: there is a significant delay between the first and second thread. The first thread starts writing to the file, and when the second thread checks it, the JSON is in an invalid state, causing an error.

Here is the result for this case.

The file becomes corrupted, and we successfully retrieve the flag.

1{
2 "FLAG": "FLAG{M4ke_It_Cr4sh_Th3y_Sa1d?!}"
3}

Additionally, we can optimize this payload by introducing a third thread to increase the likelihood of two threads being delayed. This is not mandatory but improves the chances of triggering the race condition.

1query {
2 query1:updatePost(id: 1, content: "xxx")
3 query2:updatePost(id: 2, content: "xxx")
4 query3:updatePost(id: 3, content: "xxx")
5}

Impact

Since this bug displays the error directly on the HTML page, we can access sensitive information such as the server path, the programming language, its version, the operating system, and the libraries in use.
In this case, we learn that the application runs on a Linux web server using Python 3.12, with the error originating from the file /usr/lib/python3.12/threading.py, and relies on TinyDB version 4.7.1. With this information, an attacker can leverage other vulnerabilities, such as Remote Code Execution (RCE) through outdated components. The main risk lies in chaining these details with other weaknesses to escalate the attack.

Remediation

To fix this appliciation, we have many possibilities, but I'll explain two of them here.
The first method is the most logical, do not use threading to modify a file database. You can instead execute both requests one after the other, or combine all queries in a single one, and write them to the file in one operation.
The second solution is to use the threading.Lock mechanism. This method protects the sensitive actions such as file modifications. A thread which access a file blocks the lock, preventing other threads from proceeding until the lock is released. With this approach, all threads are queued, ensuring that updates do not overwrite each other.
Here's how we can modify the code to implement the lock function.

1threads = []
2db = tinydb.TinyDB('data.json')
3db_lock = threading.Lock() # We add the Lock here
4
5# [...]
6
7 def update_post_in_db(id, content):
8 with db_lock: # We execute the check and update with the Lock condition
9 node = db.search(tinydb.Query().id == int(id))
10 if node == []:
11 return False
12 else:
13 node[0]['content'] = content
14 db.update(node[0], tinydb.Query().id == int(id))
15 return True
16
17 def resolve_update_post(self, info, id, content):
18 t = threading.Thread(target=GraphqlQuery.update_post_in_db, args=[id, content])
19 t.start()
20 threads.append(t)