풀이
col@ubuntu:~$ ls -al
total 44
drwxr-x--- 5 root col 4096 Apr 2 08:58 .
drwxr-xr-x 118 root root 4096 Jun 1 12:05 ..
d--------- 2 root root 4096 Jun 12 2014 .bash_history
-r-xr-sr-x 1 root col_pwn 15164 Mar 26 13:13 col
-rw-r--r-- 1 root root 589 Mar 26 13:13 col.c
-r--r----- 1 root col_pwn 26 Apr 2 08:58 flag
dr-xr-xr-x 2 root root 4096 Aug 20 2014 .irssi
drwxr-xr-x 2 root root 4096 Oct 23 2016 .pwntools-cache
col@ubuntu:~$ ./flag
-bash: ./flag: Permission denied
col@ubuntu:~$ ./col
usage : ./col [passcode]
col@ubuntu:~$ ./col 111
passcode length should be 20 bytes
col@ubuntu:~$ ./col 11111111111111111111
wrong passcode.
col@ubuntu:~$
flag는 바로 얻을 수 없고, 20바이트의 패스코드를 입력받아 검증하는 col 파일이 있다.
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i;
int res=0;
for(i=0; i<5; i++){
res += ip[i];
}
return res;
}
int main(int argc, char* argv[]){
if(argc<2){
printf("usage : %s [passcode]\n", argv[0]);
return 0;
}
if(strlen(argv[1]) != 20){
printf("passcode length should be 20 bytes\n");
return 0;
}
if(hashcode == check_password( argv[1] )){
setregid(getegid(), getegid());
system("/bin/cat flag");
return 0;
}
else
printf("wrong passcode.\n");
}
return 0;
}
입력한 패스코드는 check_password
함수에서 처리한다.
문자열로 받은 p
의 포인터를 매개변수로 보내고, 이 포인터를 다시 정수형 포인터로 가리킨다.
char는 1바이트, int는 4바이트 크기이므로 *ip
는 배열을 4바이트씩 읽는다. 즉, 입력된 20개의 문자를 4개씩 끊어서 읽는다.
만약 문자열의 첫 4바이트가 '0000'이었다면,
'0'의 아스키코드는 48이고, 이는 16진수로 0x30이다.
정수형 포인터 *ip
가 보기에 *ip[0]
은 0x30을 4바이트씩 묶은 0x30303030이 된다.
// 메모리에서:
['0']['0']['0']['0']
[48 ][48 ][48 ][48 ]
[0x30][0x30][0x30][0x30]
ip[0] = [0x30][0x30][0x30][0x30] = 0x30303030 = 808464432
21DD09EC
를 5로 나누어 입력하면 될 것 같다.
21DD09EC / 5 = 06C5 CEC8
여기서 잘린 나머지 4를 마지막에 더해준다.
6C5CEC8
4개와 6C5CECC
하나를 입력해주자.
그런데 입력은 20바이트로 제한되어 있다. 입력이 20바이트가 맞는지 확인해보자.
16진수 하나는 4비트를 차지하므로 두자리 수는 8비트 = 1바이트를 차지한다.
'06 C5 CE C8' 4바이트를 4번, '06 C5 CE CC' 4바이트를 1번 입력하므로 총 20바이트가 맞다.
이때 x64 프로세서는 리틀 엔디언 방식을 사용하므로, 'C8 CE C5 06'
을 네 번, 'CC CE C5 06'
을 한 번 입력하면 된다.
그렇다면 바이트를 어떻게 입력해야 할까?
백틱을 이용해 파이썬으로 입력하는 방법이 일반적이다.
파이썬2
col@ubuntu:~$ ./col `python2 -c 'print "\xc8\xce\xc5\x06" * 4 + "\xcc\xce\xc5\x06"'`
Two_hash_collision_Nicely
파이썬2에서 print는 문장(statement)으로, 뒤에 오는 내용을 바이트 문자열로 처리한다.
파이썬 2로 출력한 내용은 백틱(``)으로 감싼다. 백틱은 내부 명령어 실행 결과(파이썬 출력)를 문자열로 치환하여 바깥쪽 명령어의 인수로 전달한다.
파이썬3
col@ubuntu:~$ ./col `python -c 'print "\xc8\xce\xc5\x06" * 4 + "\xcc\xce\xc5\x06"'`
File "<string>", line 1
print "\xc8\xce\xc5\x06" * 4 + "\xcc\xce\xc5\x06"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: Missing parentheses in call to 'print'. Did you mean print(...)?
usage : ./col [passcode]
ssh로 접속한 서버의 기본 파이썬 버전은 python3로, 파이썬2처럼 print로 출력을 시도하면 print()
를 괄호로 감싸라고 한다.
col@ubuntu:~$ ./col `python3 -c 'print("\xC8\xCE\xC5\x06"*4+"\xcc\xce\xc5\x06")'`
passcode length should be 20 bytes
하지만 괄호로 감싸면 바이트 수가 다르다고 한다.
print 함수를 사용할 때는 내부 문자열을 바이트로 처리하지 않으므로 길이가 예상과 다르다.
./col $(python3 -c "import sys; sys.stdout.buffer.write(b'\xC8\xCE\xC5\x06'*4+b'\xcc\xce\xc5\x06')")
sys.stdout.buffer.write
를 이용해 바이트 배열을 그대로 출력하면 된다.
$()
는 백틱과 동일한 효과를 내는데, 백틱은 혼란을 야기하므로 $()
를 권장한다.
혹은 그냥 모든 문자를 직접 입력해서 출력해도 된다.
./col $(printf "\xC8\xCE\xC5\x06\xC8\xCE\xC5\x06\xC8\xCE\xC5\x06\xC8\xCE\xC5\x06\xcc\xce\xc5\x06")
해시 충돌
그래서 해시 충돌과 무슨 상관일까?
해시 충돌은 다른 두 값에서 동일한 해시 결과가 출력되는 것을 말한다.
MD5는 해시 충돌이 쉽게 발생한다.
파일의 무결성을 검증하기 위해 MD5로 해시를 생성했다고 하자.
누군가 MD5를 이용하여 내용이 다른데 해시값은 같은 파일을 생성했다. 그러면 다른 파일을 같은 파일이라고 착각할 수도 있다.
check_password()
는 어떤 입력을 다른 값으로 치환하는 일종의 해시 함수라고 할 수 있다.
하지만 동작 방식이 간단한 탓에, 우리는 '21DD09EC'라는 결과값을 임의로 생성할 수 있다.
어떤 수 5개의 합이 21DD09EC이기만 하면 된다.
그래서 check_password()
는 md5처럼 몹시 취약한 해시 함수이다.