WolvCTF2022 Homework Help
Problem statement
I don’t have the statement exactly but it was something about a friend needing help with their homework.
Binary
We are given a binary that asks us for our answer to a problem and if we guess it correctly it will check the flag, but there is a catch.
When we analyse the main function, it just calls the “ask” function for our input.
__int64 ask()
{
char anonymous0; // [rsp+0h] [rbp+0h] BYREF
unsigned __int64 vars8; // [rsp+8h] [rbp+8h]
vars8 = __readfsqword(0x28u);
puts("Bro can you help me with my homework?");
puts(
"What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?");
__printf_chk();
gets(&anonymous0);
return eval(&anonymous0);
}
__int64 __fastcall eval(char *s)
{
char *parcel; // rbp
size_t v2; // r13
unsigned int answer; // r12d
char accept[13]; // [rsp+Bh] [rbp-3Dh] BYREF
unsigned __int64 v6; // [rsp+18h] [rbp-30h]
v6 = __readfsqword(0x28u);
strcpy(accept, "0123456789+\n");
parcel = strtok(s, "+");
v2 = strspn(s, accept);
if ( v2 == strlen(s) ) // All match.
{
answer = 0;
if ( !parcel )
goto LABEL_5;
do
{
answer += strtol(parcel, 0LL, '\n');
parcel = strtok(0LL, "+");
}
while ( parcel );
if ( answer == 3 )
{
puts("Thanks, I'll help you check the flag");
__printf_chk();
fgets(FLAG, 33, stdin);
}
else
{
LABEL_5:
puts("That really dosn't look right, I'll keep trying");
}
}
else
{
answer = 0;
puts("Uh, the answer wouldn't look like that");
}
return answer;
}
The call to strspn will return the amount of chars from "0123456789+\n"
inside s
, our input, and we are tokenizing +
with strtok. The literal values of the sum parcels are turned to integers with strtol
and added. Simple, right? There it is hardcoded, we must input a sum that results in 3.
Bro can you help me with my homework?
What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?
Answer: 1+1+1
Thanks, I'll help you check the flag
Flag:
Yeah, we don’t see any relevant code after and the program seems to just terminate… What a bummer… Or do we? Modern compilers can detect obvious overflows of dangerous functions that can corrupt the stack or use heuristics to evaluate how dangerous a function might be in the not-obvious situations, hence the existance of stack canaries. Decompilers like IDA Pro, Ghidra or Binja may hide that call from the user, since it is placed by the compiler itself, but it is there, and a call to gets is obviously dangerous. Why am I talking about this? Well, it turns out the author of the challenge decided to troll us and implement their own _stack_chk_fail
where the flag is tested.
void __noreturn _stack_chk_fail()
{
__int64 v0; // rax
int i; // edx
int v2; // [rsp+Ch] [rbp-15Ch]
int v3[2]; // [rsp+10h] [rbp-158h]
__int64 v4; // [rsp+18h] [rbp-150h]
__int64 v5; // [rsp+20h] [rbp-148h]
__int64 v6; // [rsp+28h] [rbp-140h]
__int64 v7; // [rsp+30h] [rbp-138h]
__int64 v8; // [rsp+38h] [rbp-130h]
__int64 v9; // [rsp+40h] [rbp-128h]
__int64 v10; // [rsp+48h] [rbp-120h]
__int64 v11; // [rsp+50h] [rbp-118h]
__int64 v12; // [rsp+58h] [rbp-110h]
__int64 v13; // [rsp+60h] [rbp-108h]
__int64 v14; // [rsp+68h] [rbp-100h]
__int64 v15; // [rsp+70h] [rbp-F8h]
__int64 v16; // [rsp+78h] [rbp-F0h]
__int64 v17; // [rsp+80h] [rbp-E8h]
__int64 v18; // [rsp+88h] [rbp-E0h]
struct __jmp_buf_tag env[1]; // [rsp+90h] [rbp-D8h] BYREF
unsigned __int64 v20; // [rsp+158h] [rbp-10h]
v20 = __readfsqword(0x28u);
v3[1] = 20;
v4 = 0x1200000017LL;
v5 = 0x500000001DLL;
v6 = 0x5D00000046LL;
v7 = 0x4100000042LL;
v8 = 0x330000006CLL;
v9 = 0x5A0000005DLL;
v10 = 0x3A0000000ELL;
v11 = 0x410000006ALL;
v12 = 0x5700000040LL;
v13 = 0x3400000008LL;
v14 = 0xB0000003CLL;
v15 = 0x3400000003LL;
v16 = 0x4600000028LL;
v17 = 0x530000005FLL;
v18 = 0x5000000010LL;
v2 = 54;
if ( _setjmp(env) )
{
puts("Nope.");
}
else
{
v0 = 0LL;
for ( i = 65; ; i = v3[v0] )
{
v2 ^= i;
if ( FLAG[v0] != v2 )
__longjmp_chk(); // Get out.
if ( ++v0 == 32 )
break;
}
puts("Well Done.");
}
}
There we go, a checker. Once a character doesn’t match what it expects from the xorring, it will exit with the libc longjmp shenenigans. We see the v3 array overflows into the other stack vars. Thanks to the addressing, the 0s in the middle are to make the space to address every value as u32
even though they clearly fit in u8
.
i = 65
flag = ""
cur = 54
v3 = [0, 0x14, 0x17, 0x12, 0x1D, 0x50, 0x46, 0x5D, 0x42, 0x41, 0x6C, 0x33, 0x5D, 0x5A, 0x0E, 0x3A, 0x6A, 0x41, 0x40, 0x57, 0x08, 0x34, 0x3C, 0x0B, 0x03, 0x34, 0x28, 0x46, 0x5F, 0x53, 0x10, 0x50]
v0 = 0
while(True):
v0 += 1
cur = (i ^ cur)
flag += chr(cur)
if (v0 == 32):
break
i = v3[v0]
print(flag)
wctf{+m0r3_l1ke_5t4ck_chk_w1n=-}
There, so easy to statically analyse that I feel like this writeup is completely introductory. However, I decided to learn about Frida because I had heard good stuff about it in the community and I went out for dinner with my dad in the meantime and sure a blackbox for this would be easy to implement anyway (maybe shouldn’t have procrastinated with learning a new tool during a CTF, but oh well). It just so happens that the eval
function has the call to _stack_chk_fail
right before ask
, so jumping to it there would just be perfect to make the program loop and ease out a blackbox, so we do that patch in the binary itself and it will check the flag.
var r = Process.getModuleByName("homework_help_patched").base;
var getchar = Module.getExportByName("libc.so.6", "getchar");
var gets = r.add(0x1140);
var fgets = r.add(0x1110);
var chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ ";
var cur_char_id = 0;
var cur_rax = 5;
var cur_flag = "wctf{";
Interceptor.replace(gets, new NativeCallback((inPtr) => {
Memory.copy(inPtr, Memory.allocUtf8String("1+1+1\n\x00"), 8);
return inPtr;
}, 'pointer', ['pointer']));
Interceptor.replace(fgets, new NativeCallback((inPtr, sz, iin) => {
let _data = cur_flag + chars[cur_char_id] + "\n\x00";
Memory.copy(inPtr, Memory.allocUtf8String(_data), _data.length + 2);
return inPtr;
}, 'pointer', ['pointer', 'int', 'int']));
Interceptor.attach(r.add(0x1426), function() {
let nrax = this.context.rax;
let getchar_call = new NativeFunction(getchar, 'uint64', []);
if (nrax.toInt32() != cur_rax) {
cur_flag += chars[cur_char_id];
cur_char_id = 0;
cur_rax = nrax;
} else if (nrax.toInt32() == 31) {
console.log("flag: " + cur_flag);
getchar_call();
} else {
cur_char_id++;
}
});
This was the exploit I came up with. So, here I use the Interceptor
class that represents the hooking primitive in binary patching. I use two methods: replace
to completely replace a C function (gets
to hardcode the sum we talked about early and fgets
to place the current blackbox flag in memory, both with Memory
primitives) with a JavaScript function and attach
to perform an inline hook right at the basic block where longjmp is called thanks to failing a match with a char.
RAX is the indexing register, so we can check for an incremented value and cache the correct guess from the alphabet. I match until 31 and then stall with a call to getchar so I can see the flag.
Bro can you help me with my homework?
What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?
Answer: Thanks, I'll help you check the flag
Flag: Nope.
Bro can you help me with my homework?
What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?
Answer: Thanks, I'll help you check the flag
Flag: Nope.
Bro can you help me with my homework?
What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?
Answer: Thanks, I'll help you check the flag
Flag: Nope.
Bro can you help me with my homework?
What is the largest number of circles with a diameter of 0.7 that will fit completely inside a 2 x 1 rectangle without overlapping?
Answer: Thanks, I'll help you check the flag
Flag: flag: wctf{+m0r3_l1ke_5t4ck_chk_w1n=-
I found Frida pretty intuitive and nice for someone to learn binary patching and intrumentation. The frida-gum
lib implements a lot of instrumentation logic and can be called by native languages like C and Rust. Honestly, I would still prefer to write a Python gdbscript in blackbox situations, due to their simplicity and since Frida is suited for more complex instrumentation.