checksec로 보호기법을 보니 NX가 적용되어 있어서 쉘 코드 삽입은 불가능할 것 같았다.
info func 명령을 통해 함수 목록을 보면 다음과 같다.
main, jump, get_flag 함수가 눈에 띈다.
각 함수들의 어셈 코드는 다음과 같다.
main 함수에서 jump 함수를 호출하고 있고, jump 함수에서는 gets 함수로 사용자의 입력을 받고 있다. get_flag 함수에서는 fopen함수로 파일을 열어 fread함수로 내용을 읽어 리턴하고 있다.
아이다의 헥스레이 기능을 이용하여 위 함수들을 확인하면 다음과 같다.
jump 함수에서 v1이라는 변수로 64바이트를 할당하고, gets함수로 v1에 값을 입력받는데, 입력받는 값의 길이에 대한 검증이 없어서 bof가 발생할 수 있다. jump 함수에서 bof를 발생시켜 get_flag 함수로 리턴하도록 하면 서버의 flag 파일을 읽어올 수 있을 것 같다. 따라서 payload를 다음과 같이 입력값에 64(v1크기) + 8(sfp 크기) 만큼 입력하고 리턴에 get_flag 함수 주소를 넣어서 get_flag 함수를 실행시키도록 하면 될 것 같다. 따라서 익스 코드는 다음과 같다.
UMD CTF 후기 : 사실 가장 오랜 시간을 잡고 있던 문제는 JIE 문제 였는데 NX 보호기법이 적용되어 있지 않아서 쉘코드 삽입하는 문제라 생각하여 그 쪽으로 접근했는데 풀리지 않아서 아쉬웠다. 같이 문제푸신 팀원중 한 분이 ROP 기법으로 문제를 JIE를 포함한 다른 문제도 풀으셨는데 이것을 보고 포너블 기법 공부를 더 열심히 해야겠다는 동기 부여를 받은것 같다. JIE문제는 쉘코드를 삽입하여 푸는 방식으로 롸업이 올라온다면 한번 확인해보고 싶다.
)'; 으로 파이썬 명령어를 닫아주고 내가 원하는 다른 명령어를 실행시킬 수 있을 것 같다. 실행할 명령어를 작성한후 ;#을 붙이면 정상적으로 원하는 명령어를 실행하고 그 결과값을 볼 수 있다. #은 리눅스 쉘에서도 주석의 역할을 하기 때문에 #으로 시스템 함수에 들어가는 남은 파이썬 명령어를 주석처리하는 것이다.
ls명령어로 확인해보니 flag 파일이 있었고, cat flag 명령으로 flag를 확인할 수 있었다.
payload : )';cat flag;#
HackPack CTF 후기 : 푼 문제는 파이썬 코드를 닫고 원하는 명령어를 입력한 뒤 주석처리해서 이후 구문을 무시하여 원하는 명령어만 정상적으로 실행되도록 하여 풀었는데, 웹해킹을 공부할때 원하는 쿼리를 입력하고 주석으로 이후 구문을 무시한다는 점에서 기본적인 SQL Injection 과 비슷한 느낌을 받았다. 이 문제 외에 다른 문제들도 풀려고 시도해 보려 했지만 코드가 길어지고, 아직 포너블 기초밖에 공부가 되어있지 않다보니 꽤 오랜시간을 보고 생각나는 다양한 방법들로 구글링을 시도해 보았지만 역시 쉽지 않았다. 포너블 공부를 더 열심히 해야 겠다는 생각을 하게 되었고, 다양한 기법들을 공부해서 문제에 적용해야 겠다는 생각을 하게 되었다.
비트 필도로 사용할 멤버는 익명 구조체로 감싸주고, 비트 필드의 값을 한번에 접근할 수 있는 unsigned short형 멤버(e)를 선언하여 익명 공용체로 감싸준다. 각 비트 필드에 값을 할당하고, e를 출력하면 64020이 나온다. 각 비트 필드에 할당한 비트들을 차례로 연결하면 1111 1010000 10 100이 되어 10진수로 64020이 된다.
56.3 퀴즈
정답은 d 이다.
정답은 4이다.
정답은 e 이다.
56.4 연습문제 : 구조체로 플래그 비트 필드 만들기
비트 수를 제한해야 하므로 정답은 다음 코드와 같다.
unsigned int a : 2;
unsigned int b : 1;
unsigned int c : 6;
56.5 연습문제 : 구조체와 공용체로 플래그 비트 필드 만들기
32936을 2진수로 표현하면 1000 0000 1010 1000이다. 할당할 값들을 2진수로 표현하면 각각 1000, 10, 10, 1000 0000 이므로 비트를 맞춰주면 다음과 같다.
unsigned short a : 4;
unsigned short b : 2;
unsigned short c : 2;
unsigned short d : 8;
56.6 심사문제 : 구조체로 플래그 비트 필드 만들기
정답은 다음 코드와 같다.
struct Flags{
unsigned int a : 4;
unsigned int b : 7;
unsigned int c : 3;
};
56.7 심사문제 : 구조체와 공용체로 플래그 비트 필드 만들기
57412를 2진수로 변환하면 1110 0000 0100 0100이므로 비트수를 맞춰주면 다음과 같다.
unsigned int a : 3;
unsigned int b : 4;
unsigned int c : 7;
unsigned int d : 2;
Unit 57. 열거형 사용하기
열거형은 정수형 상수에 이름을 붙여서 코드를 이해하기 쉽도록 해준다.
57.1 열거형 정의하기
열거형은 eum키워드로 정의하고, enum 열거형이름 변수이름; 의 형태로 정의하여 사용한다.
for(INTERFACE_TYPE i = Internal; i < MaximumInterfaceType; i++)
Unit 58. 자료형 변환하기
C에서는 자료형이 같거나 크기가 큰 쪽, 표현 범위가 넓은 쪽으로 저장하면 자동으로 형 변환이 된다. 그러나 자료형이 다르면서 크기가 작은쪽, 표현 범위가 좁은 쪽으로 저장하면 컴파일 경고가 발생할 수 있다. 자료형의 크기가 큰 쪽, 표현 범위가 넓은 쪽으로 자동 형 변환 되는 것을 형 확장 이라 하며 암시적 형 변환이라 한다. 반대로 자료형 크기가 작은 쪽, 표현 범위가 좁은 쪽으로 변환되는것을 형 축소라 한다. 형 축소에서 컴파일 경고가 나오지 않도록 만드는 것을 형 변환 이라 한다. 프로그래머가 강제적으로 자료현을 변환한다고 하여 명시적 형 변환이라 부르기도 한다. 형 확장은 값의 손실이 없기 때문에 컴파일러가 알아서 처리할 수 있지만 형 축소는 값의 손실이 발생하여 컴파일러가 알아서 처리할 수 없기 때문에 경고가 발생한다. 형 변환은 컴파일러에게 자료형을 변환한다는 의도를 명확하게 알려주는 것이다.
58.1 기본 자료형 변환하기
자료형을 지정하여 변환하는 것을 명시적 자료 변환이라고 하며 변수나 값 앞에 변환할 자료형을 붙인 뒤 괄호로 묶어준다.
numPtr에 메모리를 할당하고 역참조 하여 0x12345678을 저장하였다. cPtr에 int 포인터를 char 포인터로 저장하여 메모리 주소를 저장하면 cPtr은 char 포인터이므로 1바이트만 값을 가져오게 된다. 메모리 공간에는 리틀 엔디언 형식으로 값이 저장되므로 출력값은 0x78이 된다.
위와 반대로 크기가 작은 메모리 공간을 할당한 뒤 큰 자료형의 포인터로 역참조하면 의도치 않은 값을 가져올 수도 있다.
크기가 작은 메모리를 할당한 뒤 큰 자료형의 포인터로 역참조하면 컴파일러에 따라 옆의 메모리 공간을 침범하여 값을 가져올 수 도 있다. 위 코드는 2바이트 만큼 메모리를 할당했으므로 0x1234만 저장되어 있지만 4바이트 크기만큼 값을 가져오면 2바이트 크기를 벗어나서 malloc함수로 할당하지 않은 공간까지 함께 가져올 수도 있다. 할당하지 않은 공간에는 쓰레기값이 들어있다.
포인터를 다른 자료형으로 변환하면서 역참조 하려면 다음과 같이 괄호 앞에 역참조 연산자를 붙여주면 된다.
구조체 포인터 d1을 선언하고 구조체 크기만큼 메모리를 할당하여 각 멤버에 'a'와 10을 저장하였다. 이후 void ptr에 d1을 할당하였다. void포인터이기 때문에 Data 구조체의 형태를 모르는 상태 이므로 멤버에 바로 접근할 수 없기 때문에 위와 같이 ptr을 Data 구조체로 변환한 뒤 멤버에 접근해야 한다. ptr을 구조체 포인터로 변환한 뒤 멤버에 접근할 때는 자료형 변환과 포인터 전체를 다시 한 번 괄호로 묶어야 -> 연산자를 사용하여 구조체 멤버에 접근할 수 있다.
58.5 퀴즈
정답은 d 이다.
정답은 e이다.
정답은 c이다.
58.6 연습문제 : 삼각형의 넓이 구하기
base와 height가 모두 정수이므로 하나를 실수로 변환해야 한다. 정답은 다음 코드와 같다.
area = (float)base * height / 2;
58.7 포인터 변환하기
출력값이 2바이트 이므로 short 포인터로 형변환 해야 한다.
numPtr2 = (short *)nupPtr1;
58.8 연습문제 : void 포인터 변환하기
numPtr1의 크기가 uint64_t이므로 uint64_t 포인터로 변환한 뒤 역참조하여야 한다.
*(uint64_t *)ptr
58.9 연습문제 : 구조체 포인터 변환하기
정답은 다음과 같다.
((struct Person *)ptr)->name, ((struct Person *)ptr)->age
58.10 심사문제 : 소수점 이하 버리기
num1이 실수이고, 출력하는 것은 num2이고, 정수이므로 num2에 num1을 넣으면 된다.
포인터 변수에는 메모리 주소가 들어있다. 메모리 주소에 일정 숫자를 더하거나 빼면 메모리 주소가 증가, 감소한다. 포인터 연산을 통해 다른 메모리 주소에 접근할 수 있으며 메모리 주소를 손쉽게 옮겨 다닐 수 있다. 메모리 주소가 커지는 상황을 순방향으로 이동, 메모리 주소가 작아지는 상황을 역방향으로 이동이라고 한다.
59.1 포인터 연산으로 메모리 주소 조작하기
포인터 변수에 +, - 사용하여 값을 더하거나 빼고, ++, -- 연산자로 값을 증가, 감소시킬 수 있다. *, / 연산자와 실수값은 사용할 수 없다.
numPtrB는 numPtrA에 1을 더해 4바이트 만큼 순방향 이동했고, numPtrC는 numPtrA에 2를 더해서 8바이트만큼 순방향으로 이동했다. numPtrB, numPtrC도 일반 포인터이므로 역참조 연산이 가능하고, 두 값은 결국 numArr[1], numArr[2]와 같다.
다음과 같이 포인터 연산을 한 부분을 괄호로 묶어 맨 앞에 역참조 연산자를 붙여 포인터 연산과 역참조 연산을 동시에 사용할 수도 있다.
구조체 배열 d를 선언한 뒤 첫번째 요소의 메모리 주소를 ptr에 저장하였다. (ptr+1)->num1 과 같이 포인터 연산을 한뒤 괄호로 묶은 후 화살표 련산자를 사용하여 멤버에 접근할 수 있다. 이는 구조체 배열의 인덱스로 접근하는 d[1].num1과 같다. 구조체의 크기는 4바이트짜리 int형 멤버가 두 개 있으므로 8바이트이다. 따라서 포인터 연산을 하게 되면 8바이트씩 메모리 주소에서 더하거나 뺀다.
다음은 동적 메모리를 할당하여 포인터 연산을 하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
struct Data {
int num1;
int num2;
};
int main()
{
void*ptr =malloc(sizeof(struct Data) *3);
struct Data d[3];
((struct Data *)ptr)->num1 =10;
((struct Data *)ptr)->num2 =20;
((struct Data *)ptr +1)->num1 =30;
((struct Data *)ptr +1)->num2 =40;
((struct Data *)ptr +2)->num1 =50;
((struct Data *)ptr +2)->num2 =60;
memcpy(d, ptr, sizeof(struct Data) *3);
printf("%d %d\n", d[1].num1, d[1].num2);
printf("%d %d\n", ((struct Data *)ptr +2)->num1, ((struct Data *)ptr +2)->num2);
xor 함수에서는 문자열과 길이를 인자로 받아 길이만큼 반복하며 문자열의 각 문자를 1씩 xor 연산을 한다.
main 함수의 첫번째 if문을 보면 할당연산자보다 비교연산자가 우선순위가 높아서 비교연산을 먼저 수행하면 open함수의 리턴값은 파일을 잘 읽어와서 fd 값을 반환하고, fd값은 음이 아닌 정수이기 때문에 비교 연산의 결과는 거짓이므로 fd에는 flase(0)값이 들어가고, 조건문은 수행하지 않는다.
PW_LEN+1 크기의 pw_buf를 선언하고, if 함수의 조건식에서 read함수를 실행한다. fd값이 위에서 0으로 저장되었기 때문에 fd 0 은 stdin 이라 PW_LEN만큼 pw_buf에 입력을 받는다. 입력을 받으면 크기는 0보다 커지기 때문에 not 연산으로 조건식은 거짓이 되어 조건문은 실행하지 않는다.
PW_LEN + 1 크기의 pw_buf2를 선언하고, 입력받는다. 이후 xor함수로 xor연산을 진행한다. 이후 조건문에서 xor한 pw_buf2와 pw_buf가 같다면 시스템 함수로 플래그를 출력할 수 있다.
xor 함수는 각 자리를 1로 xor 연산 하므로 1 xor 1 = 0 이 되므로 첫번째 입력값에는 0을 10개, 2번째 입력에서는 1을 10개 입력하면 플래그를 확인할 수 있다.
rand 함수를 이용해 변수 random에 값을 저장하는데, rand()함수만 사용하면 프로그램이 생성될 때 값이 정해지기 때문에 계속 실행해도 같은 값이 저장되어 있다. key를 입력받아서 key ^ random 값이 0xdeadbeef면 시스템 함수로 flag를 읽을 수 있다. random 값을 알아야 하기 때문에 gdb로 프로그램을 실행시켜 main 함수를 보면 다음과 같다.
rand 함수가 실행되고 난 후(main+18)에 breakpoint를 걸고 실행한 후에 현재 레지스터를 확인해주면 다음과 같다.
산술연산 및 함수 반환값은 rax에 들어가기 때문에 0x6b8b4567이 rand()함수로 반환된 값이다.
0x6b8b4567과 0xdeadbeef를 xor한 값이 passcode에 key로 입력되면 if 조건문을 만족시킬 수 있을 것 같다.
두 값을 xor 연산 후 10진수로 변환하면 3039230856이 나온다. random 을 실행하고 10진수로 변환한 값을 넣으면 플래그를 획득할 수 있다.
main 함수에서 welcome 함수와 login 함수를 실행하고 있고, wlecome 함수에서는 이름을 입력받아 출력하고 있고, login함수에서는 passcode1과 passcode2를 입력받고, 두 값이 모두 조건과 일치하면 시스템 함수로 cat flag를 실행한다. 그러나 두 값을 입력받는 곳을 보면 주소 연산자를 사용하고 있지 않아서 입력받아도 passcode1과 passcode2에는 입력받은 값이 들어갈 수 없다.
또한 login 함수에 있는 fflush(stdin)은 입력 버퍼를 지우는 코드이다. 그러나 리눅스에서 해당 코드는 실행이 안된다고 한다.
welcome함수와 login함수를 gdb로 보면 다음과 같다.
스택의 구조를 생각해보면 welcome 함수에서 name을 입력받기 전 0x70만큼의 공간을 확보하고 있고, login함수에서는 passcode를 입력받기 전 0x10만큼의 공간을 확보하고 있다. name의 크기는 100바이트 지만 0x70-0x10=0x60은 10진수로 96이므로 4바이트가 남고, int형 정수인 passcode1의 값을 변경시킬 수 있다. fflush의 got을 flag를 출력하는 시스템 함수의 주소로 바꾸면 flag를 출력할 수 있을 것 같다.
fflush plt에 들어가 fflush의 got주소를 구할 수 있다.
fflush의 got주소는 0x0804a004이다. 시스템 함수의 시작점은 0x080485e3이지만 scanf함수에서 정수로 받기 때문에 10진수로 변환하면 134514147 이다.
CTF에서 포너블 문제의 형식을 보면 바이너리가 주어지고, 문제 서버가 보통 nc서버로 주어진다. 바이너리를 분석하여 nc 서버에 payload를 보내서 문제를 해결하는 방식이다. 서버에 접속하면 바이너리가 바로 실행되는 형태가 많다. 이와 같은 상태의 서버를 구축하기 위해 xinetd를 설치해야 한다. xinetd는 네트워크에서 들어오는 요청을 받아 그 요청에 적절한 서비스를 실행한다. 요청은 포트번호를 식별자로 사용하여 구분한다.
$ sudo apt-get install xinetd
설치가 다 되었으면 /etc/xinetd.d 경로에 서비스 이름으로 설정 파일을 만들어야 한다. 테스트용 서비스로 hello를 만들것 이기 때문에 hello 파일을 만들었다. hello 파일에 다음과 같은 내용을 작성한다.
마지막 줄 server에 서버에 접속했을 때 실행시킬 바이너리 파일의 경로를 적어주면 된다.
이후 /etc/services 파일의 하단 # Local services 아래에 실행시킬 서비스와 포트 번호를 추가해준다.
이후 지정한 포트 번호로 nc 접속하면 서비스가 제대로 동작함을 확인할 수 있다.
테스트용 바이너리의 소스코드는 다음과 같다.
#include <stdio.h>
int main()
{
printf("hello\n");
return 0;
}