Pay1oad/Study

Pay1oad System Hacking Study 2주차 과제 : Pwntools, Basic BOF

T1deSEC 2024. 10. 7. 21:39

Pwntools

먼저 문제의 코드를 살펴보겠습니다.

#include <stdio.h>
#include <stdlib.h>
#include <sys/select.h>
#include <unistd.h>
#include <time.h>

void init() {
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
}

int main() {
    init();

    int num1, num2, userInput, i;
    fd_set readfds;
    struct timeval tv;

    srand(time(NULL));

    for (i = 0; i < 20; i++) {
        num1 = rand() % 10;
        num2 = rand() % 10;

        printf("%d: %d * %d : ", i + 1, num1, num2);

        // select()를 이용하여 0.2초 동안 입력을 기다림
        FD_ZERO(&readfds);
        FD_SET(STDIN_FILENO, &readfds);

        tv.tv_sec = 0;
        tv.tv_usec = 200000;  // 0.2초 = 200,000 마이크로초

        int ret = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);

        if (ret == -1) {
            perror("select");
            exit(EXIT_FAILURE);
        } else if (ret == 0) {
            printf("timeout\n");
            exit(EXIT_FAILURE);
        } else {
            if (FD_ISSET(STDIN_FILENO, &readfds)) {
                scanf("%d", &userInput);

                if (userInput == num1 * num2) {
                    printf("correct!\n");
                } else {
                    printf("wrong\n");
                    exit(EXIT_FAILURE);
                }
            }
        }
    }

    printf("pwntools Master!!!\n");
    return 0;
}

 

간단한 곱셈 문제가 나오는데, 타임아웃이 0.2초로 설정되어 인간의 힘으로는 도저히 풀 수 없어 보이네요.

 

pwntools를 사용해 자동으로 문제를 해결해주는 코드를 짜보겠습니다.

 

문제의 형식은 "%d: %d * %d : "로 되어 있으므로, 이에 유의하며 문자열을 받아와서 정수만 파싱하여 곱셈 결과를 다시 

 

프로그램에 보내주면 되겠다는 생각이 듭니다. 아래는 제가 짠 pwntools 파이썬 코드입니다.

from pwn import *

def parse_and_calc(str):
    str = str.split(':')[1].split('*')
    nums = [int(num.strip()) for num in str]
    return nums[0] * nums[1]

p = process('./mic')
for i in range(20):
    prob = p.recvuntil(b' : ').decode()
    print(f"Problem from program :\n{prob}")

    ans = parse_and_calc(prob)
    print(f"Sending answer {ans} to program.")
    p.sendline(str(ans).encode())
    
    feedback = p.recvline().decode()
    if feedback == "correct!\n":
        print(f"Feedback from the program : {feedback}")
    else:
        print(f"Something wrong! Program said : {feedback}")
        break

p.interactive()

 

코드를 간단히 해설해보겠습니다.

def parse_and_calc(str):
    str = str.split(':')[1].split('*')
    nums = [int(num.strip()) for num in str]
    return nums[0] * nums[1]

 

문자열에서 원하는 부분을 파싱해서 새로운 리스트에 정수형으로 할당하고 이를 곱셈하여 문제의 정답을 리턴합니다.

p = process('./mic')
for i in range(20):
    prob = p.recvuntil(b' : ').decode()
    print(f"Problem from program :\n{prob}")

    ans = parse_and_calc(prob)
    print(f"Sending answer {ans} to program.")
    p.sendline(str(ans).encode())
    
    feedback = p.recvline().decode()
    if feedback == "correct!\n":
        print(f"Feedback from the program : {feedback}")
    else:
        print(f"Something wrong! Program said : {feedback}")
        break

 

recvuntil(b' : ')로 문제의 끝부분에 해당하는 문자열인 ' : '가 나올때까지의 문자열을 받아옵니다.

 

이 때, b로 바이트 포맷팅을 해준 이유는 pwntools가 통신할 때 바이너리로 문자열을 처리하기 때문인데요,

 

없어도 알아서 처리해주지만 경고 메세지가 거슬려서 그냥 넣었습니다.

 

.decode()를 붙인 이유도 마찬가지입니다. 받아온 바이너리 데이터를 문자열로 다시 디코딩하기 위해 써줍니다.

 

그 이후에는 위에서 작성해놓은 함수로 문자열을 파싱하여 곱셈 결과를 내고, 답변을 전송합니다.

 

마지막으로는 프로그램에서 출력하는 correct! 등의 문자열을 처리해주는 부분이 있습니다.

 

p.interactive()

 

반복문이 끝난 뒤(문제풀이가 끝난 뒤)에는 interactive()로 유저에게 입출력을 넘깁니다. 마지막에 오는 성공 문자열을

 

직접 보고 싶어서 굳이 넣어봤습니다.

성공!

 

파이썬 코드를 실행하면 이렇게 "pwntools Master!!!"가 나오면서 성공을 축하해주네요.

BOF basic

정적 분석(IDA64 사용)

분기 전

 

main함수의 디스어셈블 결과입니다. Size를 입력받아오고, 어떤 값과 대소비교에 따라서 분기하는 모습이 보이네요.

분기 후

 

분기 이후입니다. 유저가 입력한 값이 너무 클 경우 "Too Big!!"을 출력한 뒤 프로그램이 끝나버립니다.

 

그렇지 않을 경우에는 main의 특정 주소로 점프를 뛰네요.

 

점프한 뒤에는 유저가 입력한 Size에 맞게 입력을 받습니다. 이제 디컴파일 결과를 보겠습니다.

 

main 디컴파일 결과

 

코드의 흐름이 딱 예상하던 대로 진행하네요. 입력을 받을 때 19를 초과한다면 프로그램이 종료되게 만들어 얼핏 보면

 

BOF 대비가 잘 되어 있구나~ 라고 생각할 수도 있겠습니다. 하지만 무언가 이상한 부분이 보입니다.

 

1. read함수의 입력 크기를 지정하는 인자로 (unsigned int)nbytes가 들어가 있네요. 강제로 타입 캐스팅을 진행합니다.

2. 예외 처리 부분에서 19를 초과하는 값은 걸러내지만, 음수는 따로 걸러내지 않는다.

 

이 두 가지 단서를 조합해 보면 다음과 같은 결론을 낼 수 있습니다.

 

'음수를 넣어서 unsigned int형으로 강제 형변환이 일어났을 때, 적당한 크기의 값을 넣는다면 19를 초과하는 값도 read 함수의 인자로 넣어줄 수 있겠구나! 충분히 BOF를 사용해서 익스플로잇을 할 수 있겠다!'

 

함수 리스트에서 win()이라는 함수도 찾았습니다. BOF 공격을 실행할 때 main함수의 RET 주소를 win()의 시작 주소로

 

덮어씌워주면 쉘을 탈취할 수 있겠네요. 스택 프레임을 한 번 그려보겠습니다.

main의 스택 프레임

이제 우리가 해야 할 것은 nbytes에 입력할 때 적당한 음수를 넣어 딱 BOF를 일으킬만큼의 입력 제한을 확보한 뒤,

 

buf를 입력할 때 28(buf) + 4(nbytes) + 8(SFP) 만큼의 더미 값을 넣고 RET의 값을 win함수의 주소로 덧씌워주면 되겠네요.

 

즉, 총 필요한 입력 길이 제한은 더미 값 40바이트 + RET Overwrite용 8바이트이므로 48바이트가 필요합니다.

 

동적 분석(pwndbg 사용)

checksec 결과

 

풀라고 만들어 놓은 문제이니 당연히 보호 기법은 비활성화 되어 있겠지만, 그래도 한 번 더 확인해봤습니다.

 

Non-eXcutable stack(NX)가 활성화되었으니 스택에서 바로 쉘 코드를 실행시켜 익스플로잇은 불가능해 보이고,

 

Canary와 PIE는 비활성화되어 있으니 정적 분석에서 계획했던 대로 익스플로잇을 진행하면 될 듯 합니다.

 

물론 NX를 우회하기 위해 Return to Library를 활용해볼 수도 있겠지만, 나중의 즐거움으로 남겨놓겠습니다.

정적 분석에서 미리 봐뒀던 win()함수의 주소

 

바로 win()함수의 주소를 체크해줍니다. 0x40123d네요. 이 주소를 메인 함수의 RET에 덮어씌우면 끝입니다.

 

그러기 위해서는 첫 번째로, 정적 분석에서 봤던 nbytes에 음수를 입력하여 예외 처리는 회피하고 그 뒤에 나오는

 

unsigned int로의 강제 형변환을 활용해야 합니다.

 

현대 컴퓨터 아키텍쳐에서는 2진수의 음수 표현에 주로 2의 보수를 취합니다.

 

즉, -1을 표현할 때

 

00000000 00000000 00000001 의 비트를 반전시켜서

11111111 11111111 11111110 을 만들고 이에 1을 더하면

11111111 11111111 11111111 이 바로 int형의 -1 표현인 것입니다.

 

이를 unsigned int로 강제 형변환하면 비트 패턴은 그대로인 채 양수로 해석하게 되므로

 

10진수로 표현했을 때 16777215라는 BOF 공격을 수행하기에 충분하다 못해 넘치는 수가 나오게 되겠네요.

 

정확히 48이 나오도록 계산을 해서 넣는 방법도 있겠지만, 지금은 그렇게 하지 않아도 풀리니 그냥 편하게 가겠습니다.

 

바로 -1을 넣어봅니다.

-1을 입력한 뒤의 모습

 

보이시나요? -1을 입력했더니 우리의 예상대로 nbytes(rbp - 4)에 0xffffffff(32비트 모두 1)라는 값이 들어가게 되었습니다.

 

위에서 예상했던 대로 당연히 예외처리 구문(jle)도 통과하는 모습을 볼 수 있네요. 

 

이제 다음은 buf에 넣어줄 페이로드를 작성하면 됩니다. RET까지 도달하기 위해 40바이트 만큼 아무 값이나 넣고 미리

 

알아냈던 win()함수의 주소값을 8바이트 크기의 RET에 넣어주면 되겠네요.

 

이 때 유의해야 할 점은 리틀 엔디언 형식으로 주소를 입력해 주어야 한다는 것입니다.

 

우리가 덮어씌우길 원하는 주소는 0x40123d이므로, 이를 8바이트 크기의 주소 공간에 넣으려면

 

\x3d \x12 \x40 \x00 \x00 \x00 \x00 \x00 을 입력해 주면 되겠네요. pwntools로 익스플로잇 코드를 짜보겠습니다.

from pwn import *

p = process('./bof_basic')
p.recvuntil(':')
p.sendline(str('-1').encode())
p.recvuntil(':')
p.send(b'\x90' * 40 + b'\x3d\x12\x40\x00\x00\x00\x00\x00')
p.interactive()

 

RET Overwrite를 위한 이스케이프 시퀀스 앞에는 채워야 하는 공간만큼 NOP Sled를 추가해 주었습니다.

 

솔직히 여기서 별 쓸모는 없어 보이는데 그냥 간지나니까 해봤습니다. 이제 스크립트를 실행시켜 볼까요?

야호!

 

코드는 정상적으로 작동했고, 성공적으로 win()함수를 실행시켜서 쉘을 따낼 수 있었습니다.

 

flag를 찾는 것까지가 과제였으므로 실행시켜봤습니다. BOF에 대해 안다고 칭찬받았네요.