February 05 2025
Race Conditions (TOCTOU)
Stage 4 Area 1
We are given a shared object library (.so), and a header file exposing several launch-related functions. The challenge description tells us that the original launch computer has been destroyed, and that the remaining library has been sabotaged so the correct launch sequence should never succeed. Our objective is to reverse the library, understand how its internal state changes to identify the required launch sequence, and exploit whatever bug remains to recover the final kick-start engine value.
Hint: "Not only has the alien destroyed our launch systems, it's meddled with the code itself. This is a RACE against time."
// launch_interface.h
#ifndef launch_interface_h__
#define launch_interface_h__
extern int kick_start_multi_isolinear_thruster();
extern void reconfigure_altonian_multiphase_warp();
extern void align_micro_optic_phaser_polarity();
extern void activate_multi_isolinear_sustainer();
extern void calibrate_evasion_subspace_flux();
#endifReversing the library
I started by disassembling each function using Radare2. While reversing, I noticed that each function references the same global structure, reloc.s. Its layout appeared to be four 32-bit values at offsets +0x00, +0x04, +0x08, and +0x0c, followed by a character buffer beginning at +0x10.
To make the control flow easier to understand, I rewrote the relevant logic in C:
typedef struct reloc_s {
int32_t field0; // +0x00
int32_t field1; // +0x04
int32_t field2; // +0x08
int32_t field3; // +0x0c
char buffer[20]; // +0x10
} s;
// Note: offset +0x12 inside buffer is later referenced as 32-bit integer.void quantum_logic_counter() {
if (sysconf(_SC_NPROCESSORS_ONLN) <= 1)
exit(0);
}
void activate() {
quantum_logic_counter();
if (s->field0 == 0 && s->field1 == 0)
s->field0 = 1;
}
void align() {
quantum_logic_counter();
if (s->field0 == 1 && s->field1 == 0)
s->field1 = 1;
}
void calibrate() {
if (s->field0 == 1 && s->field1 == 0 && s->field3 == 0) {
s->field3 = 0x224;
strcat(s->buffer, "a");
}
}
void reconfigure() {
if (s->field0 == 1 &&
s->field1 == 1 &&
s->field2 == 0 &&
s->field3 == 0x224) {
s->field2 = s->field3 + 0x1ba3; // 0x1dc7
strcat(s->buffer, "b");
}
}
int kick_start() {
if (s->field0 == 1 &&
s->field1 == 1 &&
s->field2 == 0x1dc7 &&
s->field3 == 0x224 &&
s->buffer[0] != '\0') {
s->field2 = 0;
// TOCTOU vulnerability
if (s->field2 == 0) {
*(int32_t *)((char *)s + 0x12) = 0x29a;
puts("\n## Error condition");
return 0;
}
if (*(int32_t *)((char *)s + 0x12) != 0x29a) {
printf("## KICK STARTING ENGINE: %d\n", *(int32_t *)((char *)s + 0x12));
exit(0);
}
}
return 0;
}Note the library uses a
strcat-like function.
Identifying the required launch order
Rewriting the library like this made the intended launch sequence much clearer. Each function checks that the shared structure is set to a specific set of values, and if those conditions are met, it updates the structure for the next stage of the launch process. Looking at those conditions reveals the intended launch sequence: activate(), calibrate(), align(), reconfigure().
After these calls, the shared structure becomes the valid state expected by kick_start():
+0x00 = 1
+0x04 = 1
+0x08 = 0x1dc7
+0x0c = 0x224
+0x10 = "ab"Identifying the sabotage
Although we have correctly identified the launch sequence, kick_start() deliberately breaks it at the last moment:
s->field2 = 0;
if (s->field2 == 0) {
// Error condition
}Even after satisfying all of the expected checks, the function resets offset +0x08 (field2) to zero immediately before testing it. In an attempt for an easy win, I patched this out in Radare2, but removing this resulted in the incorrect flag being generated.
After the hint mentioning a RACE, and the CPU-count check in the quantum_logic_counter(), it became clear the intended solution would be to exploit using a race condition rather than a simple patch.
Exploiting the race condition
After some research, this is a classic TOCTOU (time of check, time of use) vulnerability. In a normal synchronous program, field2 would always be zero when the if statement is reached. However, because this library is not thread-safe and does not use atomic operations or any other synchronisation, another thread can modify field2 in the small window between it being set to zero and it being checked.
To exploit this, kick_start() needs to be running continuously in one thread. At the same time, reconfigure() must be running in another thread, since it restores field2 to the expected value (0x1dc7). If that write lands after the reset but before the check, the error path is skipped.
I wrote a small C program that used the library, prepared the required launch state, and then spawned two threads to continuously call kick_start() and reconfigure().
alias 'race=gcc race.c -L./4_1/ -llaunch_interface -o race && LD_LIBRARY_PATH=./4_1 ./race'
race | grep -E "KICK|failed"
## KICK STARTING ENGINE: 6423194With both threads hammering the relevant functions, field2 is eventually restored at exactly the right moment, the sabotaged error path is bypassed, and the library prints the final kick-start engine value completing the challenge!
Understanding how the flag was generated
At this point the challenge was already solved, but I still wanted to understand how the flag was generated, and why I occasionally saw a different one.
The final kick-start engine value is read as a dword from offset +0x12. Initially, this is empty since it sits inside the 20-byte buffer. When one thread inevitably hits the error path, it seeds +0x12 with 0x29a. Importantly, this overwrites the null terminator after "ab", so the string extends further into memory.
When reconfigure() later wins the race and runs again, it appends another 'b' (0x62) to the new end of the string. Because the terminator has moved, that 'b' lands inside the same region that kick_start() later reads as a 32-bit integer:
Offset | 0x10 0x11 0x12 0x13 0x14 0x15
Initial state | 61 62 00 00 00 00
After error | 61 62 9a 02 00 00
After reconfigure | 61 62 9a 02 62 00Read as little-endian, this is 0x0062029a, which is 6423194 decimal, the value printed by the library.
I also saw 0x6262029a (1650590362 decimal), which happens when reconfigure() appends 'b' more than once before the final print. Since the binary only checks that the value at +0x12 is not equal to 0x29a, this would likely also have been a valid flag.