이번에는 plt와 got를 뜯어보고 기록하면서 이해할 것입니다. 이 글에서 끝장내버리고 싶어요.
저번 글에서
동적! 라이브러리(그 중에서 libc) 의 주소에 대해서 다뤘는데 이번에는 이를 구체적으로, plt와 got를 알아보며 어떻게 우리가 함수를 불러와 사용하는지 알아봅시다. (exploit할 때 그닥 알 필요는 없었는데, 궁금하잖아요)
참고문헌 : https://bpsecblog.wordpress.com/2016/03/07/about_got_plt_1/, https://bpsecblog.wordpress.com/2016/03/09/about_got_plt_2/
https://movefast.tistory.com/200
https://pwnwiz.tistory.com/267
PLT, GOT 소개
plt는 외부 프로시저를 연결해주는 테이블, (Procedure Linkage Table)
got는 plt가 참조하는 테이블로 프로시저의 주소가 들어있습니다. (Global Offset Table)
테이블이라고 해서 그냥 주소만 적혀있는 공간이라고 생각했는데, got는 그럴지 몰라도 plt는 그렇게 생각하면 좀 힘들었습니다. 그냥 외부 프로시저를 연결해주는 기능을 하는..? 그런 느낌으로 생각하고 있어요.
plt가 동작하는 방식을 보면 왜인지 알거 같은데..
plt는 got를 참조해서 해당 함수로 점프하거나 최초로 호출하면 got에 해당 함수의 주소를 쓰는 역할을 하기 때문입니다.
got에는 함수의 libc_base+offset인 실제 주소가,
plt에는 got로 점프하여 got에 적혀있는 함수 주소의 내용을 참조하거나, 함수 주소를 구해오는 부분이 있습니다.
그래서 덧붙이면, ret를 덮을 때 함수의 실제 주소를 구해 덮을 수 있지만 해당 함수의 plt가 있다면 plt의 주소만 구해서 덮어도 괜찮습니다.
해당 함수의 주소를 구해서 덮어도 rip(eip)가 이동하여 함수의 내용인 명령어(instruction)을 차례로 실행하는데
plt에서는 got를 참조하여 해당 함수로 점프하는 과정이 있을 뿐 똑같습니다.
다만 got로 ret를 덮을 경우에 got는 말 그대로 함수의 주소만 가지는 테이블이고 실행 권한이 없는 data 영역에 존재하여 프로그램이 터집니다.
이제 got와 plt에 대해 간단한 개념을 설명하겠습니다.
함수를 가져오는 간단한 개념 (아직은 이 정도만 알아도?)
사실 위에 있는 이미지만 보고 "plt는 got에 있는 주소를 참고해서 libc에 있는 실제 함수로 이동하는구나"라고 이해하고
got는 함수들의 실제 주소를 담고 있는 테이블이구나 해도 괜찮습니다.
"처음 함수를 만나면 plt+6부터 해서 실제 함수 주소를 got에 쓰는구나~~" 이렇게 이해만 하고 있어도
got overwrite나 Return to plt, Return to libc의 개념을 이해하는데엔 이전 게시물의 offset 개념을 포함해서 이해할 수 있습니다.
got overwrite는 libc에 들어갈 실제 주소를 다른 함수의 실제 주소로 바꾸는거고
Return to plt는 원하는 함수의 plt 주소를 ret에 덮어씌우는거고
Return to Library(libc)는 libc의 offset을 이용해서 실제 주소를 구하고 ret를 덮는 것이었습니다.
음.. 그래도 궁금하니까 더 깊게 실제로 어떻게 함수의 주소를 구해오는지 3일간 조사했습니다.
전체적인 개념을 설명하고 세세하게 분석해보겠습니다.
맨 상단에 적혀있는 lazy binding, 리눅스 elf 바이너리에서 시작 주소를 미리 구하지 않고 함수를 호출할 때 구하는 것을 lazy binding이라고 합니다. 함수를 처음 호출해서 got에 plt+6의 주소가 들어있는 상황에 대해 살펴볼 것입니다.
편의상, 뜯어볼 때 볼 함수가 puts이므로 puts라고 적었습니다.
1. main -> puts@plt
main 함수에서 puts을 호출(call) 하면 puts@plt라는 곳으로 이동합니다.
puts@plt의 첫번째 instruction은 got에 있는 puts의 자리의 값으로 jmp하는 것입니다.
got에는 (다른 함수가 있다면 다른 함수도) puts와 __libc_start~~~이런 부분이 있고 (gdb에서 got 입력)
그 중 puts부분에는 함수 주소가 puts@plt+6을 가리키고 있습니다.
그래서 puts@plt+6으로 점프합니다.
plt+6에서는 reloc_offset이라는 값을 스택에 push합니다. reloc_offset은 8byte 단위일 것입니다. (이후 이해함)
plt+11에는 plt section으로 점프합니다.
2. plt section -> _dl_runtime_resolve
plt에서 plt section 부분으로 점프했습니다. 여기서 별건 없고
그냥 link_map이라는 구조체를 스택에 push하고
_dl_runtime_resolve라는 함수로 jmp합니다.
스택에는 지금
이렇게 쌓여있을 것입니다.
link_map이라는 구조체는 꽤 중요한데, ld loder가 참조하는 링크 지도입니다.
라이브러리의 정보를 담고 있는 구조체이고 이 구조체를 통해 여러 테이블의 주소를 구하게 됩니다.
_dl_runtime_resolve에서는
eax, ecx, edx를 push하고
edx에 [esp+0x10]
eax에 [esp+0xc]를 넣습니다. []로 감싸져 있으면 해당 주소의 값을 뜻하는데 (이번에 확실히 깨달음)
그러면
eax에 link_map의 주소를,
edx에는 reloc_offset의 값이 들어가게 됩니다
그리고
_dl_fixup 이라는 함수를 call 합니다. _dl_fixup함수는 인자로 link_map과 reloc_offset을 가지는 것이죠.
3. _dl_fixup
_dl_fixup이라는 함수는 매우매우 중요합니다. 분석하다가 여기서 포기할 뻔 했어요.
_dl_fixup함수의 기능적인 부분을 간단히 정리한 것입니다. puts함수만 그런건지 모르겠는데,
link_map의 주소를 인자로 받아왔었습니다.
link_map+0x7c의 값[] 에는 8byte의 구조체가 들어있고 그 구조체의 값을 보면 x/2wx로 보면 뒤 4byte가 JMPREL이라는 테이블의 시작 주소로 되어있습니다.
link_map+0x34의 값에도 8byte 구조체가 있고 그 구조체의 값의 뒤 4byte는 STRTAB라는 테이블의 시작 주소입니다.
JMPREL과 STRTAB가 무엇이냐.
STRTAB는 문자열 테이블(STRing TABle)로 프로그램 내에서 쓰이는 각 심볼들의 문자열이 들어있습니다.
이를테면 이런 값이요.
JMPREL은 재배치 정보를 담고 있는 테이블입니다. 재배치라는게 뭐냐? 라는 세세한 질문은 넘어가고 (저도 몰라서 ㅎ)
JMPREL은 Elf32_Rel이라는 구조체로 이루어져 있습니다.
간단히 구조체 정보와 값을 봅시다.
구조체는 다음과 같이 이루어져 있습니다.
typedef uint32_t Elf32_Word;
typedef uint32_t Elf32_Addr;
/* Relocation table entry without addend (in section of type SHT_REL). */
typedef struct
{
Elf32_Addr r_offset; /* Address */
Elf32_Word r_info; /* Relocation type and symbol index */
} Elf32_Rel;
r_offset은 재배치 된 후(lazy binding이 끝나고) 함수 주소가 저장될 got의 주소이고
r_info는 재배치 타입과 DYNSYM의 index를 가지고 있습니다.
하나에 8byte 세트로 이루어져있는데 앞 4byte는 0x0804a00c, got의 주소이고
뒤의 4byte 0x00000107에서
앞 1byte 0x07은 재배치 타입,
다음 3byte 0x000001은 DYNSYM에서의 index입니다.
재배치타입은 따로 볼건 없었고, index인거만 잘 알아두면 좋았습니다. (little endian 고려)
위로 올리기 귀찮으니까 다시 그림을 가져와서
현재 JMPREL과 STRTAB의 개념과 위치까지 했습니다.
아까 JMPREL이 어떻게 되어있는지 봤을 때 got가 puts말고도 __libc_start_main~~도 있었는데 JMPREL에서 어떤 값이 puts인지 모릅니다.
그래서 원래 edx에 reloc_offset이 들어있었으니까 거기에 JMPREL의 시작 주소를 더하면
원하는 함수(여기선 puts)의 JMPREL... Elf32_Rel 구조체주소를 얻을 수 있는 것입니다.
이후 이해한다고 했던 reloc_offset이 8byte단위인 이유는 Elf32_Rel 구조체 크기가 8byte이기 때문이죠.
해당 함수의 구조체에서
r_info의 끝 3byte; DYNSYM(DYNamic SYMbol) 의 index를 구해서
DYNSYM의 시작 주소에 더합니다.
DYNSYM은 [link_map+0x38]의 8byte 구조체의 뒤 4byte에 시작 주소가 있습니다. (그림에선 빼먹어버림)
정리하자면
JMPREL의 시작 주소에 reloc_offset을 더해서 puts과 관련된 구조체 부분을 찾고
그 구조체에서 DYNSYM의 index에 해당하는 부분을 DYNSYM의 시작 주소에 더합니다.
그러면 DYNSYM에는 뭐가 있느냐?
DYNSYM은 동적 심볼 테이블로 import 및 export 하는 모든 심볼의 정보가 담겨있습니다.
Elf32_Sym 구조체로 이루어져 있습니다.
typedef uint16_t Elf32_Section;
/* Symbol table entry. */
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) */
Elf32_Addr st_value; /* Symbol value */
Elf32_Word st_size; /* Symbol size */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;
이것이 Elf32_Sym 구조체.
Elf32_Section은 2byte 자료형입니다.
기억할 것은 st_name과 st_other로
st_name에는 STRTAB(심볼 문자열 있던 거)의 시작 주소로부터 해당 함수(여기선 puts)의 위치까지 offset이 들어있습니다.
st_other은 3과 and연산을 하여 0이면 호출되지 않았던 함수입니다.
실제 값을 확인해보면
이렇게 되어있습니다.
여기서 puts에 해당하는 Elf32_Sym은
이 부분인데 그러면 st_name이 0x0000001a이고 st_info는 0x12, st_other은 0x00, st_shndx는 0x0000이 될 것입니다.
0x0000 00 12
st_shndx
st_other
st_info
st_other에 & 3을 하면 0이니까 처음 실행하는 함수네요.
그럼 실제로 STRTAB의 시작 주소에 0x0000001a를 더하면 puts가 있는 문자열인지, 확인해봅시다.
그러합니다.
이렇게 구한 함수의 이름 puts를 인자로
_dl_lookup_symbol_x 함수를 call합니다.
puts가 아니라 다른 함수의 이름을 넣으면 그 함수의 시작 주소를 얻어올 것입니다.
공부하면서 알게 됐는데, 이를 return to dl_resolve라고 합니다. (RTDL)
왜 꼭 bss인지는 모르겠지만,
write가 가능한 bss영역으로 strtab의 시작 주소를 가리키는 포인터를 바꾼 뒤,
DYNSYM의 +st_name된 영역에 원하는 함수의 주소를 넣어버리면 원하는 함수가 실행되는 공격입니다.
4. _dl_lookup_symbol_x
거의 다 왔습니다..! 사실 _dl_fixup함수가 너무 힘들었어요.
이 함수는 우리가 STRTAB와 DYNSYM의 st_name을 통해 구한 함수 이름(puts)을 eax로, 인자로 받아서
그 값을 해시로 변경하고 _do_lookup_x 함수의 인자로 전달하여 호출합니다.
_do_lookup_x에서는 심볼테이블 (SYMTAB)의 인덱스와 그 인덱스로 라이브러리로부터의 offset을 구한다고 합니다.
SYMTAB라는 테이블에는 전역변수와 함수에 대한 정보를 담고 있는데, 실제 함수 주소(라이브러리 시작 주소로부터의 offset)가 담겨 있습니다.
결론적으로 _dl_lookup_symbol_x 함수에서는 다양한 과정을 거쳐(여기부턴 진ㅉ ㅏ모르겠음)
라이브러리의 시작 주소와 SYMTAB의 주소를 가져옵니다.
SYMTAB의 두 번째 4byte에 library_base에서부터의 offset이 존재함.
5. _dl_fixup -> _dl_runtime_resolve (마무리)
위에서 구한 library_base와 SYMTAB에 있는 함수의 offset을 가지고 함수의 실제 주소를 알 수 있습니다.
다시 _dl_fixup 함수로 되돌아와서
got에 구했던 실제 주소를 씁니다. 아마 JMPREL에서 r_offset이 got 주소를 가지고 있었는데, 해당 주소에 쓰는 것 같습니다.
일련의 과정을 거쳐 eax에 실제 함수의 주소가 있는데,
이를 EBP가 가리키는 got의 값에 넣는 모습
_dl_fixup이 끝날 쯤에 got가 puts의 실제 주소로 덮여있는 모습.
다시 이미지를 가져와서.
처음 실행이라고 got에 주소만 쓰고 실행하지 않을건 아니니까.
_dl_runtime_resolve로 되돌아가서는 실제 puts 함수로 넘어갑니다.
_dl_runtime_resolve의 ret이후 <puts>로 넘어가는 모습
이제 실제로 뜯어보면서 이해해봅시다.
여기서부턴 진짜로 볼 필요가 없긴 한데..
실제로 확인해보기
음.. 여기까지 확인할정도로 궁금증이 많은 저를 포함해서 독자분에게도 박수(짝짝짝)
직접 gdb로 확인해보겠습니다. 웬만하면 필요한 부분들만요.. 나중에 64bit는 언제 다하나....
분석에 사용할 코드는
// Name: got.c
// Compile: gcc -o got got.c
#include <stdio.h>
int main() {
puts("Resolving address of 'puts'.");
puts("Get address from GOT");
}
해당 코드이며 Compile에서
gcc -m32 -fno-stack-protector -mpreferred-stack-boundary=2 -z execstack -no-pie -o 32got got.c
를 사용했던걸로 기억합니다.
puts를 두번 사용했는데, 처음 puts에서 어떻게 Lazy binding을 하는지 볼 것입니다.
gdb-pwndbg 32got
1. main -> puts@plt
우선 main 함수에서 puts를 호출하기 전
got는 다음과 같습니다.
si로 들어가보면 plt에는 got에 있는 값으로 점프하는데, 이때 참조하는 값이 [got+12]입니다.
인덱스로 따지면, got[3]배열과 같은데
got는 사실 함수들의 주소만 들어있는 것이 아니었습니다.
got의 시작 주소는 0x804a000이고 DATA영역
got[0] = .dynsym의 시작 주소 -> 이건 뭔지 모르겠는데 확인해보니 DYNSYM의 시작주소는 아니었음.
got[1] = link_map 연결 리스트 첫번째 노드의 시작 주소
got[2] = _dl_runtime_resolve의 시작 주소
got[3] = 여기서부터 plt가 참조하는 got의 영역입니다.
현재 [got+12]가 가지고 있는 값은 plt+6의 주소입니다.
그래서 plt+6으로 이동합니다. 여기서부터 lazy binding이 시작되는 것이죠.
plt+6에서는 0을 push합니다 여기 들어가는 값이 reloc_offset인데 이 값이 0인 것입니다.
아마 got라는 명령어를 쳤을 때 나오는 순서 따라 들어가는 듯 한데.
그래서 gets와 puts를 가지는 프로그램을 하나 더 만들어서 테스트해보니
puts가 두 번째에 있는데
여기서 puts를 처음 호출할 때 reloc offset이 얼마나 들어가나 확인해보면 (예상대로면 8)
8이 들어가는 것을 보여줍니다.
이후 jmp 0x80482d0로 이동합니다. plt section
2. plt section -> _dl_runtime_resolve
plt section에서 [got+4]였던 그니까 got[1]이었던 link_map 구조체의 시작 주소를 push합니다.
그리고 [got+8]으로는 _dl_runtime_resolve 함수의 시작 주소가 있었는데 해당 값으로 jmp합니다.
_dl_runtime_resolve에서는 eax, ecx, edx를 push 하고
edx에는 [esp + 0x10]을,
eax에는 [esp + 0xc]를 대입합니다.
스택을 보면
박스 친 부분이 우리가 봤던 부분들인데
0xffffcfb8에는 reloc_offset이 들어있고 그 위에
0xffffcfb4에는 link_map이 들어있습니다.
위로는 순서대로 넣었던 eax, ecx, edx가 들어있습니다.
edx에 들어가는 [esp+0x10]은 0xffffcfb8의 값인 0(reloc_offset)이 들어갑니다.
eax에는 0xffffcfb4의 값인 0xf7ffd940이 들어갑니다. (link_map 시작주소)
edx = reloc_offset
eax = link_map
그렇게 인자로 주고 _dl_fixup함수를 호출합니다.
3. _dl_fixup
함수를 시작할 때 뭔가를 하는데, 모르니까 그냥 건너뛰어보고.
mov ecx, dword ptr [eax+0x34]
mov ebx, dword ptr [eax+0x38]을 보면
eax는 link_map의 시작 주소였고
link_map + 0x34는 STRTAB를 가지는 8byte 구조체입니다.
같은 원리로
link_map+0x38은 DYNSYM을 가지는 8byte 구조체의 주소입니다.
구조체의 주소를 가지니까 해당 값을 보면 뒤 4byte에 주소가 들어있습니다.
ecx는 STRTAB, ebx는 DYNSYM에 대해서요.
이제 ecx+4의 값을 esi에 넣습니다.
ecx+4는 아까 위에서 0x8049f54를 확인했는데. 0x8049f58이 ecx+4이고
[ecx+4]는 그 값인 0x0804821c입니다. STRTAB의 시작 주소가 esi에 있는 것이죠.
ESI = STRTAB
이제 ecx는 다시 eax(link_map) + 0x7c의 값을 가집니다. 이것도 8byte 구조체인데. JMPREL과 관련됩니다.
해당 과정이 끝나면 edx에 ecx+4의 값을 대입합니다.
ecx+4라는 주소에서는 JMPREL의 시작 주소를 갖고 있습니다. edx에 JMPREL의 시작 주소만큼 더합니다.
edx에 mov하지 않고 add하는 이유는
edx = reloc_offset이었으니까 add 후에는
edx = reloc_offset + JMPREL일 것입니다.
ecx+4 = 0x8049f98이므로 확인해보면
여기까지 정리
eax = link_map
edx = puts의 JMPREL
esi = STRTAB
ebx = DYNSYM 을 가지는 8byte 구조체의 시작주소
기존의 edx를 ebp에 넣어두고
edx를 다시 puts의 JMPREL(Elf32_Rel) 중 r_info부분의 값을 넣어둡니다.
r_info는 DYNSYM의 index와 재배치 형식을 가지고 있는 변수였습니다.
다음과 같은 JMPREL에서 0x00000107 값이 edx에 저장될 것입니다.
첫 1byte는 재배치 타입, 이후 3byte는 DYNSYM의 index입니다.
index의 단위는 16byte입니다. DYNSYM의 구조체 Elf32_Sym의 크기가 16byte이기 때문입니다.
이후 다른 몇가지 디테일을 건너뛰고
esi에 edx의 값인 0x00000107이 들어가고
esi에 8bit(1byte)를 right로 shift연산합니다 (shr)
그러면 뒤에 1byte였던 0x07(재배치 타입)이 날아가고
DYNSYM의 index인 0x000001만 esi에 남아있습니다.
그 값을 ecx에 저장합니다.
eax = link_map
edx = puts JMPREL의 r_info값
esi = STRTAB
ebx = DYNSYM 을 가지는 8byte 구조체의 시작주소
ecx = DYNSYM의 index
ecx에는 1이 들어있으므로 아까 16byte 단위라고 했었으니, 16으로 불려줍니다. (shl을 통해서)
그리고
ebx+4는 DYNSYM을 가지고 있는 주소이고 [ebx+4]는 DYNSYM의 시작주소입니다.
거기서 ecx에 있던 index을 더하면
ecx는 puts의 DYNSYM(Elf32_Sym) 부분(구조체)을 얻습니다.
ecx = 0x80481dc로 같습니다.
디테일한 과정을 거치면
edi에는 STRTAB의 주소가 들어있습니다.
이때 edi에
ecx(DYNSYM에서 puts 부분) 의 값인 0x0000001a를 더합니다.
이는 Elf32_sym에서 st_name부분으로 STRTAB에서부터의 ptus문자열까지 offset을 의미합니다.
그리고 계산된 STRTAB에서 "puts"문자열이 있는 주소를 push하고
_dl_lookup_symbol_x함수를 실행합니다.
아마 0x1a라는 offset이 그대로면. bss영역으로 Fake_STRTAB를 넣어두고 원하는 함수의 문자열이 들어가게 하면 RTDL.
4. _dl_fixup -> _dl_runtime_resolve (마무리)
_dl_lookup_symbol_x 함수를 저렇게 설명했는데
함수가 끝나고 무슨 일이 일어나는지 살펴봅시다.
함수가 끝나면 이렇게 되어있는데
뭔가의 과정을 거쳐 eax는 puts 함수의 실제 주소가 됩니다.
그리고 ebp는 원래는 plt+6이 있던 got+12의 주소를 가지고 있는데
해당 값에 eax(puts의 실제 주소)를 적어넣습니다.
그래서 이제 got에는 puts의 실제 주소가 있어 바로 점프할 수 있죠.
그리고 위에서 말했다시피
_dl_runtime_resolve에서는 puts로 넘어갑니다.
이렇게 세세한 과정까지 살펴봤습니다.
시간이 된다면 이 과정을 살펴보는데 큰 도움이 된
codegate 2015 본선인 yocto를 풀어보면 좋겠네요
너무너무 길었던 글
끝! (64bit는 언제..?)