Defusing a Bomb That Wanted to Phone Home

The CS:APP bomb lab, defused off-campus: an LD_PRELOAD shim, a seventy-line fake grader, and a walk through all six phases plus the secret one.

C x86-64 Reverse Engineering Linux Internals Networking

The CS:APP Bomb Lab is a rite of passage. You get an ELF binary and six obscure phases to solve by reading x86-64 assembly. Detonate the wrong input, and the bomb prints BOOM!!!, reports you to the instructor, hurts your feelings, and makes you feel bad.

My particular copy, however, was convinced it would only run on the course server, abc123.cs.uni.edu. Running it on my own machine produced:

Initialization error:
Running on an illegal host

Which is, frankly, rude. This post is the log of the day I spent convincing it otherwise, then defusing it.

I set myself two ground rules. The first was to use only the tools allowed in the lab. This means gdb, objdump, strings, plus whatever was needed to build the network shim, which came out to gcc and python3. No decompilers, no third-party cheats. The second was more of a disclaimer than a rule: I do not condone cheating. I performed this bypass after completing the lab in class, and found that bypassing the host check was the only way to review the lab on my own machine because it was locked to the university's servers. End PSA.

Triage

The first useful question to ask any binary is what kind of binary it is:

$ file bomb
bomb: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
  dynamically linked, ..., with debug_info, not stripped

The "not stripped" and "with debug_info" were not a surprise. The instructor's readme promised as much, which means objdump -t bomb hands over the full symbol table: phase_1, phase_2, phase_3, phase_4, phase_5, phase_6, secret_phase, func4, fun7, explode_bomb, send_msg, submitr, initialize_bomb. The bomb has labeled every door it wants me not to open. I will not look a gift horse in the mouth.

I dumped the disassembly and the strings once and worked from the static files thereafter:

$ objdump -d bomb > work/bomb.asm
$ strings bomb   > work/bomb.strings

strings alone is worth running before any real work; it tends to hand you the first phase on a plate. I will come back to that.

Finding the gate

initialize_bomb is where the "illegal host" grievance is issued. The relevant fragment:

1bd9: mov  %rsp,%rdi
1bdc: mov  $0x40,%esi
1be1: call gethostname@plt
1be6: test %eax,%eax
1be8: jne  ...                  # gethostname failed
1bea: mov  %rsp,%rsi
1bed: lea  0x3541(%rip),%rdi    # userid
1bf4: call strcasecmp@plt
1bf9: test %eax,%eax
1bfb: jne  ...                  # "Running on an illegal host"
1bfd: lea  0x40(%rsp),%rdi
1c02: call init_driver

Three separate conditions have to hold for initialization to succeed, and it is worth being precise about what each one is actually checking. gethostname() must return something (anything) successfully. strcasecmp(hostname, userid) must return zero, which is subtler than "hostname equals some FQDN"; the comparison target is a baked-in userid string, not a DNS name. A glance at the data section makes this concrete:

5120  374a4e7a 427a4550 5a6a4831 4d614333   <conveniently placed password string>
5130  41366443 00746372 32333100           A6dC.<conveniently placed username>.

user_password and userid sit adjacent, helpfully labeled. So the hostname has to be <conveniently placed username>, which is also, as it turns out, my user ID on the course server; the bomb is per-student. The last condition is that init_driver() has to succeed, and that function looks a lot like a connectivity probe: socket, gethostbyname("abc123.cs.uni.edu"), connect to port 0xae69 in network byte order (which, reading little-endian-backwards with appropriate squinting, is port 27054), then close. No bytes exchanged. Just "is the server reachable."

The same submitr function shows up later, doing a real HTTP GET, which the bomb calls every time a phase is defused or exploded. So the full network surface is narrow: gethostname must return the userid, gethostbyname("abc123.cs.uni.edu") must resolve to something reachable, connect(... :27054) must succeed, and the HTTP response body to a submitr call must be literally "OK". Three libc functions and one TCP server. Not so bad.

The shim

I went with the combined approach: LD_PRELOAD to lie about gethostname and gethostbyname, plus a small Python server on 127.0.0.1:27054 to swallow the HTTP submissions.

The preload is boring in a good way:

int gethostname(char *name, size_t len) {
    const char *h = "<conveniently placed username>";
    size_t n = strlen(h);
    if (len <= n) return -1;
    memcpy(name, h, n + 1);
    return 0;
}

struct hostent *gethostbyname(const char *name) {
    static uint32_t addr;
    static char *addrs[2];
    static struct hostent he;
    addr = htonl(INADDR_LOOPBACK);
    addrs[0] = (char *)&addr; addrs[1] = NULL;
    he.h_addrtype  = AF_INET;
    he.h_length    = 4;
    he.h_addr_list = addrs;
    return &he;
}

Because both hooks point everything at loopback, no connect override is needed; the syscall succeeds against our own listener.

The server side is in shim/fake_server.py, and its job is to speak just enough HTTP/1.0 that submitr's status-line sscanf and header loop are satisfied, to return a body that submitr's final strcmp(..., "OK") will accept, to handle the init_driver probe (which opens a socket and immediately closes without writing anything) without blowing up, to log every submission for post-hoc review (because part of the point of this exercise is seeing what the bomb was trying to tell the instructor), and to be small enough that I don't have to think about it. Python's standard library hit all four at once. socket + threading gives me a listener in under fifty lines, and there's no packaging, no pip install, no requirements.txt.

The whole server, minus logging and error-handling boilerplate:

HOST, PORT = "127.0.0.1", 27054
RESPONSE = b"HTTP/1.0 200 OK\r\n\r\nOK"    # no trailing newline - see below

def handle(conn, addr):
    data = b""
    while b"\r\n\r\n" not in data and len(data) < 8192:
        chunk = conn.recv(4096)
        if not chunk: break
        data += chunk
    if data:
        log_submission(data)
        conn.sendall(RESPONSE)
    conn.close()

def main():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind((HOST, PORT)); s.listen(16)
    while True:
        conn, addr = s.accept()
        threading.Thread(target=handle, args=(conn, addr), daemon=True).start()

Several small choices in that listing each carry some weight. SO_REUSEADDR is there because the server is restarted a lot while I iterate, and without it every restart eats a 60-second TIME_WAIT before the port is rebindable; I stopped being polite about this after the third "Address already in use." The thread-per-connection pattern is overkill for correctness; init_driver probes once and then send_msg fires twice per phase, so the traffic is a handful of short bursts. The threading earns its keep because a slow read never blocks the next probe. The read loop terminates on \r\n\r\n or 8 KiB because the bomb always sends one request line plus an empty trailer, so reading past the first blank line would only accomplish blocking until timeout; the byte ceiling is pure insurance against a malformed request. And the log captures raw bytes rather than parsed fields, because the interesting content is in the URL-encoded result=... parameter and I'd rather see %3A and %2B verbatim than trust myself to unescape correctly on the first try.

One thing that listing is quietly taking for granted is the exact RESPONSE bytes. That value took two attempts. The first version was what every HTTP-curious person would reflexively write:

HTTP/1.0 200 OK\r\n
\r\n
OK\r\n

The bomb ran, initialized, accepted phase 1's answer... and then printed, on its own line:

OK

Which was unsettling, because I hadn't asked it to. A brief stare at submitr explained it. It reads the HTTP body one line at a time with rio_readlineb, the CS:APP "robust I/O" helper, which (unlike fgets) cheerfully preserves the trailing newline in the buffer. Then it does strcmp(body, "OK"). My body was "OK\r\n", not "OK", so the comparison failed and the bomb dumped the offending buffer to stdout before deciding the server was broken.

The fix is embarrassing:

# No trailing newline. Bomb's rio_readlineb keeps the newline, then strcmp
# against literal "OK" only passes when the line is read-until-EOF.
RESPONSE = b"HTTP/1.0 200 OK\r\n\r\nOK"

The server closes the socket after writing, so rio_readlineb reads OK, hits EOF, and stops. The buffer is "OK\0". strcmp is happy. I was, at that moment, marginally happier than strcmp.

The phases

With the shim working, I could run the bomb against each candidate answer and confirm my reasoning was right.

Phase 1: strings

The assembly is almost too short to justify a code block:

phase_1:
    sub  $0x8,%rsp
    lea  0x1b1a(%rip),%rsi    # -> "Border relations ..." at 0x3150
    call strings_not_equal
    test %eax,%eax
    jne  explode_bomb
    add  $0x8,%rsp
    ret

The structure is one function call that takes two strings and returns a truth value, a test/jne pair that detonates when the return is non-zero, and nothing else. %rdi is the user's line (passed by main), %rsi is a fixed pointer into .rodata. The function name strings_not_equal removes any remaining mystery. Because the target string is a plain ASCII literal in .rodata, strings sees it without any disassembly at all:

$ strings bomb | grep -i cuba
Border relations with Cuba have never been better.

Phase 1 is always the one that makes you feel clever for about ten seconds, until you remember it is deliberately easy so students don't quit.

Answer: Border relations with Cuba have never been better.

Phase 2: six numbers with a recurrence

read_six_numbers (a helper, elsewhere in the binary) parses "%d %d %d %d %d %d" into a stack array at %rsp. The interesting fragment comes right after:

    cmpl $0x0,(%rsp)            # n[0] < 0 ?
    js   explode
    mov  %rsp,%rbp               # rbp = &n[0]
    mov  $0x1,%ebx               # ebx = loop counter, starts at 1
    jmp  .check

.next:
    add  $0x1,%ebx
    add  $0x4,%rbp               # advance one int
    cmp  $0x6,%ebx               # done after 5 iterations
    je   .exit
.check:
    mov  %ebx,%eax
    add  (%rbp),%eax             # eax = i + n[rbp]
    cmp  %eax,0x4(%rbp)          # compare eax to next int
    je   .next
    call explode

Two things pin down the shape. %rbp is initialized to %rsp and advanced by 4 each iteration, so it is a moving cursor over an int[6]. The comparison at each step is n[i+1] == i + n[i], where i is %ebx counting from 1 through 5. In plain English:

if n[0] < 0: explode
for i in 1..5:
    if n[i] != n[i-1] + i:
        explode

Starting from n[0] = 1, this gives 1, 1+1, 2+2, 4+3, 7+4, 11+5 = 1 2 4 7 11 16. Any non-negative n[0] works. The classic CS:APP seed is 0; I used 1 because the extra offset made me feel I had earned it.

Answer: 1 2 4 7 11 16

Phase 3: a switch statement in disguise

The start of phase_3 is a standard sscanf call, with the format string and two output pointers:

    lea  0x4(%rsp),%rcx         # &n[1]
    mov  %rsp,%rdx               # &n[0]
    lea  0x1d41(%rip),%rsi       # "%d %d"
    call __isoc99_sscanf
    cmp  $0x1,%eax               # sscanf returned how many?
    jle  explode                 # need at least 2 ints
    cmpl $0x7,(%rsp)             # n[0] > 7 ?
    ja   explode                 # (unsigned > catches negative too)
    mov  (%rsp),%eax
    lea  0x1ac2(%rip),%rdx       # table base = 0x31c0
    movslq (%rdx,%rax,4),%rax   # signed 32-bit table entry
    add  %rdx,%rax               # add base → absolute target
    notrack jmp *%rax

This is GCC's preferred compilation for a small switch (n[0]) with a dense case range. The key sign is the cmpl $0x7; ja guard followed by a RIP-relative load of a 4-byte signed offset, plus adding the table base back in. Eight entries (indices 0..7) means eight cases plus the default-explode.

Each target in the table is a single mov $CONST, %eax; jmp check sequence. check compares %eax to n[1]:

    mov  $0x820,%eax             # 0x820 = 2080 for n[0]==1
    jmp  .check
...
.check:
    cmp  %eax,0x4(%rsp)          # n[1] == eax ?
    jne  explode
    ret

The data at 0x31c0 as raw bytes:

31c0  a5e5ffff 4fe5ffff 6fe5ffff 76e5ffff
31d0  7de5ffff 84e5ffff 8be5ffff 92e5ffff

Each word is a signed 32-bit offset added to 0x31c0. Entry 1 is 0xffffe54f = -0x1ab1, so it targets 0x31c0 - 0x1ab1 = 0x170f, and the instruction at 0x170f is mov $0x820,%eax. Repeating this for all eight entries:

n[0] target constant n[1]
0 0x1765 $0xd5e 3422
1 0x170f $0x820 2080
2 0x172f $0x828 2088
3 0x1736 $0x928 2344
4 0x173d $0x4b1 1201
5 0x1744 $0x6f1 1777
6 0x174b $0x1153 4435
7 0x1752 $0xcb7 3255

Any row works. I picked the small one.

Answer: 1 2080

Phase 4: recursive binary search

Phase 4 is a three-part sandwich: a sscanf for two ints with the usual guards, a call to func4(n[0], 0, 14) whose return must be exactly 3, and a check that n[1] == 3:

    call __isoc99_sscanf
    cmp  $0x2,%eax
    jne  explode                 # need both ints
    cmpl $0xe,(%rsp)             # n[0] <= 14 ?
    jbe  .ok
    call explode
.ok:
    mov  $0xe,%edx               # hi = 14
    mov  $0x0,%esi               # lo = 0
    mov  (%rsp),%edi             # x = n[0]
    call func4
    cmp  $0x3,%eax
    jne  explode                 # func4 must return 3
    cmpl $0x3,0x4(%rsp)
    jne  explode                 # n[1] must be 3

The interesting work is all inside func4(x, lo, hi):

func4:
    sub  $0x8,%rsp
    mov  %edx,%eax                # eax = hi
    sub  %esi,%eax                # eax = hi - lo
    mov  %eax,%ecx
    shr  $0x1f,%ecx               # ecx = sign bit of (hi-lo)
    add  %eax,%ecx                # ecx = (hi-lo) + sign    (ceil-div trick)
    sar  $0x1,%ecx                # ecx = (hi-lo) / 2
    add  %esi,%ecx                # ecx = lo + (hi-lo)/2  == mid
    cmp  %edi,%ecx                # compare mid to x
    jg   .mid_gt
    mov  $0x0,%eax
    jl   .mid_lt
    ret                            # mid == x:  return 0
.mid_gt:
    lea  -0x1(%rcx),%edx          # hi = mid - 1
    call func4
    add  %eax,%eax                # return 2 * result
    ret
.mid_lt:
    lea  0x1(%rcx),%esi           # lo = mid + 1
    call func4
    lea  0x1(%rax,%rax,1),%eax    # return 2*result + 1
    ret

The shr 31 + add + sar 1 combo is GCC's inlined "divide by two, rounded toward zero" for signed integers. Since hi >= lo on every call here, the sign bit is zero and it collapses to a plain average: mid = (lo + hi) / 2. The three exit paths are then clear:

func4(x, lo, hi):
    mid = (lo + hi) / 2
    if   mid == x: return 0
    elif mid >  x: return 2 * func4(x, lo, mid-1)
    else:          return 2 * func4(x, mid+1, hi) + 1

Each recursion appends a bit: the left branch shifts in a 0, the right branch shifts in a 1, and the match at the leaf terminates with 0. We want the final number to be 3 = 0b11, which means the path must be right, right, then-terminate. Laid out as the implicit search tree over (lo, hi) ranges, with arrows marking the path that produces 3:

                         (0, 14)   mid=7
                        /        \
                     L (bit 0)   R (bit 1)   ─────►
                       ...            (8, 14)   mid=11
                                     /        \
                                  L (bit 0)   R (bit 1)   ─────►
                                    ...            (12, 14)   mid=13
                                                      │
                                                    x == 13
                                                   return 0

Trace in math: fun4(13, 0, 14) = 2 · fun4(13, 8, 14) + 1 = 2 · (2 · fun4(13, 12, 14) + 1) + 1 = 2 · (2 · 0 + 1) + 1 = 3. So x = 13.

Answer: 13 3 (or, to also arm the secret phase, 13 3 DrEvil- see below).

Phase 5: the lookup cipher

The phase opens with a length check and then enters a tiny transform loop:

    call string_length
    cmp  $0x6,%eax
    jne  explode                 # input must be exactly 6 chars

    mov  $0x0,%eax               # i = 0
    lea  0x197c(%rip),%rcx       # rcx = table "adguerniotvbyhmc"
.loop:
    movzbl (%rbx,%rax,1),%edx    # edx = input[i]
    and  $0xf,%edx                # keep low nibble
    movzbl (%rcx,%rdx,1),%edx    # edx = table[nibble]
    mov  %dl,0x1(%rsp,%rax,1)    # store into scratch buffer
    add  $0x1,%rax
    cmp  $0x6,%rax
    jne  .loop

    movb $0x0,0x7(%rsp)          # null-terminate
    lea  0x1(%rsp),%rdi           # scratch
    lea  0x1920(%rip),%rsi       # -> "driven"
    call strings_not_equal
    test %eax,%eax
    jne  explode

Three tells: the and $0xf narrows the character to a 4-bit index; the (%rcx,%rdx,1) is a byte-indexed table load; and the final strings_not_equal against a fixed 6-byte rodata string makes the game "produce this exact output." So for each input byte the bomb keeps only the low nibble, looks it up in a 16-byte table, and the concatenation of those six lookups must equal the target string.

The lookup table is "adguerniotvbyhmc" at 0x31e0. The target is "driven" at 0x31ae. Reading off the target letter by letter, d sits at table index 1, r at 5, i at 7, v at 10, e at 4, n at 6. Any ASCII characters whose low nibbles are 1, 5, 7, 10, 4, 6 will do. Uppercase A, E, G, J, D, F have low nibbles 1, 5, 7, A, 4, 6, so AEGJDF works. I used the lowercase version because lowercase looks less like shouting.

Answer: aegjdf

Phase 6: sort the linked list

The most substantial phase. read_six_numbers fills an int[6] on the stack, and three clearly separable passes follow.

The first pass range-checks each entry and rejects duplicates. An outer loop walks the six ints; each one is compared against every later int:

    mov  (%r13),%eax             # current int
    sub  $0x1,%eax
    cmp  $0x5,%eax                # 1..6 ? (unsigned compare)
    ja   explode

    mov  %r14,%rbx               # inner index = outer + 1
.inner:
    mov  (%r12,%rbx,4),%eax       # n[j]
    cmp  %eax,0x0(%rbp)           # n[i] == n[j] ?
    je   explode                  # duplicates explode
    add  $0x1,%rbx
    cmp  $0x5,%ebx
    jle  .inner

Subtracting 1 and then doing an unsigned cmp ... ,$5; ja is the classic compiler idiom for "1 <= x <= 6". Combined with the all-pairs inequality, the six numbers must be a permutation of {1..6}.

The second pass builds a pointer array from that permutation: for each n[i], walk the linked list n[i]-1 steps starting at node1 and save the resulting pointer.

    mov  (%rsp,%rsi,4),%ecx       # k = n[i]
    mov  $0x1,%eax
    lea  node1(%rip),%rdx
    cmp  $0x1,%ecx
    jle  .save
.walk:
    mov  0x8(%rdx),%rdx           # rdx = rdx->next
    add  $0x1,%eax
    cmp  %ecx,%eax
    jne  .walk
.save:
    mov  %rdx,0x20(%rsp,%rsi,8)   # pointers[i] = walked node

The 0x8(%rdx) offset confirms the struct layout: 4-byte value, 4-byte padding, 8-byte next pointer. After six iterations the stack holds pointers[0..5], each already pointing at a specific node.

The third pass patches next pointers so the list visits the nodes in the user-specified order, then walks that list asserting each value is >= the next:

    mov  0x20(%rsp),%rbx          # pointers[0]
    mov  0x28(%rsp),%rax
    mov  %rax,0x8(%rbx)           # pointers[0]->next = pointers[1]
    ... (same for pairs 1-2, 2-3, 3-4, 4-5) ...
    movq $0x0,0x8(%rax)           # pointers[5]->next = NULL

    mov  $0x5,%ebp
.chain:
    mov  0x8(%rbx),%rax           # next node
    mov  (%rax),%eax              # next->value
    cmp  %eax,(%rbx)              # current->value >= next->value ?
    jge  .advance
    call explode
.advance:
    mov  0x8(%rbx),%rbx
    sub  $0x1,%ebp
    jne  .chain

The jge is a signed comparison, and the direction is "current >= next", so the list must be sorted non-increasing. Reading the nodes:

node value
1 4405
2 1558
3 1479
4 3940
5 2799
6 2276

The list as the bomb stores it, before any user input touches it:

  ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
  │ node1   │──►│ node2   │──►│ node3   │──►│ node4   │──►│ node5   │──►│ node6   │──► NULL
  │ v=4405  │   │ v=1558  │   │ v=1479  │   │ v=3940  │   │ v=2799  │   │ v=2276  │
  └─────────┘   └─────────┘   └─────────┘   └─────────┘   └─────────┘   └─────────┘

Sorted descending by value, the nodes become 4405, 3940, 2799, 2276, 1558, 1479, which is permutation 1, 4, 5, 6, 2, 3. The second pass walks n[i]-1 steps down the original list to pick each node, and the third rewires ->next pointers so the traversal visits them in that order:

  ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐
  │ node1   │──►│ node4   │──►│ node5   │──►│ node6   │──►│ node2   │──►│ node3   │──► NULL
  │ v=4405  │   │ v=3940  │   │ v=2799  │   │ v=2276  │   │ v=1558  │   │ v=1479  │
  └─────────┘   └─────────┘   └─────────┘   └─────────┘   └─────────┘   └─────────┘
        4405   ≥   3940   ≥   2799    ≥    2276   ≥   1558    ≥    1479       ✓

The jge at each hop along that new chain passes at every step, the bomb is content, and the third pass falls out the bottom.

Answer: 1 4 5 6 2 3

The secret phase: a binary search tree

The secret phase exists. The evidence is in phase_defused, which waits until all six phases are defused and then secretly re-parses input_strings[3] (phase 4's input) with "%d %d %s", comparing the string to "DrEvil". If it matches, secret_phase() is called. So the price of admission is writing phase 4 as 13 3 DrEvil.

secret_phase then reads a line, strtols it, requires the result in [1, 1001], and calls fun7(&n1, x). We need fun7 to return 7:

secret_phase:
    call read_line
    mov  %rax,%rdi
    mov  $0xa,%edx               # base 10
    mov  $0x0,%esi
    call strtol
    mov  %eax,%ebx
    sub  $0x1,%eax
    cmp  $0x3e8,%eax              # (x-1) <= 1000 ?
    ja   explode                  # so x is in [1, 1001]

    mov  %ebx,%esi
    lea  n1(%rip),%rdi
    call fun7
    cmp  $0x7,%eax
    jne  explode

fun7 itself is the interesting part:

fun7:
    test %rdi,%rdi
    je   .null                    # NULL node: return -1
    mov  (%rdi),%edx              # edx = node->value
    cmp  %esi,%edx                # compare value to target
    jg   .go_left
    mov  $0x0,%eax
    jne  .go_right                # not-greater and not-equal → less
    ret                            # value == target: return 0
.go_left:
    mov  0x8(%rdi),%rdi           # node = node->left (offset 8)
    call fun7
    add  %eax,%eax                # return 2*r
    ret
.go_right:
    mov  0x10(%rdi),%rdi          # node = node->right (offset 16)
    call fun7
    lea  0x1(%rax,%rax,1),%eax    # return 2*r + 1
    ret
.null:
    mov  $0xffffffff,%eax
    ret

Three things drop out of that. Each node is {int value; int pad; node *left; node *right;}: 24 bytes, with left at +8 and right at +16. The branching is value > target → left, value < target → right, value == target → stop, which is the canonical BST lookup. And the return arithmetic encodes the path: a left step shifts in a 0, a right step shifts in a 1, and the match terminates with 0.

So, two recursion-heavy passes later, I reconstructed the tree from the .data section:

                         n1 (36)
                    /              \
                 n21 (8)         n22 (50)
                /     \           /      \
            n31(6)  n32(22)  n33(45)   n34(107)
            /  \     /  \     /   \      /    \
         1   7   20  35   40  47   99  1001

7 = 0b111 = right, right, right, match. From n1(36) rightward: n22(50)n34(107)n48(1001). The target must equal the node value at the stopping point, so x = 1001.

Answer: 1001

The final answer file

Border relations with Cuba have never been better.
1 2 4 7 11 16
1 2080
13 3 DrEvil
aegjdf
1 4 5 6 2 3
1001

And the actual run, against my local shim:

$ LD_PRELOAD=./shim/libbombshim.so ./bomb solutions.txt
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Phase 1 defused. How about the next one?
That's number 2.  Keep going!
Halfway there!
So you got that one.  Try this one.
Good work!  On to the next...
Curses, you've found the secret phase!
But finding it and solving it are quite different...
Wow! You've defused the secret stage!
Congratulations! You've defused the bomb!
Your instructor has been notified and will verify your solution.

Or, more accurately, my instructor has been notified, as soon as I submit this on a machine where the DNS isn't a lie.

Things I'd do differently

Two small regrets, filed for next time. The first is that I should have inspected rio_readlineb before writing the fake server's response. I had the disassembly; I just skimmed past it. It cost me one debugging cycle, which is not a lot, but is more than zero. The second is that I started with objdump dumps and strings as static artifacts. Using gdb from the start, even just as an interactive disassembler with disas phase_4, x/8xw &n1, and friends, would have been faster than repeatedly going back to the saved .asm file. The lab rules allowed it; I just like objdump. Force of habit.

On the other hand, the preload-plus-loopback-server split was exactly right. The preload is pure name redirection, five lines of behavior. The server is pure protocol. Nothing in the bomb had to be patched. The cost is that I wrote a fake grading server. The benefit is that I wrote a fake grading server.

Repo

The harness (shim, fake grader, runner, README, and a demo bomb) lives at github.com/Enginerd-2019/bomblab-harness. What is there: the LD_PRELOAD library, the loopback HTTP grader, the orchestration script, an .env.example preconfigured for the demo, the README, and a demo-bomb/ directory containing a non-course binary built from the public CS:APP sources with fake credentials baked in (demouser, demohost.changeme.edu, port 12345) plus the solution that defuses it. Cloning the repo and running ./run.sh demo-bomb/solution.txt exercises the entire pipeline end-to-end on a fresh machine with no coursework involved.

The demo bomb is deliberately not the same specimen the post walks through. CS:APP bombs are per-student, with different baked-in userid, different phase constants, different .rodata strings, and different jump-table offsets, so a reader who clones the repo and disassembles the demo will see different numbers than the writeup, which is the same thing they would see running the harness against their own course-issued bomb. The technique is in the post; the demo is a worked instance of that technique applied to a different specimen, and the mismatch is the point.

What is not there, deliberately: the per-student bomb this post walks through, the .env with a real userid, the compiled .so, the submission logs, the write-up you are currently reading, and the solutions file for the real bomb. All gitignored. If you want to defuse your own course-issued bomb, drop it at ./bomb and edit .env; the userid baked into it is the only thing the shim actually needs to know.

The lab disarmed; the host unbeknownst; the instructor, in the loopback sense, notified.

Comments (0)

No comments yet. Be the first to comment.

Leave a Comment