4.9 The reality
Invoking dynamically linked library functions
- 빨간 박스는 라이브러리 함수인 'printf'의 일부 disassemble 결과
- 함수 시작에는 push ebp & mov ebp, esp와 같은 prologue가 등장하는데, 여기서는 그런 코드 없이 바로 'get_pc_thunk'가 실행되어 전통적인 스택 프레임 패턴이 나타나지 않는다.
code injection이 아니라 code reuse 공격을 수행하고자 할 때 문제를 일으킬 수 있다. 실제 prinf의 machine code의 ~0x670 주소에서 시작되는데 prologue 없이 다음 함수 호출로 바로 넘어가고 있다.
현재 코드는 printf() 진입 직후 바로 다른 함수(get_pc_thunk)가 실행된다. 이 때문에 이전에 활용하던 'room' 개념을 적용하기 어렵고 원하는 대로 code reuse가 되지 않는다.
prologue
- 스택 프레임을 새로 생성하는 코드, EBP/ESP가 바뀌고 로컬 변수 공간이 잡힌다.
- 공격자 입장에서는 prologue 때문에 짜놓은 스택 레이아웃이 깨지니까 prologue를 건너뛰고 곧바로 원하는 주소로 점프 + 원하는 EBP/ESP 상태를 만들고 싶어한다.
4.10 Chaining DLL Function Calls
E.g., printf()
- A() → B()
- EBP = X → X + 4
A에서 B로 넘어가면 EBP 값이 X → X+4처럼 규칙적으로 변한다. prologue를 건너뛰는 "skipping"을 쓰려고 했는데 printf()처럼 동적 라이브러리 함수는 전통적인 prologue가 존재하지 않는다.
- empty(): prologue -> epilogue 구조를 가진 가상의 empty 함수 상정
- 함수 A에서 EBP가 X일 때 리턴 주소를 empty로 바꾸고, empty의 prologue를 건너뛰기 위해 리턴 주소를 empty+3(epilogue)로 설정한다.
4.11 Construct the Stack Content
1. Initial State
- A()가 끝난 뒤 empty()의 epilogue를 이용해 EBP를 원하는 값으로 설정하고, 그 상태로 B 안으로 진입한다.
2. A()'s Epilogue
- A()가 종료되면 leave & ret으로 원래 돌아가야 할 주소 대신 덮어쓴 리턴 주소(empty + 3)으로 점프한다.
- empty + 3에서 epiloge가 실행되면서 스택에 심어 둔 값으로 EBP를 덮어쓰고 다음 리턴 주소로 설정한 B()의 진입점으로 흐름이 넘어간다.
3. Entering B() in libc
- empty()로 prologue를 건너뛰는 데 성공하지만, 이제 EIP는 libc 안에 있는 B()(e.g., printf)의 코드 중간으로 들어간다.
- B()는 라이브러리 함수라 내부에서 또 다른 함수를 호출하고, 그 호출한 함수들은 자신만의 prologue/epilogue를 가질 수 있다.
- 이 시점부터는 prologue를 empty()로 우회해 막을 수 없다.
4. B()'s Prologue runs, then up to C()
- 한번 B() 안으로 들어가면 이후의 chain은 라이브러리 코드가 주도하는 스택 프레임 설정과 해제를 따르게 되므로, 더 이상 empty + 3 같은 기법으로 prologue를 건너뛰거나 room을 마음대로 사용할 수 없다.
prologue를 겪으면서 x+200 값이 overwriting(push ebp)이 되기 때문에 변형이 생긴다.
A() → empty() → B() → empty() → C()
4.12 Chaining Function Calls with Arguments
Idea: using leave and ret → leavelet (instead of empty func)
- empty()라는 가상의 함수를 썼지만, 실제로는 다른 함수의 epilogue 시작부터 그대로 가져다 쓴다.
- 존재하는 함수의 epilogue에 바로 진입하도록 리턴 주소를 조작하면, 그 epilogue의 pop ebp; ret로 EBP를 원하는 값으로 설정하고 설정한 EBP를 기준으로 다음 함수의 인자들도 정상적인 형태로 전달한다.
4.13 Chaining Function Calls with Zero in the Arguments
- /bin/sh과 같은 고급 쉘로 링킹되어 있으면 setuid bit가 blocking된다.
- code injection에서는 쉘코드 안에서 직접 setuid(0)를 호출하도록 machine code를 넣고, 그 뒤에 int 0x80을 실행했다.
- 지금처럼 code reuse(ROP / return-to-libc) 환경에서는 새 코드를 주입하지 못하므로 이미 있는 setuid(0)을 호출하는 chain을 생성해야 한다.
- 그런데 문제는 인자 0이다. 0은 4 byte로 쓰면 0x00000000인데 strcpy 같은 문자열 함수는 첫 0x00에서 복사를 멈춘다.
code injection이 아니라서 쉘코드 안에서 xor 연산으로 레지스터를 0으로 만드는 식의 트릭도 쓸 수 없다.
→ sprintf()
Idea: using a function call to dynamically change argument to zero on the stack
- setuid()의 첫 번째 인자 위치(EBP+8)을 4 byte의 0으로 만들어 setuid(0)을 호출하자.
- sprintf(): dst에 데이터를 쓰고 마지막에 항상 null byte를 하나 추가
- sprintf(): 출력을 지정된 buffer로 저장하며 화면에 출력하지 않는다.
- 형식 문자열에 따라 실제 변환된 데이터를 buffer에 쓰고, 그 끝에 항상 null byte를 추가한다.
- printf(): 표준 출력(stdout, terminal)으로 직접 출력한다.
- Sequence of function calls (T is the address of the zero): use 4 sprintf() to change setuid()'s argument to zero, before the setuid function is invoked
- sprintf()를 4회 호출해서 src를 null byte 주소로 설정하고 dst를 EBP+8부터 1씩 증가시키며 1 byte씩 복사한다.
- null-terminated 동작을 이용해 정확히 1 byte의 null이 계속 채워진다.
- sprintf()의 return address를 setuid 주소로 덮어씌워 호출한다. setuid(0) 인자를 전달하며 프로그램이 root-owned setuid이므로 EUID가 이미 root 상태에서 RUID를 0으로 동기화한다.
Invoke setuid(0) before invoking system("/bin/sh") can defeat the privilege-dropping countermeasure implemented by shell programs
4.14 Further Generalization (ROP)
- Chain blocks of code together