diff --git a/2020/gctf/sprint/README.md b/2020/gctf/sprint/README.md new file mode 100644 index 0000000..a6290ca --- /dev/null +++ b/2020/gctf/sprint/README.md @@ -0,0 +1,695 @@ +# Google CTF 2020 Write-up: Sprint + +## Picking apart the binary + +We're given a single binary file. Running it through `objdump`, we +see that everything is in a single `main` function. Let's pick it +apart. + +After setting up the stack, the first function call is this: + + 1199: 41 b9 00 00 00 00 mov $0x0,%r9d + 119f: 41 b8 ff ff ff ff mov $0xffffffff,%r8d + 11a5: b9 22 00 00 00 mov $0x22,%ecx + 11aa: ba 03 00 00 00 mov $0x3,%edx + 11af: be 00 00 00 04 mov $0x4000000,%esi + 11b4: bf 00 00 00 04 mov $0x4000000,%edi + 11b9: e8 82 fe ff ff callq 1040 + 11be: 48 89 45 c8 mov %rax,-0x38(%rbp) + +This translates to the following C: + + void *mapped = mmap(0x4000000, 0x4000000, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + +Where `mapped` is the local variable at `-0x38(%rbp)`. This gives us +a chunk of memory at `0x4000000`. Technically, it only *suggests* +that the chunk of memory is *near* `0x4000000`, but checking in `gdb` +shows it actually is `0x4000000`. This is important later for some +pointer calculations. + +Next we call `memcpy` to copy some data from a constant `M` to `mapped`: + + 11c6: ba 34 f1 00 00 mov $0xf134,%edx + 11cb: 48 8d 35 4e 0e 00 00 lea 0xe4e(%rip),%rsi # 2020 + 11d2: 48 89 c7 mov %rax,%rdi + 11d5: e8 86 fe ff ff callq 1060 + +Lets check what this data is. We copy bytes starting at `0x2020`, and +ending at `0x2020+0xf134=0x11154`. + + $ objdump -s -j .rodata sprint + + sprint: file format elf64-x86-64 + + Contents of section .rodata: + 02000 01000200 00000000 00000000 00000000 ................ + 02010 00000000 00000000 00000000 00000000 ................ + 02020 25312430 30303338 73253324 686e2531 %1$00038s%3$hn%1 + 02030 24363534 39387325 31243238 36373273 $65498s%1$28672s + 02040 25392468 6e002531 24303030 37347325 %9$hn.%1$00074s% + 02050 3324686e 25312436 35343632 73253124 3$hn%1$65462s%1$ + 02060 2a382473 25372468 6e002531 24303031 *8$s%7$hn.%1$001 + ... + 03400 39732533 24686e25 31243630 34313773 9s%3$hn%1$60417s + 03410 25312435 39333932 73253724 686e0025 %1$59392s%7$hn.% + 03420 31243035 31353373 25332468 6e253124 1$05153s%3$hn%1$ + 03430 36303338 33732531 24307325 3624686e 60383s%1$0s%6$hn + 03440 00253124 36353533 34732533 24686e00 .%1$65534s%3$hn. + 03450 00000000 00000000 00000000 00000000 ................ + 03460 00000000 00000000 00000000 00000000 ................ + ... + 11000 00000000 00000000 00000000 00000000 ................ + 11010 00000000 00000000 00000000 00000000 ................ + 11020 ccb0e77b bcc0ee3a fc7381d0 7a6984e2 ...{...:.s..zi.. + 11030 48e3d759 116bf1b3 860b89c5 bf536565 H..Y.k.......See + 11040 f0ef6abf 0878c42c 99353c6c dce0c899 ..j..x.,.5 format_strings.txt + +Now we write a python script to parse each of the types of +instructions we've seen above, and translate them to human-readable +form. We can also check if some of the assumptions we've made hold +and print an error message if they don't. The code below is +abbreviated, you can find the full code [here](#). + + #!/usr/bin/env python3 + + import fileinput + import re + + literalMove = re.compile(r"^%1\$(\d*)s%3\$hn%1\$(\d*)s%1\$(\d*)s%(\d*)\$hn$") + # ... more for each type of instruction + + def showRegisterDest(regRef): + if regRef == 6: + regStr = "(%r1)" + elif regRef >= 7 and regRef <= 23 and regRef % 2 == 1: + regStr = "%r{}".format(regRef // 2 - 2) + else: + print("bad destination: {}".format(regRef)) + exit() + return regStr + + + addr = 0 + + for line in fileinput.input(): + oldAddr = addr + addr += len(line) + m = literalMove.match(line) + if m: + nextAddr = int(m.group(1)) + filler = int(m.group(2)) + literal = int(m.group(3)) + regRef = int(m.group(4)) + + if addr != nextAddr: + print("address is not the next instruction") + exit() + if nextAddr + filler != 0x10000: + print("first two numbers do not add to 0x10000") + exit() + + dest = showRegisterDest(regRef) + print("{:04x}: mov 0x{:x}, {}".format(oldAddr, literal, dest)) + continue + + # ... more for each other type of instruction + + print("unknown instruction") + exit() + +Now we can see the output: + + $ ./disassemble.py < format_strings.txt > disassembly.txt + $ cat disassembly.txt + 0000: mov 0x7000, %r2 + 0026: mov %r2, %r1 + 004a: mov 0x1, (%r1) + 006c: add %r2, 0x2, %r1 + 0095: mov 0x1, (%r1) + 00b7: mov 0x2, %r3 + 00da: add %r3, %r3, %r6 + 0108: add %r6, 0x7000, %r1 + 0136: mov (%r1), %r5 + 015b: jnz %r5, 0324 + ... + +## Picking apart the embedded code + +### Using `gdb` + +Now we have yet another chunk of assembly to reverse. It will be +useful to be able to use `gdb` to step through this code, so lets +understand how to do that. The call to `sprintf` is at `main+555`, so +by putting a breakpoint there, we can step through the embedded code +one instruction at a time. To view the 10 registers, we can examine +the memory at `0x90(%rbp)`. + + Temporary breakpoint 1, 0x0000555555555189 in main () + (gdb) b *main+555 + Breakpoint 2 at 0x5555555553b0 + (gdb) c + Continuing. + Input password: + foobar + + Breakpoint 2, 0x00005555555553b0 in main () + (gdb) x/10gx $rbp-0x90 + 0x7fffffffdde0: 0x0000000004000000 0x0000000004000000 + 0x7fffffffddf0: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde00: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde10: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde20: 0x0000000000000000 0x0000000000000000 + (gdb) c + Continuing. + + Breakpoint 2, 0x00005555555553b0 in main () + (gdb) x/10gx $rbp-0x90 + 0x7fffffffdde0: 0x0000000004000026 0x0000000004000000 + 0x7fffffffddf0: 0x0000000000007000 0x0000000000000000 + 0x7fffffffde00: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde10: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde20: 0x0000000000000000 0x0000000000000000 + +Instead of single-stepping, we can also set breakpoints in the +embedded code, by adding conditional breakpoints in `gdb`. Before the +call to `sprintf`, the program counter is passed in `%rsi`: + + (gdb) clear *main+555 + Deleted breakpoint 2 + (gdb) b *main+555 if $rsi == 0x400015b + Breakpoint 3 at 0x5555555553b0 + (gdb) c + Continuing. + + Breakpoint 3, 0x00005555555553b0 in main () + (gdb) x/10gx $rbp-0x90 + 0x7fffffffdde0: 0x000000000400015b 0x0000000004007004 + 0x7fffffffddf0: 0x0000000000007000 0x0000000000000002 + 0x7fffffffde00: 0x0000000000000000 0x0000000000000000 + 0x7fffffffde10: 0x0000000000000004 0x0000000000000000 + 0x7fffffffde20: 0x0000000000000000 0x0000000000000000 + +At pretty much every step below, I was checking in `gdb` as I went, +but I'll leave that out for brevity. + +### Checking the input length + +We'll jump straight to the first place where we read the user input. +Instead of picking apart the code before that, we can just use `gdb` +to examine the state of the program at this point. + + 0374: mov 0xe000, %r2 # Address of user input + 039a: mov 0x0, %r3 # Counter, initialized to 0 + + 03bd: mov %r2, %r1 + 03e1: mov (%r1), %r4 # Read a char from user input + 0406: jnz %r4, 043a # If it's nonzero, skip to 043a + 042b: jmp 04a1 # If it's zero, stop looping + + 043a: add %r3, 0xffff, %r3 # Decrement %r3 + 0469: add %r2, 0x1, %r2 # Increment %r2 + 0492: jmp 03bd # Repeat the loop + +At the end of this loop, `%r3` contains the number of characters in +the user input, but negated. + + 04a1: add %r3, 0xfe, %r6 + 04d0: jnz %r6, 0504 # jump to 0504 if %r3+0xfe != 0 + 04f5: jmp 0536 # jump to 0536 if %r3+0xfe == 0 + 0504: mov 0x5, %r9 + 0527: jmp 13d9 # jump to 139d if %r3+0xfe != 0 + +This code checks if the length of the user input is `0xfe`, or 254. +If it's not, it jumps down to `139d`, which writes zero to the flag +and halts: + + 13d9: mov 0xe800, %r1 + 13ff: mov 0x0, (%r1) + 1421: halt + +Thus our input needs to be exactly 254 characters long. + +### Sprinting + +Next we load some data into registers + + 0536: mov 0x0, %r2 + 0558: mov 0x0, %r3 + 057b: mov 0xf100, %r1 + 05a1: mov (%r1), %r4 # Read from 0xf100, value = 0x11 + 05c6: mov 0x1, %r5 + 05e9: mov 0x0, %r9 + +We have another chunk of code the jumps around in a pattern that looks +like a loop. The loop starts with this: + + 060c: add %r2, 0xe000, %r1 # %r2 is index into user input + 0639: mov (%r1), %r6 # read two bytes into %r6 + 065e: jnz %r6, 0692 # if lower byte is zero, break + 0683: jmp 0d97 # 0d97 is the end of the loop + 0692: add %r2, 0x1, %r # increment %r2 + +So we're looping over the user input. The byte we just read is in the +lower half of `%r6`. Now we break into cases depending on this byte. + + 06bb: add %r6, 0xff8b, %r7 + 06ea: jnz %r7, 0745 # jump to next block if input != 0x75 ('u') + 070f: mov 0xfff0, %r6 # %r6 = 0xfff0 (-16) + 0736: jmp 0945 # jump to end of section + + 0745: add %r6, 0xff8e, %r7 + 0774: jnz %r7, 07cb # jump to next block if input != 0x72 ('r') + 0799: mov 0x1, %r6 # %r6 = 0x1 (1) + 07bc: jmp 0945 # jump to end of section + + 07cb: add %r6, 0xff9c, %r7 + 07fa: jnz %r7, 0852 # jump to next block if input != 0x64 ('d') + 081f: mov 0x10, %r6 # %r6 = 0x10 (16) + 0843: jmp 0945 # jump to end of section + + 0852: add %r6, 0xff94, %r7 + 0881: jnz %r7, 08dc # jump to next block if input != 0x6c ('l') + 08a6: mov 0xffff, %r6 # %r6 = 0xffff (-1) + 08cd: jmp 0945 # jump to end of section + + # This executes if input was none of {u,r,d,l} + 08dc: mov 0x0, %r5 + 08ff: mov 0x0, %r6 + 0922: mov 0x1, %r9 + +At this point, the characters 'u','r','d','l' probably stand for "up", +"right", "down", and "left", so we're moving something around on a +grid. It looks like `%r6` holds an offset to move. Data must be +stored in rows of length 16, since we add or subtract 16 to move up or +down. If we don't enter a valid direction, we don't move at all, and +`%r5` and `%r9` are updated to remember that. + +From the next block of code, it looks like `%r4` stores a position on +the grid. The code writes and then reads memory at adjacent locations +to get just the upper byte of the position. + + 0945: add %r4, %r6, %r4 # Update the position + + 0973: mov 0xffef, %r1 + 0999: mov %r4, (%r1) + 09be: mov 0xfff0, %r1 + 09e4: mov (%r1), %r6 + + # At this point, the lower byte of %r6 is the upper byte of %r4 + # So we jump if the upper byte of the position is nonzero + 0a09: jnz %r6, 0d65 + + # ... snip ... + + 0d65: mov 0x4, %r9 + 0d88: halt + +Since we haven't written anything to the flag yet, halting here means +we lose, so we had better keep the position between 0 and 255. That +means we're on a 16×16 grid. + +Now, as expected, we're using `%r4` as an index into some memory, +which we can think of as a map. + + 0a2e: add %r4, 0xf000, %r1 # %r4 is index into mem starting at 0xf000 + 0a5c: mov (%r1), %r6 # %r6 is two bytes read from that location + + # This snippet just zeros out the high byte of %r6 + 0a81: mov 0xffef, %r1 + 0aa7: mov %r6, (%r1) + 0acc: mov 0xfff0, %r1 + 0af2: mov 0x0, (%r1) + 0b14: mov 0xffef, %r1 + 0b3a: mov (%r1), %r6 + + # Now %r6 holds the byte at 0xf000(%r4) + # Use 2 * %r6 as an index into memory at 0x7000 + 0b5f: add %r6, %r6, %r6 + 0b8d: add %r6, 0x7000, %r1 + 0bbb: mov (%r1), %r6 # Read from 0x7000+2*%r6 + 0be0: jnz %r6, 0d10 # Jump below if it's nonzero + + # ... snip ... + + 0d10: mov 0x0, %r5 + 0d33: mov 0x2, %r9 + 0d56: jmp 060c # Continue from start of loop + +From this, we see that there is an array of 256 bytes at `0xf000` that +we index by our position. Each of these is in turn an index into an +array of up to 256 16-bit integers at `0x7000`. If the lower byte of +the number we read is nonzero, we record it by setting `%r5` and +`%r9`, and jump to the next iteration. Since we never modify either +of these regions over the course of this loop, this ends up being a +round-about way of storing a 16×16 grid of booleans. Let's call this +grid the "terrain". + +We use the position one more time in the loop: + + 0c05: add %r3, 0x1, %r6 + 0c30: add %r6, 0xf102, %r1 + 0c5e: mov (%r1), %r6 # read %r6 <- 0xf103(%r3) + + 0c83: add %r6, %r4, %r6 + 0cb1: jnz %r6, 0d01 # check if pos+%r6==0 + 0cd6: add %r3, 0x1, %r3 # if true, increment %r3 + 0d01: jmp 060c # continue + +So we have an array of bytes starting at `0xf103`. We start out +reading the first one, and read the others as `%r3` gets incremented. +These are the negations of positions on the map. Once we move to this +position, we increment `%r3`. Call these positions "checkpoints". + +Now we're done with the loop. Let's see how the data we set in `%r3`, +`%r5`, and `%r9` are used + + # If %r5 == 0, you lose! + 0d97: jnz %r5, 0dcb + 0dbc: jmp 13d9 # zeros flag and halts + + # If %r3 != 9, you lose! + 0dcb: add %r3, 0xfff7, %r6 + 0dfa: jnz %r6, 0e2e + 0e1f: jmp 0e60 # jump to end of section + + 0e2e: mov 0x3, %r9 + 0e51: jmp 13d9 # zeros flag and halts + +So we see that setting `%r5` to zero means we lose. That means we +can't ever enter an invalid direction, and we can't ever step on +nonzero terrain. We also see that we need to step on exactly nine +checkpoints. + +The register `%r9` appears to hold a "reason code" for why we lose. +This could be useful for debugging later. + +There's still more code after, but for now our task is clear: we need +to map out the terrain and checkpoints, and plan our route. + +### Mapping it out + +Since the code at the very beginning appears to mess with memory at +`0x7000`, we can't just pull the terrain from the program binary. +Instead, we should run our debugger until it's done messing with +memory, but before it starts checking any input, and then dump the +memory from the right locations. In `gdb`: + + (gdb) b *main+555 if $rsi == 0x4000374 + (gdb) set logging on + (gdb) x/256bu 0x400f000 + (gdb) x/256hu 0x4007000 + (gdb) x/9bx 0x400f103 + +Now we manipulate this data into a few files, cleaning it up. First +we have the indices at `0xf000`: + + 204 176 231 123 188 192 238 58 + 252 115 129 208 122 105 132 226 + 72 227 215 89 17 107 241 179 + 134 11 137 197 191 83 101 101 + ... + +Then the values at `0x7000`: + + 1 1 0 0 1 0 1 0 + 1 1 1 0 1 0 1 1 + 1 0 1 0 1 1 1 0 + 1 1 1 1 1 0 1 0 + ... + +And finally the negated positions of the checkpoints: + + 83 01 af 49 ad c1 0f 8b e1 + +Now we run a script to draw our map: + + with open("values.txt") as f: + valuesStr = f.read() + values = [int(s) for s in valuesStr.split()] + + with open("indices.txt") as f: + indicesStr = f.read() + indices = [int(s) for s in indicesStr.split()] + + with open("checkpoints.txt") as f: + checkpointStr = f.read() + checkpoints = [0x100 - int(s, base=16) for s in checkpointStr.split()] + + for col in range(16): + print(" " + hex(col), end="") + print() + + for row in range(16): + print(hex(row), end="") + for col in range(16): + pos = row * 16 + col + if pos in checkpoints: + print(" {} ".format(checkpoints.index(pos)+1), end="") + elif values[indices[pos]] == 1: + print(" # ", end="") + else: + print(" - ", end="") + print() + +And admire the output: + + 0 1 2 3 4 5 6 7 8 9 a b c d e f + 0 # # # # # # # # # # # # # # # # + 1 # - # - - - - - # - - - - - - 9 + 2 # - # - # # # # # - # # # # # # + 3 # - - - - - - - # - # - # - - 6 + 4 # - # # # # # - # - # - # - # # + 5 # 3 # 5 # - - - - - - - - - - - + 6 # # # - # # # - # # # # # # # - + 7 # - - - # 8 - - # - - - # 1 - - + 8 # - # # # # # - # # # - # # # # + 9 # - - - - - - - # - # - - - # - + a # - # - # # # # # - # - # # # - + b # - # - # - # 4 # - - - - - - - + c # - # # # - # - # - # - # # # # + d # - - - - - - - - - # - - - - - + e # # # - # - # # # # # - # - # # + f # 7 - - # - # - - - - - # - - 2 + +We start at position 0x11, i.e. (1, 1). Now we just need to plot a +path that hits all the checkpoints in order. It turns out such a path +is exactly 254 steps long. Save it in a file called `directions`. + + $ cat <(fold directions) <(echo) + ddrrrrrrddrrrrrrrrddllrruullllllllddddllllllddddrrrrrrrruurrddrrddrrlluulluulldd + lllllllluuuurrrrrruuuuuulllllldduurrrrrrddddddllllllddddrrrrrruuddlllllluuuuuurr + uuddllddrrrrrruuuurrrrrruurrllddllllllddddllllllddddrrddllrruulluuuurrrrrruullrr + uurruuuurrrrrr + $ ./sprint < directions + Input password: + Flag: CTF{n0w_ev3n_pr1n7f_1s_7ur1ng_c0mpl3te} + +Success!