Add draft of sprint writeup

This commit is contained in:
Jay 2020-08-26 12:06:21 -07:00
parent a13d9eb135
commit 2963d46fcb
1 changed files with 695 additions and 0 deletions

695
2020/gctf/sprint/README.md Normal file
View File

@ -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!