문제
Haven't you ever thought that GCM mode is overcomplicated and there must be a simpler way to achieve Authenticated Encryption? Here it is!
Server: aes-128-tsb.hackable.software 1337
server.py
분석
server.py의 내용은 다음과 같다.
#!/usr/bin/env python2
import SocketServer
import socket
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from struct import pack, unpack
from secret import AES_KEY, FLAG
class CryptoError(Exception):
pass
def split_by(data, step):
return [data[i : i+step] for i in xrange(0, len(data), step)]
def xor(a, b):
assert len(a) == len(b)
return ''.join([chr(ord(ai)^ord(bi)) for ai, bi in zip(a,b)])
def pad(msg):
byte = 16 - len(msg) % 16
return msg + chr(byte) * byte
def unpad(msg):
if not msg:
return ''
return msg[:-ord(msg[-1])]
def tsb_encrypt(aes, msg):
msg = pad(msg)
iv = get_random_bytes(16)
prev_pt = iv
prev_ct = iv
ct = ''
for block in split_by(msg, 16) + [iv]:
ct_block = xor(block, prev_pt)
ct_block = aes.encrypt(ct_block)
ct_block = xor(ct_block, prev_ct)
ct += ct_block
prev_pt = block
prev_ct = ct_block
return iv + ct
def tsb_decrypt(aes, msg):
iv, msg = msg[:16], msg[16:]
prev_pt = iv
prev_ct = iv
pt = ''
for block in split_by(msg, 16):
pt_block = xor(block, prev_ct)
pt_block = aes.decrypt(pt_block)
pt_block = xor(pt_block, prev_pt)
pt += pt_block
prev_pt = pt_block
prev_ct = block
pt, mac = pt[:-16], pt[-16:]
if mac != iv:
raise CryptoError()
return unpad(pt)
def send_binary(s, msg):
s.sendall(pack('<I', len(msg)))
s.sendall(msg)
def send_enc(s, aes, msg):
send_binary(s, tsb_encrypt(aes, msg))
def recv_exact(s, length):
buf = ''
while length %gt; 0:
data = s.recv(length)
if data == '':
raise EOFError()
buf += data
length -= len(data)
return buf
def recv_binary(s):
size = recv_exact(s, 4)
size = unpack('<I', size)[0]
return recv_exact(s, size)
def recv_enc(s, aes):
data = recv_binary(s)
return tsb_decrypt(aes, data)
def main(s):
aes = AES.new(AES_KEY, AES.MODE_ECB)
try:
while True:
a = recv_binary(s)
b = recv_enc(s, aes)
if a == b:
if a == 'gimme_flag':
send_enc(s, aes, FLAG)
else:
# Invalid request, send some random garbage instead of the
# flag :)
send_enc(s, aes, get_random_bytes(len(FLAG)))
else:
send_binary(s, 'Looks like you don\'t know the secret key? Too bad.')
except (CryptoError, EOFError):
pass
class TaskHandler(SocketServer.BaseRequestHandler):
def handle(self):
main(self.request)
if __name__ == '__main__':
SocketServer.ThreadingTCPServer.allow_reuse_address = True
server = SocketServer.ThreadingTCPServer(('0.0.0.0', 1337), TaskHandler)
server.serve_forever()
이 문제의 서버는 입력을 두번받고 출력을 한번해주는 방식으로 작동한다.
다만 모든 입출력은 정해진 형식으로 주고받는데
우선 최초의 4바이트는 전달될 메세지(평문 or 암호문)의 길이를 int packing한 형태이고
그 뒤로 해당 길이만큼의 메세지가 전달된다. (send_binary, recv_binary 참고)
그래서 server.py가 제공하는 함수를 모두 포함하고 있는 client.py를 만들고
보내고 싶은 메세지 두개를 전달인자로 넣어주면 서버의 출력을 반환해주는 run 함수를 만들었다.
def run(msg1, msg2, choice='b'): if choice=='': choice = 'b' send_binary(r, msg1) send_binary(r, msg2) if choice == 'b': return recv_binary(r) elif choice == 'd': return recv_enc(r)
이 문제에서 사용하는 블럭암호 방식은 다음과 같다.
앞으로 이 방식을 TSB 암호화 방식이라 하자.
서버의 메인함수에서는 두개의 입력을 받는다. (msg1, msg2)
그리고 변수 a 에는 msg1을 그대로 넣고
변수 b 에는 msg2를 TSB로 복호화한 값을 넣는다.
그리고 a와 b가 같은지 비교해서 다르면 우리를 놀리는 문자열을 출력한다.
a와 b가 같다면 flag의 길이만큼 랜덤 문자열을 생성한 뒤 TSB로 암호화한 값을 출력한다.
만약 a와 b가 같은데 a가 'gimme_flag'와 같다면 flag를 TSB로 암호화한 값을 출력한다.
이것저것 실험해보는 중 재미있는 점을 발견했다.
c1 = get_random_bytes(16)
c2 = get_random_bytes(16)
run('', c1 + c2 + c1)
위와 같이 실행하면 우리를 놀리는 문자열이 아닌 읽을 수 없는 문자열이 반환된다는 것이다!
+) 두번째 입력이 ABA형태인 이유는 mac검사를 통과하기 위함이다.
+) valid한 암호문은 ABCDE.....A의 형태를 가진다.
잘 생각해보면 아마 서버의 a변수와 b변수가 같아져서 랜덤 문자열의 암호문이 반환된 것 같다!
반환되는 문자열의 길이도 조사해봤는데 항상 96이다.
참고로, 우리를 놀리는 문자열의 길이는 50이다.
그리고 이 코드를 반복적으로 호출하다보면 가끔씩은 길이가 50인 문자열도 반환된다.
왜 그런지 알아보기 위해 복호화과정을 자세히 살펴보니 unpad 함수가 문제였다.
이 암호화방식의 패딩방식은 length 패딩이다. block size에서 부족한 길이 값을 패딩바이트로 넣는 것이다.
(예: msg가 12바이트일때 패딩결과 : msg + '\x04' * 4
근데 unpad를 하는 방식이 깔끔하지 못하다.
메세지의 마지막 바이트만 보고 그 값을 토대로 패딩길이를 판단하고 그 길이만큼의 문자열 꼬리를 잘라낸다.
이 때, 마지막 바이트가 만약 0x10 이상의 값이라면?
unpad 결과 반환되는 문자열은 빈 문자열이다 ('')
아하! 그래서 서버의 a와 b가 일치했던 것이다!
그럼 가끔씩 길이 50인 문자열이 반환된 것은?
복호화 결과 마지막 바이트가 0x10보다 작았던 것이다.
그럼 unpad 결과로 빈 문자열이 반환되지 않았을 것이고 a!=b가 참이 된다.
EXPLOIT
자 이제 이것을 이용해 볼 방법을 찾아보자.
내가 생각해낸 문제를 푸는 기본 개념은 다음과 같다.
a로 '\x01'을 전달하고
unpad되기 전의 b의 값이 '\x01' + '?' * 14 + '\x0f'라면?
a == b가 만족된다.
*) 아래에서는 설명의 편의를 위해 (unpad되기 전의 b)를 b'라 하겠다.
그렇다면 c1으로 '\x01'을 넣고 c2는 계속 랜덤하게 줘서 저 조건을 맞출 것인가?
확률은 1/(256*256) 이다.
가능이야 하겠지만 꽤 오래 걸릴 것 같다.
시간을 획기적으로 단축시킬 방법이 한가지 있다.
run('', c1+c2+c1)의 결과로 길이 50인 문자열이 반환될 때를 찾는 것!
그 때는 b'의 마지막 바이트가 0xf 이하라는 뜻이다!
그 때의 c1, c2가 중요하다.
TSB복호화 과정을 잘 생각해보면
b' = DEC(c1 ^ c2) ^ c1
*) ENC()와 DEC()는 AES 한블럭의 암,복호화를 의미한다.
이때, c1 ^ c2 의 값은 유지한 채로 c1의 특정바이트만 조정하면 b'의 해당바이트를 바꿀 수 있다!
예를 들어 랜덤으로 찾은 c1의 마지막 바이트가 x이고 그 때의 b'의 마지막 바이트가 '\x00'인게 밝혀지면
c1의 마지막 바이트를 x' 으로 바꿨을때 b'의 마지막 바이트는 ('\x00' ^ x) ^ x' 이 될 것이다.
이 점을 이용하면 x'를 잘 조정해서 b'의 마지막 바이트를 우리가 원하는 대로 세팅해줄 수 있다.
그럼 b'의 마지막 바이트가 '\x00'일 때를 어떻게 찾는가?
사실 위에서 b'의 마지막 바이트가 '\x0f'이하로 만드는 방법은 설명을 했다.
이 때의 x'이 중요하다. 만약 이 때 x'이 '\xc4' 였다면
x'을 '\xc0' 부터 '\xcf' 까지 변화시켜보자.
b'의 마지막 바이트가 '\x00'이 될때는 서버 출력 문자열의 길이는 50이 아니라 96일 것이다.
이 또한 unpad 과정을 잘 생각해보면 알 수 있다.
a = '\x01'로 고정하고
b' = '\x01' + '?' * 14 + '\x0f'이 되는 c1을 찾는다.
c1 ^ c2를 유지한다면 c1의 첫바이트만 '\x00' 에서 '\xff'까지 쭉 대입해줬을 때 그 중 한 케이스가 우리가 원하는 b'을 만들어 줄 것이다.
우리가 원하는 b'을 찾았을 때 서버의 출력의 길이는 96이고 아닐 때는 50이라는 점을 이용했다.
그 다음 과정은 이렇다.
이제
a = '\x01\x01'로 고정하고
b = '\x01\x01' + '?' * 13 + '\x0e'이 되는 c1을 찾자.
이 때 c1의 첫바이트는 전 단계에서 찾은 값을 이용하고 두번째 바이트만 바꿔가면서 테스트해주면 된다.
이런식으로 반복하다보면
a = '\x01' * 16일때
b = '\x01' * 15 + '?' * 0 + '\x01'이 되는 c1까지 찾아낼 수 있을 것이다.
이 말은 ENC(c1 ^ c2) = c1 ^ b 라는 것이다.
평문-암호문 쌍 한개를 찾아낸 것이다!!
이 때
xx = c1 ^ c2
yy = c1 ^ b 라 하자.
이제
new_c1 = yy ^ pad('gimme_flag')
new_c2 = xx ^ yy ^ pad('gimme_flag') 이라 하고
run('gimme_flag', new_c1 + new_c2 + new_c1) 을 실행하면
TSB로 암호화된 flag를 얻을 수 있다!
이제 이를 복호화해주는 작업만 남았다.
한 블럭씩 복호화해주는 코드는 다음과 같다.
이때 c_1과 c_2에는 TSB 암호화 된 flag을 블럭단위로 잘라서 차례로 넣어준다.
즉, 처음에는 c_1 = c[0]; c_2 = c[1]이고
두번째에는 c_1 = c[1]; c_2 = c[2]
...
와 같은 방식이다.
코드에 대한 상세한 설명은 주석으로 달아놓았다.
# c는 TSB 암호화 된 flag를 블럭단위로 나누어서 저장해놓은 list
c_1 = c[0] # c[0] : iv
c_2 = c[1]
x = xor(c_1, c_2)
# b'의 마지막 바이트를 0x0f 이하로 만드는 c_1 값 찾기
i = 0x00
while True:
c_1 = get_random_bytes(16)
c_2 = xor(x, c_1)
# print hex(i)
ret = run('', c_1 + c_2 + c_1)
if len(ret) == 50:
# print c_1.encode('hex')
break
i += 1
c_1_found = c_1
# b'의 마지막 바이트를 0x00 으로 만드는 c_1 값 찾기
b_last = c_1[-1]
b_last &= 0xf0
while True:
c_1 = c_1_found[:-1] + chr(b_last)
c_2 = xor(x, c_1)
# print hex(b_last)
ret = run('', c_1 + c_2 + c_1)
if len(ret) == 96:
# print c_1.encode('hex')
break
b_last += 1
c_1_found2 = c_1
# b' == '\x01' * 16인 c_1 값 찾고 평문 한 블럭 구하기
# p = 'DrgnS{Thank_god_no_one_deployed_this_on_producti'
# p_prev = 'this_on_producti'
p_prev = c[0] # 처음에는 iv 값이다.
c_first = ''
i = 1
while True:
b_test = 0x00
c_last = c_1_found2[-1] ^ (0x10 - i) # c_1_found2[-1] == b_last
if i == 16:
break
while True:
c_1 = c_first
c_1 += chr(b_test) + c_1_found[i:-1] + chr(c_last)
c_2 = xor(x, c_1)
#print hex(b_test)
ret = run('\x01' * i, c_1 + c_2 + c_1)
if len(ret) == 96:
#print c_1.encode('hex')
c_first += chr(b_test)
i += 1
break
b_test += 1
d_x = xor('\x01'*16, c_1)
r = xor(d_x, p_prev)
print 'flag : ', p + r
FL4G
처음 한 블럭만 실행해본 모습, flag가 나오기 시작함을 알 수 있다
마지막 블럭에 대해 실행한 모습, 전체 flag를 구해낼 수 있었다.
프로그램을 다 만들고 4개의 블럭에 대해 모두 수행해주는데 걸린시간은 약 40분? 사실 잘 기억이 안난다..
'CTFs' 카테고리의 다른 글
| [CONFidence CTF 2019 Teaser] Bro, do you even lift? writeup (0) | 2019.03.19 |
|---|---|
| [CONFidence CTF 2019 Teaser] Count me in! writeup (0) | 2019.03.19 |
| [HumanCTF] More than privacy writeup (2) | 2018.10.13 |
| [Hackover CTF 2018] I AM MANY writeup (0) | 2018.10.11 |
| [InCTF 2018] Biz44re (forensics) writeup (0) | 2018.10.10 |