CTF writeups/Crypto

[Cyber Apocalypse CTF 2022] Jenny From The Block

Now1z 2022. 5. 20. 13:08

1. Investigation

썸네일용

>> chall.py

from hashlib import sha256
from Crypto.Util.Padding import pad, unpad
import signal
import subprocess
import socketserver
import os

allowed_commands = [b'whoami', b'ls', b'cat secret.txt', b'pwd']
BLOCK_SIZE = 32


def encrypt_block(block, secret):
    enc_block = b''
    for i in range(BLOCK_SIZE):
        val = (block[i]+secret[i]) % 256
        enc_block += bytes([val])
    return enc_block


def encrypt(msg, password):
    h = sha256(password).digest()
    if len(msg) % BLOCK_SIZE != 0:
        msg = pad(msg, BLOCK_SIZE)
    blocks = [msg[i:i+BLOCK_SIZE] for i in range(0, len(msg), BLOCK_SIZE)]
    ct = b''
    for block in blocks:
        enc_block = encrypt_block(block, h)
        h = sha256(enc_block + block).digest()
        ct += enc_block

    return ct.hex()


def run_command(cmd):
    if cmd in allowed_commands:
        try:
            resp = subprocess.run(
                cmd.decode().split(' '),  capture_output=True)
            output = resp.stdout
            return output
        except:
            return b'Something went wrong!\n'
    else:
        return b'Invalid command!\n'


def challenge(req):
    req.sendall(b'This is Jenny! I am the heart and soul of this spaceship.\n' +
                b'Welcome to the debug terminal. For security purposes I will encrypt any responses.')
    while True:
        req.sendall(b'\n> ')
        command = req.recv(4096).strip()
        output = run_command(command)
        response = b'Command executed: ' + command + b'\n' + output
        password = os.urandom(32)
        ct = encrypt(response, password)
        req.sendall(ct.encode())


class incoming(socketserver.BaseRequestHandler):
    def handle(self):
        signal.alarm(30)
        req = self.request
        challenge(req)


class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
    pass


def main():
    socketserver.TCPServer.allow_reuse_address = True
    server = ReusableTCPServer(("0.0.0.0", 1337), incoming)
    server.serve_forever()


if __name__ == "__main__":
    main()

문제에서 주어진 파일을 다운로드 해봤더니 위와 같은 파이썬으로 작성된 소스코드를 확인할 수 있었습니다.

 

대략적인 로직을 살펴보니 아래와 같은 방식으로 동작하고 있었습니다.

  1. 사용자로부터 command 를 입력받음
  2. 입력받은 command 가 allowed_commands 에 있는지 확인하고 subprocess 로 해당 command 실행한 결과를 output 에 저장 (allowed_commands = [b'whoami', b'ls', b'cat secret.txt', b'pwd'])
  3. b'Command executed: ' + command + b'\n' + output 형태의 평문을 만들어 랜덤으로 생성된 32 바이트 크기의  password 와 함께 encrypt 를 수행
  4. 반환된 ciphertext 를 출력

 

encrypt 는 다음과 같은 로직으로 동작하고 있었습니다.

  1. password 의 sha256 해시값을 h 에 저장
  2. 평문을 32 바이트 단위로 패딩한 후 블록으로 나눔
  3. 첫번째 블록, h 와 함께 encrypt_block 수행한 결과를 enc_block 에 저장
  4. enc_block 과 첫번째 블록을 이어붙여 만든 문자열의 sha256 해시값을 h 에 저장
  5. ciphertext 에 enc_block 붙임
  6. 이를 반복하여 완성된 ciphertext 를 반환 (ciphertext = enc_block_1 + enc_block_2 + ... + enc_block_n)

 

encrypt_block 은 다음과 같은 로직으로 동작하고 있었습니다.

  1. 블록과 h 를 한자리씩 10진수로 더한 값에 256 으로 나머지 연산 수행 -> val = (block[i] + h[i]) % 256
  2. 연산된 값을 바이트 형태로 변환하여 enc_block 에 붙임 -> enc_block += bytes([val])
  3. 이를 반복하여 완성된 enc_block 을 반환 (enc_block = val_1 + val_2 + ... + val_31 + val_32)

 

결론적으로는 FLAG 를 찾기 위해 allowed_commands 중에 "cat secret.txt" 를 실행하고, 그 결과를 암호화하여 반환된 ciphertext 를 위 로직에서 약점을 찾아 초기에 랜덤으로 생성된 32 바이트 크기의 password 를 모르더라도 복호화하여 평문을 알아내야 한다는 것을 알 수 있습니다.


2. Solution

일단 문제 서버에 접속해 "cat secret.txt" 를 command 로 입력하여 다음과 같은 ciphertext 를 반환 받았습니다.

ct = '8f0684e23cd3dec423f4a9668226c06e022430c66c76684ef5eef210b7c60222fb44b765be60dc328266cd2b06a66f4f699da138e497b4de6c6d9e111ec743ba4b1183d4574843634e4c3bbcdeb5a92f3c71a7e6398b516cfd0fd52d96346a961868a3ad7d2e2e405c1014b8517acd226deae2de4981715684e056764c20b98e72c35ac16ed49deb2928be3096939861cff45a0474f52048f16d3aabbbd78881b25e74f0830a87f093a1c0fa613119a046e679ac498d3efd8681741814db2dd48cca92b22acd37f5303079a0d8fa019d32bf20f5162d0dc8a04122f65535f13bae55a31c03c583ff947fdf2ab7add9fb4a159c3210ad17c68ba56d827d1a29d7'

# 블록 단위로 쪼갬
CBLOCK1 = '8f0684e23cd3dec423f4a9668226c06e022430c66c76684ef5eef210b7c60222' (Length : 32 bytes)
CBLOCK2 = 'fb44b765be60dc328266cd2b06a66f4f699da138e497b4de6c6d9e111ec743ba' (Length : 32 bytes)
CBLOCK3 = '4b1183d4574843634e4c3bbcdeb5a92f3c71a7e6398b516cfd0fd52d96346a96' (Length : 32 bytes)
CBLOCK4 = '1868a3ad7d2e2e405c1014b8517acd226deae2de4981715684e056764c20b98e' (Length : 32 bytes)
CBLOCK5 = '72c35ac16ed49deb2928be3096939861cff45a0474f52048f16d3aabbbd78881' (Length : 32 bytes)
CBLOCK6 = 'b25e74f0830a87f093a1c0fa613119a046e679ac498d3efd8681741814db2dd4' (Length : 32 bytes)
CBLOCK7 = '8cca92b22acd37f5303079a0d8fa019d32bf20f5162d0dc8a04122f65535f13b' (Length : 32 bytes)
CBLOCK8 = 'ae55a31c03c583ff947fdf2ab7add9fb4a159c3210ad17c68ba56d827d1a29d7' (Length : 32 bytes)

 

위에서 평문은 b'Command executed: ' + command + b'\n' + output 와 같은 형태로 이루어진다고 했으므로  command 부분에 "cat secret.txt" 를 넣어보면 다음과 같은 평문이 만들어집니다. (output 은 cat secret.txt 실행 결과)

pt = b'Command executed: cat secret.txt' + b'\n' + output

위 평문을 password 와 함께 encrypt 한 것의 결과가 바로 반환받은 ciphertext 입니다.

 

문제의 소스코드에서 32 바이트 단위로 블록을 나누고 있기 때문에 평문을 블록으로 나눠보겠습니다.

 

PBLOCK1 : b'Command executed: cat secret.txt' (length: 32)

PBLOCK2 : b'\n' + output????????????????????? (length: 32)

PBLOCK3 : ...

...

PBLOCKn : ?????padpadpadpadpadpadpadpadpadpad (length : 32)

대략 위와 같이 평문 블록이 구성되며 공교롭게도 첫번째 블록이 정확히 32 바이트인 것을 확인할 수 있습니다.

 

첫번째 블록만을 가지고 encrypt 가 어떻게 수행되는지 생각해보겠습니다.

   |  PBLOCK1 : b'Command executed: cat secret.txt'
+  |  h       : b'sha256(password)=???????????????'
---------------------------------------------------
   |            ??????????????????????????????????
%  |            mod 256 in each digit
---------------------------------------------------
                enc_block_1

이를 식으로 나타내면 enc_block_n[i] = (PBLOCK_n[i] + h_n[i]) % 256 과 같은 형식이 됩니다.

여기서 우리는 enc_block 을 이어붙인 것이 곧 ciphertext 이고, 첫번째 평문 블록의 값들을 알고 있기 때문에 식에 대입해보면 0x8f(=ciphertext 첫번째 블록의 첫번째 바이트) = (b'C'(=0x43) + ?) % 256 라는 식이 나오며 ? 는 h 가 sha256 해시값이기 때문에 한 바이트 당 최대 0xff 까지 표현이 가능하여 합리적인 시간 내에 Brute-Force 를 통해 ? 값을 알아낼 수 있게 됩니다.

-> 모든 32 자리의 바이트에 대해서 Brute-Force 를 수행하면 password 의 sha256 해시값을 알 수 있음. (문제풀이에는 불필요)

 

for i in range(0x100):
    if(0x8f == (0x43 + i) % 256):
        print(i)   # 76 == 0x4c

 

위와 같은 과정을 머리 속에 담아두고 두번째 블록으로 넘어가보면 이제는 평문을 모르고 h 값은 sha256(enc_block1 + pblock1) 을 통해 알아낼 수 있기 때문에 enc_block_2[i] = (? + h_2[i]) % 256 와 같은 식이 만들어지며 ? 는 평문이라 ASCII 범위 내의 문자일 것이기 때문에 똑같이 최대 0xff 까지 Brute-Force 를 수행해보면 됩니다.

 

for i in range(0x100):
    if(0xfb == (i + 0xf1) % 256):
        print(i)   # 10 == 0x0a == b'\n'

 

결론

  1. ciphertext 를 알고 있기 때문에 각 enc_block 또한 알 수 있음.
  2. 첫번째 평문 블록이 공교롭게도 정확히 32 바이트 길이기 때문에 다음 두번째 블록의 encryption 에 사용되는 h 값을 구할 수 있음. (h = sha256(enc_block_1 + pblock_1))
  3. 0x00 ~ 0xff 범위 내의 Brute-Force 를 수행하여 enc_block_n[i] = (PBLOCK_n[i] + h_n[i]) % 256 식을 만족하는 평문을 알아냄

 

>> sol.py

from hashlib import sha256

def decrypt(cblocks, pblock):
    plaintext = b''
    for i in range(7):
        enc_block1 = bytes.fromhex(cblocks[i])

        print(f"[+] enc_block_{i+1} + pblock_{i+1}: ", enc_block1.hex(), "+", pblock)        
        h = sha256(enc_block1 + pblock).digest()
        print("[+] h: ", h.hex())

        enc_block2 = bytes.fromhex(cblocks[i+1])
        pblock = b''
        for a, b in zip(enc_block2, h):
            for j in range(0x100):
                if(a == (j + b) % 256):
                    pblock += bytes([j])
        plaintext += pblock
        print(f"[+] pblock_{i+2}: ", pblock)
        print('='*64)
        
    return plaintext

def main():
    # b'Command executed: ' + command + b'\n' + output
    BLOCK_SIZE = 64
    ct = '8f0684e23cd3dec423f4a9668226c06e022430c66c76684ef5eef210b7c60222fb44b765be60dc328266cd2b06a66f4f699da138e497b4de6c6d9e111ec743ba4b1183d4574843634e4c3bbcdeb5a92f3c71a7e6398b516cfd0fd52d96346a961868a3ad7d2e2e405c1014b8517acd226deae2de4981715684e056764c20b98e72c35ac16ed49deb2928be3096939861cff45a0474f52048f16d3aabbbd78881b25e74f0830a87f093a1c0fa613119a046e679ac498d3efd8681741814db2dd48cca92b22acd37f5303079a0d8fa019d32bf20f5162d0dc8a04122f65535f13bae55a31c03c583ff947fdf2ab7add9fb4a159c3210ad17c68ba56d827d1a29d7'
    cblocks = [ct[i:i+BLOCK_SIZE] for i in range(0, len(ct), BLOCK_SIZE)]
    for i, block in enumerate(cblocks):
        print(f"[+] enc_block_{i+1}: ", block)
    print('='*64)
    
    pblock1 = b'Command executed: cat secret.txt'

    plaintext = decrypt(cblocks, pblock1)
    print("[+] plaintext: ", plaintext)
    # [+] plaintext:  b'\nIn case Jenny malfunctions say the following phrase: Melt My Eyez, See Your Future  \nThe AI system will shutdown and you will gain complete control of the spaceship.\n- Danbeer S.A.\nHTB{b451c*****************w34k!!!}\n\x07\x07\x07\x07\x07\x07\x07'
    

    



main()

🚩 FLAG : HTB{b451c*****************w34k!!!}