Add draft of sprint writeup
This commit is contained in:
parent
a13d9eb135
commit
2963d46fcb
|
@ -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 <mmap@plt>
|
||||
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 <M>
|
||||
11d2: 48 89 c7 mov %rax,%rdi
|
||||
11d5: e8 86 fe ff ff callq 1060 <memcpy@plt>
|
||||
|
||||
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<l....
|
||||
11050 c83bef29 970bb38b cc9dfc05 1b67b5ad .;.).........g..
|
||||
11060 15c108d0 45452643 456df4ef bb4906ca ....EE&CEm...I..
|
||||
|
||||
So we have a bunch of null-terminated format strings packed together,
|
||||
then zeros, and then finally some binary data. We'll have fun
|
||||
deciphering those format strings later, but for now let's just see how
|
||||
this data is used.
|
||||
|
||||
The next few instructions zero out 80 bytes of memory at
|
||||
`-0x90(%rbp)`, and then initialize them. That's the size of 10
|
||||
pointers, since we're on x86_64. It is equivalent to
|
||||
|
||||
void *regs[10] = {mapped, mapped};
|
||||
|
||||
Remember that this means `regs[2]` through `regs[9]` is initialized to
|
||||
zero. We'll see why I'm calling this `regs` later.
|
||||
|
||||
Next we ask the user for a password. We read the input with the
|
||||
equivalent of
|
||||
|
||||
scanf("%255s", mapped+0xe000);
|
||||
|
||||
This is the only place this program reads input. We have control over
|
||||
255 bytes starting at `mapped+0xe000`.
|
||||
|
||||
Next we have some jumping around, which we can identify as a loop.
|
||||
The body of the loop is a single call to `sprintf`, but it takes a lot
|
||||
of code to pass the *25 arguments* that it takes. The equivalent C is
|
||||
|
||||
while (buf[0] != mapped + 0xfffe) {
|
||||
sprintf(0x6000000, // destination
|
||||
regs[0], // format string
|
||||
M + 0xf14a, // vararg 1, pointer to null character
|
||||
0, // vararg 2
|
||||
&(regs[0]), // vararg 3
|
||||
0x6000000, // vararg 4
|
||||
*((short*) regs[1]), // vararg 5
|
||||
regs[1], &(regs[1]), // varargs 6, 7
|
||||
regs[2], &(regs[2]), // varargs 8, 9
|
||||
regs[3], &(regs[3]), // varargs 10, 11
|
||||
regs[4], &(regs[4]), // varargs 12, 13
|
||||
regs[5], &(regs[5]), // varargs 14, 15
|
||||
regs[6], &(regs[6]), // varargs 16, 17
|
||||
regs[7], &(regs[7]), // varargs 18, 19
|
||||
regs[8], &(regs[8]), // varargs 20, 21
|
||||
regs[9], &(regs[9]) // varargs 22, 23
|
||||
);
|
||||
}
|
||||
|
||||
Clearly, what exactly this call can do depends on the format string
|
||||
pointed to by `regs[0]`. This is initialized to `mapped`, where
|
||||
our list of format strings starts. We'll get into this in the next
|
||||
section.
|
||||
|
||||
Last, our program checks if the 16-bit short at `mapped+0xe800` is
|
||||
zero. If it is, it quits, and if it's not, it prints the flag from
|
||||
memory at `mapped+0xe800`.
|
||||
|
||||
## `sprintf` is Turing complete
|
||||
|
||||
From our analysis so far, we haven't seen any direct reference to the
|
||||
user input (at `mapped+0xe000`) or the flag (at `mapped+0xe800`), so
|
||||
the format strings must be doing all the heavy lifting.
|
||||
|
||||
We read the strings using `gdb`, by examining the memory at
|
||||
`0x4000000`. We find there are 146 strings, and save them to a file:
|
||||
|
||||
(gdb) b *main+555
|
||||
(gdb) set logging on
|
||||
(gdb) x/146s 0x4000000
|
||||
|
||||
Let's take apart the first one:
|
||||
|
||||
%1$00038s%3$hn%1$65498s%1$28672s%9$hn
|
||||
|
||||
In order:
|
||||
|
||||
1. `%1$00038s` writes vararg 1 as a string, and pads it with
|
||||
spaces to ensure the length is at least 38. Since this argument is
|
||||
an empty string, it just writes 38 spaces.
|
||||
2. `%3$hn` doesn't write anything, but it checks the number of
|
||||
characters written so far, and writes it in the `short int` pointed
|
||||
to by vararg 3. That is, it writes it to the lower two bytes of
|
||||
`regs[0]`.
|
||||
3. `%1$65498s` writes 65498 spaces, so the total number of spaces so
|
||||
far is 65536, or `0x10000`.
|
||||
4. `%1$28672s` writes 28672 spaces, or `0x7000` spaces.
|
||||
5. `%9$hn` counts the number of characters written so far, and writes
|
||||
it to the `short int` pointed to by vararg 9. That is, the lower
|
||||
two bytes of `regs[2]`. The number of characters is `0x17000`, but
|
||||
since we're only writing two bytes, we're effectively writing
|
||||
`0x7000`.
|
||||
|
||||
At the end of this operation, `regs[0]` is `0x4000026` and `regs[2]`
|
||||
is `0x7000`. It just so happens that `0x4000026` is the address of
|
||||
the next format string, so on the next iteration of the loop we'll use
|
||||
that string instead. Let's call this operation `mov 0x7000, %r2`.
|
||||
|
||||
So far:
|
||||
|
||||
- We can write an arbitrary number of spaces with `%1$_____s`.
|
||||
- We can store the number of spaces written so far, modulo `0x10000`, to
|
||||
`regs[_]` with `%_$hn`.
|
||||
- By manipulating `regs[0]`, we can control which format string we use
|
||||
next.
|
||||
|
||||
So really, what's going on is this: We have a virtual machine with 10
|
||||
registers, the instructions for this machine are `sprintf` format
|
||||
strings, and the first register is the program counter. Lets call the
|
||||
registers `%r0, %r1, ..., %r9`. We've allocated 8 bytes for each
|
||||
register, but in reality, we only ever write to the lower 2 bytes, and
|
||||
the registers we read from always have zeros in all but the lower two
|
||||
bytes (we do not read from `%r0` or `%r1`, as we'll see). The
|
||||
registers `%r0` and `%r1` are initialized to `0x4000000`, so we can
|
||||
use them to reference memory locations `0x4000000` to `0x400ffff`. We
|
||||
can think of each of these registers as only being two bytes, with a
|
||||
"virtual" address space of `0x0000` to `0xffff`.
|
||||
|
||||
|
||||
At this point, our strategy is to understand what types of
|
||||
instructions we'll encounter, and then write a disassembler for this
|
||||
language, so we can reason about this embedded program.
|
||||
|
||||
### Reading from registers
|
||||
|
||||
The next format string shows us a new trick:
|
||||
|
||||
%1$00074s%3$hn%1$65462s%1$*8$s%7$hn
|
||||
|
||||
Again, `%1$00074s%3$hn` sets the program counter to the next
|
||||
instruction, and `%1$65462s` ensures the number of spaces written so
|
||||
far is 0 modulo `0x10000`. `%1$*8$s` is new. This writes a number of
|
||||
spaces depending on vararg 8, which is `regs[2]`. Then it saves this
|
||||
number to the `short int` pointed to by vararg 7, which is `regs[1]`.
|
||||
Effectively, this is `mov %r2, %r1`.
|
||||
|
||||
### Reading from memory
|
||||
|
||||
The 5th variadic argument to sprintf is `*((short*) regs[1])`. That
|
||||
is, it interprets `regs[1]` as a pointer to a short, and dereferences
|
||||
it. Since `regs[1]` was initialized to `0x4000000` and we can modify
|
||||
the lower two bytes of it, this allows us to read from memory
|
||||
locations `0x4000000` to `0x400ffff`. To do this, we use `%1$*5$s`,
|
||||
like in this example:
|
||||
|
||||
%1$00347s%3$hn%1$65189s%1$*5$s%15$hn
|
||||
|
||||
We'll write this as `mov (%r1), %r5`.
|
||||
|
||||
### Addition
|
||||
|
||||
By concatenating strings of spaces, we can perform addition. For
|
||||
example,
|
||||
|
||||
%1$00149s%3$hn%1$65387s%1$*8$s%1$2s%7$hn
|
||||
|
||||
This prints a number of spaces based on `%r2`, then prints two more
|
||||
spaces, and saves the total count in `%r1`. We'll call this `add %r2,
|
||||
0x2, %r1`.
|
||||
|
||||
We can also add two registers, like
|
||||
|
||||
%1$00264s%3$hn%1$65272s%1$*10$s%1$*10$s%17$hn
|
||||
|
||||
This is `add %r3, %r3, %r6`.
|
||||
|
||||
### Unconditional jumping
|
||||
|
||||
To do an unconditional jump, all we have to do is set the program
|
||||
counter, `%r0`, to the desired value. For example,
|
||||
|
||||
%1$00430s%3$hn
|
||||
|
||||
This jumps to the instruction at `0x4000000+430`, or `0x40001ae`.
|
||||
We'll write this as `jmp 01ae`.
|
||||
|
||||
### Branching
|
||||
|
||||
The most complex type of instruction is branching. Here's an example
|
||||
of one:
|
||||
|
||||
%14$c%1$00419s%2$c%4$s%1$65499s%3$hn
|
||||
|
||||
Breaking this down:
|
||||
|
||||
1. `%14$c` writes the lowest byte of `%r5`.
|
||||
2. `%1$00419s` writes 419 spaces.
|
||||
3. `%2$c` writes a null byte.
|
||||
4. `%4$s` writes the string at `0x6000000`, i.e. the string we're in
|
||||
the middle of writing to. If the lower byte of `%r5` is zero, this
|
||||
writes nothing, but if it's nonzero, this writes 420 bytes.
|
||||
5. `%1$65499s` writes 65499 bytes.
|
||||
6. `%3$hn` stores the number of bytes written in the program counter.
|
||||
|
||||
So if the lower byte of `%r5` is zero, we jump to
|
||||
`1+419+1+65499=0x10180`, or really `0x0180` since we only keep the
|
||||
lower two bytes. If the lower byte is nonzero, we jump to
|
||||
`1+419+1+420+65499=0x10324`, or really `0x0324`. It happens that the
|
||||
instruction after this one is at `0x180`, so it would make sense to
|
||||
call this instruction `jnz %r5, 0324`.
|
||||
|
||||
### Writing a disassebler
|
||||
|
||||
Taking our output from gdb from before and running it through `sed`
|
||||
gives us a file with a format string on each line:
|
||||
|
||||
$ sed 's/.*"\(.*\)"/\1/' gdb.txt > 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!
|
Loading…
Reference in New Issue