NSEC2020 "Hack the Time" CTF Writeup


Note: The challenge binary is available on GitHub.

This is a walk-through of "Hack The Time" a 4-point challenge from the 2020 NSEC CTF. It was a great challenge that required static analysis, dynamic analysis, web skills, Go knowledge, and some creative Bash foo to solve. This was team effort with help from two of my teammates (finding the arg and some bash foo).

Props to @becojo for a great challenge!


Classes are sooooooo long and I’m bored af. There is a rumour that the student that developed the service that controls the school’s clock added a backdoor to change the time. It is still running to this day… It would be really cool if you can find it :wink: I was able to get a copy of the binary if that helps you: https://dl.nsec/time_server [note binary included in this repo] The school server is running at http://time-server.ctf:8080. 



Browsing to http://time-server.ctf:8080/ displays a simple clock with the current time.

The source of the page is below:

<!doctype html>
<html lang="en">
   <meta charset="UTF-8"/>
   <link href="/styles.css" rel="stylesheet"/>
   <div id="clock">
      <svg viewBox="0 0 40 40">
      <circle cx="20" cy="20" r="19" />
      <g class="marks">
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />
         <line x1="15" y1="0" x2="16" y2="0" />

      <line x1="0" y1="0" x2="9" y2="0" class="hour" />
      <line x1="0" y1="0" x2="13" y2="0" class="minute" />
      <line x1="0" y1="0" x2="16" y2="0" class="seconds" />
      <circle cx="20" cy="20" r="0.7" class="pin" />

      <text x="-3" y="0"></text>

   <div id="time"></div>

   <script src="/script.js"></script>


async function main() {
    var res = await fetch('/time.json');
    var {0: j} = await res.json();

    document.querySelector('svg text').textContent = j;

    var svg = document.querySelector('svg');
    var date = new Date(Date.parse(j));

    svg.style.setProperty('--start-seconds', date.getSeconds());
    svg.style.setProperty('--start-minutes', date.getMinutes());
    svg.style.setProperty('--start-hours', date.getHours() % 12);


setInterval(main, 5000);

This script fetches /time.json every 5 seconds. /time.json is an array of the current time, such as:

["2020-05-16 09:53:24"]

styles.css just contains CSS to make the clock. I don't have the original CSS due to how I solved the challenge and local testing.

That is the extent of the website; no obvious backdoors exposed. Let's look at the binary.

Static Analysis

Opening the binary in IDA Pro we see lots of go_ and net_http__ functions.

So we have a Go binary. It's a mess, but golang_loader_assist helps resolve function names and strings, and generally makes it a little more pleasant to work with.

You can see in the screenshot above of main.main there are two handlers being registered, /time.json and /, which makes sense given what we saw on the challenge site. There are no other handlers, so the backdoor isn't as easy as /cmd or similar.

Let's look at the /time.json handler (main_timeHandler).

It gets the current time (time_Now), formats it (time_Time_Format), coverts it to a string (runtime_convTstring), and finally prints it (fmt_Fprintf). Nothing jumps out as being a backdoor, so let's move on for now. We can come back and reverse engineer this function in more depth if we don't find anything else.

How about main_root the handler for /?

Nothing super interesting. Follow the rabbit hole down main_root_func1.

Keep going into main_e

Well well well. net_http__Request_FormValue with a strange string mlaasdkfasldkfm. This feels like a backdoor. How can we send that string to have it read by FormValue? To the docs!

// FormValue returns the first value for the named component of the query. 
// POST and PUT body parameters take precedence over URL query string values. 
// FormValue calls ParseMultipartForm and ParseForm if necessary and ignores 
// any errors returned by these functions. 
// If key is not present, FormValue returns the empty string. 
// To access multiple values of the same key, call ParseForm and // then inspect Request.Form directly. 

Since we know that / works with GET, we must have to send that string as "name component of the query" aka a GET parameter. The string will be the "key" and whatever it's value is will be returned by FormValue. Something like:

curl -v 

Ok so that should get us to the `jbe short loc_76320C` test. What is that checking? Since this is a 64bit ELF, we can see if IDA's pseudocode helps any:

If something is <=1 it will panic, but we're not quite sure what that something is. Looking further to line 17, there are two checks against something related to `os_Args` (os_Args + 24 and os_Args +16). If all three of these checks pass, the program will call main_rr. Let's look at main_rr, with a mental note to come back and figure out what these checks are for.

As soon as we see os_exec_Command:

At this point we're confident we have found the backdoor, but it looks like it has additional checks and it isn't obvious what those checks are for. It's time for dynamic analysis.

Dynamic Analysis

Before you start any GDB adventures with Go binaries, the Go documentation on GDB integration is required reading.

For this challenge my .gdbinit file contained the following:

source ~/peda/peda.py 
set auto-load safe-path $debugdir:$datadir/auto-load:/usr/local/go/src/runtime source /usr/local/go/src/runtime/runtime-gdb.py 
set follow-fork-mode parent # this keeps GDB on the main binary if it forks any children (shells, etc) 

I am using peda to make GDB a little more friendly.

Let's dig into the binary. First, get it open in GDB: gdb ./time_server

Now we load the function of interest and set a break point at it.

gdb-peda$ l main.e
16      in /app/main.go
gdb-peda$ b main.e
Breakpoint 1 at 0x763160: file /app/main.go, line 21.
gdb-peda$ r
Starting program: /mnt/hgfs/nsec/time/time_server 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff5b6d700 (LWP 33773)]
[New Thread 0x7ffff536c700 (LWP 33774)]
[New Thread 0x7ffff4b6b700 (LWP 33775)]
[New Thread 0x7fffeffff700 (LWP 33776)]
[New Thread 0x7fffef7fe700 (LWP 33777)]
[New Thread 0x7fffeeffd700 (LWP 33778)]

At this point the time_server is running and waiting for connections on port 8080. Based on our static analysis, we know it will look for a form value of `mlaasdkfasldkfm` but not sure what happens after. Let's find out! In a different terminal, send a request like so, using a dummy value of test for the form key discovered earlier:

$ curl -v
*   Trying
* Connected to ( port 8080 (#0)
> GET /?mlaasdkfasldkfm=test HTTP/1.1
> Host:
> User-Agent: curl/7.68.0
> Accept: */*

Now this request hangs open, as the time_server is paused at our break point. Switching back to the first terminal we see:

Thread 3 "time_server" hit Breakpoint 1, main.e (r=0xc000134500) at /app/main.go:21
21      in /app/main.go

Perfect, let's step forward to the values being loaded before the first comparison by disassembling main.e and setting a breakpoint at the mov before the cmp after the call to FormValue. Note: you may have to c (continue) a few times before breakpoint 2 hits.

gdb-peda$ disas main.e
Dump of assembler code for function main.e:
=> 0x0000000000763160 <+0>:     mov    rcx,QWORD PTR fs:0xfffffffffffffff8
   0x0000000000763169 <+9>:     cmp    rsp,QWORD PTR [rcx+0x10]
   0x000000000076316d <+13>:    jbe    0x76321a <main.e+186>
   0x0000000000763173 <+19>:    sub    rsp,0x30
   0x0000000000763177 <+23>:    mov    QWORD PTR [rsp+0x28],rbp
   0x000000000076317c <+28>:    lea    rbp,[rsp+0x28]
   0x0000000000763181 <+33>:    mov    rax,QWORD PTR [rsp+0x38]
   0x0000000000763186 <+38>:    mov    QWORD PTR [rsp],rax
   0x000000000076318a <+42>:    lea    rcx,[rip+0xd9133]        # 0x83c2c4
   0x0000000000763191 <+49>:    mov    QWORD PTR [rsp+0x8],rcx
   0x0000000000763196 <+54>:    mov    QWORD PTR [rsp+0x10],0xf
   0x000000000076319f <+63>:    call   0x6c3610 <net/http.(*Request).FormValue>
   0x00000000007631a4 <+68>:    mov    rax,QWORD PTR [rsp+0x20]
   0x00000000007631a9 <+73>:    mov    rcx,QWORD PTR [rsp+0x18]
   0x00000000007631ae <+78>:    mov    rdx,QWORD PTR [rip+0x3fa883]        # 0xb5da38 <os.Args+8>
   0x00000000007631b5 <+85>:    mov    rbx,QWORD PTR [rip+0x3fa874]        # 0xb5da30 <os.Args>
   0x00000000007631bc <+92>:    cmp    rdx,0x1
   0x00000000007631c0 <+96>:    jbe    0x76320c <main.e+172>
   0x00000000007631c2 <+98>:    mov    rdx,QWORD PTR [rbx+0x10]
   0x00000000007631c6 <+102>:   cmp    QWORD PTR [rbx+0x18],rax
   0x00000000007631ca <+106>:   je     0x7631d6 <main.e+118>
   0x00000000007631cc <+108>:   mov    rbp,QWORD PTR [rsp+0x28]
   0x00000000007631d1 <+113>:   add    rsp,0x30
   0x00000000007631d5 <+117>:   ret    
   0x00000000007631d6 <+118>:   mov    QWORD PTR [rsp],rcx
   0x00000000007631da <+122>:   mov    QWORD PTR [rsp+0x8],rdx
   0x00000000007631df <+127>:   mov    QWORD PTR [rsp+0x10],rax
   0x00000000007631e4 <+132>:   call   0x402dc0 <runtime.memequal>
   0x00000000007631e9 <+137>:   cmp    BYTE PTR [rsp+0x18],0x0
   0x00000000007631ee <+142>:   je     0x7631cc <main.e+108>
   0x00000000007631f0 <+144>:   mov    rax,QWORD PTR [rsp+0x38]
   0x00000000007631f5 <+149>:   mov    rcx,QWORD PTR [rax+0x8]
   0x00000000007631f9 <+153>:   mov    rax,QWORD PTR [rax]
   0x00000000007631fc <+156>:   mov    QWORD PTR [rsp],rax
   0x0000000000763200 <+160>:   mov    QWORD PTR [rsp+0x8],rcx
   0x0000000000763205 <+165>:   call   0x762fe0 <main.rr>
   0x000000000076320a <+170>:   jmp    0x7631cc <main.e+108>
   0x000000000076320c <+172>:   mov    eax,0x1
   0x0000000000763211 <+177>:   mov    rcx,rdx
   0x0000000000763214 <+180>:   call   0x460370 <runtime.panicIndex>
   0x0000000000763219 <+185>:   nop
   0x000000000076321a <+186>:   call   0x45da80 <runtime.morestack_noctxt>
   0x000000000076321f <+191>:   jmp    0x763160 <main.e>
End of assembler dump.
gdb-peda$ b *0x00000000007631ae
Breakpoint 2 at 0x7631ae: file /app/main.go, line 22.
gdb-peda$ c
RAX: 0x0 
RBX: 0x1 
RCX: 0x0 
RDX: 0x0 
RSI: 0xc0001440ae ("asldkfm HTTP/1.1")
RDI: 0x83c2cc ("asldkfmms: gomaxprocs=negative offsetnegative updatenetwork is downno dot in fieldno medium foundno such processnon-minimal tagnot a directorynshortparallel;ntriangleright;num_symbols: 1\nrecord overfl"...)
RBP: 0xc00013cb40 --> 0xc00013cb80 --> 0xc00013cba8 --> 0xc00013cc08 --> 0xc00013cc38 --> 0xc00013cfb8 (--> ...)
RSP: 0xc00013cb18 --> 0xc000134500 --> 0xc0001440a0 ("GET /?mlaasdkfasldkfm HTTP/1.1")
RIP: 0x7631ae (<main.e+78>:     mov    rdx,QWORD PTR [rip+0x3fa883]        # 0xb5da38 <os.Args+8>)
R8 : 0x1 
R9 : 0x12 
R10: 0x8b5768 --> 0x807060504030201 
R11: 0x1 
R12: 0xffffffffffffffff 
R13: 0x12 
R14: 0x11 
R15: 0x200
EFLAGS: 0x202 (carry parity adjust zero sign trap INTERRUPT direction overflow)
   0x76319f <main.e+63>:        call   0x6c3610 <net/http.(*Request).FormValue>
   0x7631a4 <main.e+68>:        mov    rax,QWORD PTR [rsp+0x20]
   0x7631a9 <main.e+73>:        mov    rcx,QWORD PTR [rsp+0x18]
=> 0x7631ae <main.e+78>:        mov    rdx,QWORD PTR [rip+0x3fa883]        # 0xb5da38 <os.Args+8>
   0x7631b5 <main.e+85>:        mov    rbx,QWORD PTR [rip+0x3fa874]        # 0xb5da30 <os.Args>
   0x7631bc <main.e+92>:        cmp    rdx,0x1
   0x7631c0 <main.e+96>:        jbe    0x76320c <main.e+172>
   0x7631c2 <main.e+98>:        mov    rdx,QWORD PTR [rbx+0x10]
0000| 0xc00013cb18 --> 0xc000134500 --> 0xc0001440a0 ("GET /?mlaasdkfasldkfm HTTP/1.1")
0008| 0xc00013cb20 --> 0x83c2c4 ("mlaasdkfasldkfmms: gomaxprocs=negative offsetnegative updatenetwork is downno dot in fieldno medium foundno such processnon-minimal tagnot a directorynshortparallel;ntriangleright;num_symbols: 1\nrecor"...)
0016| 0xc00013cb28 --> 0xf 
0024| 0xc00013cb30 --> 0x0 
0032| 0xc00013cb38 --> 0x0 
0040| 0xc00013cb40 --> 0xc00013cb80 --> 0xc00013cba8 --> 0xc00013cc08 --> 0xc00013cc38 --> 0xc00013cfb8 (--> ...)
0048| 0xc00013cb48 --> 0x7635ed (<main.root.func1+61>:  mov    rax,QWORD PTR [rsp+0x20])
0056| 0xc00013cb50 --> 0xc000134500 --> 0xc0001440a0 ("GET /?mlaasdkfasldkfm HTTP/1.1")
Legend: code, data, rodata, value

Thread 3 "time_server" hit Breakpoint 2, 0x00000000007631ae in main.e (r=0xc000134500) at /app/main.go:22
22      in /app/main.go
gdb-peda$ x/1x 0xb5da38
0xb5da38 <os.Args+8>:   0x0000000000000001

The last command run above examines 1 byte in hex at address 0xb5da38 and we see that it is 1. GDB with the help of the Go python script we sourced helpfully annotated this as os.Args+8. The Go docs describe os.Args as a string array that holds the command-line arguments, starting with the program name. The value we read that will be compared against 1 indicates that this is the length of os.Args which makes since, as we did not run it with any arguments. To validate this, we restart the program with an argument and re-issue the curl request.

gdb-peda$ kill
[Inferior 1 (process 33769) killed]
gdb-peda$ r testarg
Starting program: /mnt/hgfs/nsec/time/time_server testarg
Thread 1 "time_server" hit Breakpoint 2, 0x00000000007631ae in main.e (r=0xc0000ea100) at /app/main.go:22
22      in /app/main.go
gdb-peda$ x/1x 0xb5da38
0xb5da38 <os.Args+8>:   0x0000000000000002