내가 계속 헷갈렸던 포인트를 한 번에 정리한 문서다
공백을 찍어서 숫자를 만든다는 건 이해했는데, 그게 왜 GOT에 대입처럼 써지는지 연결이 안 됐던 부분을 풀었다
FSB write는 결국 이 한 줄로 요약된다
*(uint16_t*)TARGET = (printed_chars & 0xffff);
여기서
이 두 개를 포맷 스트링으로 동시에 달성하는 게 exploit의 본체다
printf는 뭘 출력할 때마다 내부적으로 이런 값을 계속 증가시킨다
여기서 중요한 건 공백도 문자라는 점이다
보이는 글자든 공백이든 전부 1글자로 카운트된다
%n 계열 specifier는 화면에 글자를 찍지 않는다
대신 카운터 값을 메모리에 기록한다
종류별 의미는 이렇게 보면 된다
| 포맷 | 개념적 동작 |
|---|---|
| %n | (int)ptr = printed_chars |
| %hn | (short)ptr = printed_chars |
| %hhn | (char)ptr = printed_chars |
즉 %hn을 만나면 printf가 내부적으로 하는 일은 이거다
short *p = (short*)ptr;
*p = (short)printed_chars;
그래서 카운터 올리기가 곧 쓰기 값 만들기로 직결된다
exploit에서 쓰는 트릭은 %Nc다
즉
%4660c
이게 실행되면 화면에는 공백이 대부분이고 마지막에 문자 1개가 찍히는 느낌인데
중요한 건 printed_chars가 4660이 된다는 점이다
여기서부터가 핵심이다
%5$hn은 아래를 의미한다
개념적으로는 이거다
short *p = (short*)arg5;
*p = (short)printed_chars;
즉 5번째 인자 값이 어디에 쓸지 결정한다
arg5가 GOT 주소가 되게 만들면 된다
즉
arg5 == exit_got
을 성립시키면
*(uint16_t*)exit_got = printed_chars;
가 된다
이 순간 카운터가 GOT에 대입되는 게 성립한다
여기가 FSB에서 제일 자주 쓰는 트릭이다
코드가 이런 구조다
read(0, buf, 0x80);
printf(buf);
printf는 포맷스트링에 %5$hn 같은 게 있으면 5번째 인자를 읽으려 한다
근데 호출자는 실제로 인자를 안 줬다
그래서 printf는 그 자리에 있던 값을 그냥 인자인 것처럼 읽는다
이게 UB인데, 실전에서는 그대로 재현되는 경우가 많다
payload 끝에 target 주소를 붙인다
포맷스트링 + exit_got 주소 4바이트
그럼 이 4바이트 값이 메모리 어딘가에 올라가고
printf가 5번째 인자 슬롯을 읽을 때 그 위치를 읽도록 오프셋을 맞추면
즉 운이 아니라 배치다
여기서 5라는 숫자는 보편 공식이 아니라 실측 결과일 수 있다
환경이 바뀌면 6이나 7이 될 수도 있다
핵심 구조는 보통 이렇다
1) 출력량을 원하는 값으로 맞춘다
%{under}c
이게 끝나면 printed_chars = under 상태가 된다
2) %hn으로 그 값을 목표 주소에 쓴다
%5$hn
이게 실행되면
*(uint16_t*)arg5 = (under & 0xffff);
3) payload 끝에 exit_got를 붙여서 arg5가 exit_got이 되게 한다
결과적으로
*(uint16_t*)exit_got = under;
가 된다
그리고 exit 호출이 일어나면 GOT가 가리키는 함수가 바뀌어서 흐름이 꺾인다
under는 get_shell 주소의 하위 2바이트를 정수로 만든 값이다
예를 들어 get_shell 주소가 0x08048609면
리틀엔디안 바이트열은
하위 2바이트만 보면
이걸 little endian 정수로 해석하면
이게 under다
그리고 %{under}c에서 under는 숫자여야 하니까
bytes를 정수로 바꾸는 과정이 필요하다
FSB write가 안 먹힐 때 나는 이 순서로 확인한다
내가 계속 헷갈렸던 이유는
공백 출력과 메모리 쓰기가 연결되는 규칙을 머릿속에 한 줄로 못 박아두지 못해서였다
이제는 그냥 이렇게 외우면 된다