Some block cipher modes, such as OFB, CTR, or CFB, turn a block cipher into a stream cipher. The idea behind stream ciphers is to produce a pseudorandom keystream which is then XORed with the plaintext. One advantage of stream ciphers is that they can work of plaintext of arbitrary length, with no padding required.
OFB is an obscure cipher mode, with no real benefits these days over using CTR. This challenge introduces an unusual property of OFB.
source.py
from Crypto.Cipher import AES
KEY = ?
FLAG = ?
@chal.route('/symmetry/encrypt/<plaintext>/<iv>/')
def encrypt(plaintext, iv):
plaintext = bytes.fromhex(plaintext)
iv = bytes.fromhex(iv)
if len(iv) != 16:
return {"error": "IV length must be 16"}
cipher = AES.new(KEY, AES.MODE_OFB, iv)
encrypted = cipher.encrypt(plaintext)
ciphertext = encrypted.hex()
return {"ciphertext": ciphertext}
@chal.route('/symmetry/encrypt_flag/')
def encrypt_flag():
iv = os.urandom(16)
cipher = AES.new(KEY, AES.MODE_OFB, iv)
encrypted = cipher.encrypt(FLAG.encode())
ciphertext = iv.hex() + encrypted.hex()
return {"ciphertext": ciphertext}
Do ciphertext = iv.hex() + encrypted.hex(), mình dễ dàng có được iv và flag encrypted.
Để ý thì ở đây, key là không đổi. Vì vậy, mình có thể lấy khối encrypt bằng cách encrypt đoạn text bất kì dài bằng flag và xor nó với chính đoạn text ban đầu. key không đổi khiến cho khối encrypt đó giống với khối dùng để encrypt flag. Cuối cùng chỉ cần XOR khối đó với encrypted flag sẽ ra được flag
I've struggled to get PyCrypto's counter mode doing what I want, so I've turned ECB mode into CTR myself. My counter can go both upwards and downwards to throw off cryptanalysts! There's no chance they'll be able to read my picture.
source.py
from Crypto.Cipher import AES
KEY = ?
class StepUpCounter(object):
def __init__(self, step_up=False):
self.value = os.urandom(16).hex()
self.step = 1
self.stup = step_up
def increment(self):
if self.stup:
self.newIV = hex(int(self.value, 16) + self.step)
else:
self.newIV = hex(int(self.value, 16) - self.stup)
self.value = self.newIV[2:len(self.newIV)]
return bytes.fromhex(self.value.zfill(32))
def __repr__(self):
self.increment()
return self.value
@chal.route('/bean_counter/encrypt/')
def encrypt():
cipher = AES.new(KEY, AES.MODE_ECB)
ctr = StepUpCounter()
out = []
with open("challenge_files/bean_flag.png", 'rb') as f:
block = f.read(16)
while block:
keystream = cipher.encrypt(ctr.increment())
xored = [a^b for a, b in zip(block, keystream)]
out.append(bytes(xored).hex())
block = f.read(16)
return {"encrypted": ''.join(out)}
Mình nghĩ challenge sẽ cho mình tìm ra được keystream đầu tiên bằng cách nào đó. Một block dài 16 bytes, và đây là mã hóa file png. Do không phải một người chơi Forensic nên mình đã phải thử đọc hex của vài file png và nhận ra 16 bytes đầu đều giống nhau.
89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52
Vì vậy mình có thể dễ dàng lấy được keystream đầu. Để ý thêm code class:
Ở đây, mình thấy step_up = False, nên xuống dưới, self.newIV = hex(int(self.value, 16) - self.stup). Tuy nhiên có lỗi chính tả ở đây khi đáng lẽ phải trừ đi self.step. Việc trừ đi self.stup, thứ có giá trị False đã khiến cho iv không tăng/giảm, và làm keystream luôn không đổi.
Vậy là không còn gì khó, mình chỉ cần xor keystream với từng block của file png bị mã hóa thôi.
There may be a lot of redundancy in our plaintext, so why not compress it first?
source.py
from Crypto.Cipher import AES
from Crypto.Util import Counter
import zlib
KEY = ?
FLAG = ?
@chal.route('/ctrime/encrypt/<plaintext>/')
def encrypt(plaintext):
plaintext = bytes.fromhex(plaintext)
iv = int.from_bytes(os.urandom(16), 'big')
cipher = AES.new(KEY, AES.MODE_CTR, counter=Counter.new(128, initial_value=iv))
encrypted = cipher.encrypt(zlib.compress(plaintext + FLAG.encode()))
return {"ciphertext": encrypted.hex()}
Mọi thứ trong thuật toán này đều ổn áp. Vì vậy, chúng ta phải nghĩ đến chuyện khai thác hàm zlib.compressZlib.compress trong python sử dụng thuật toán deflate để nén dữ liệu. Thuật toán deflate bao gồm 2 phương pháp chính là LZ77 encoding và Huffman encoding. Về Huffman encoding, đây đơn thuần là phương pháp encode theo từng ký tự một dựa trên cây Huffman, các ký tự xuất hiện nhiều như chữ 'e' sẽ được sub thành một chuỗi các bit ngắn hơn (3-4 bits thay vì 8 bits như bình thường), cách ký tự xuất hiện ít như chữ 'q' thì được encode bằng các bit dài hơn (không quá 7 bits). Default trong python thì cây Huffman được lấy theo quy chuẩn chung (fixed) nên cuối cùng thì đây chỉ là bước thay thế từng byte thành các bits tương ứng, không có tác dụng gì nhiều lắm. Tiếp theo là LZ77, đây là một thuật toán khá phức tạp, hiểu đơn giản là nó sẽ thay các chuỗi ký tự bị lặp bằng cách tham chiếu đến chuỗi ký tự tương ứng đã được tìm thấy trước đó. Ví dụ minh họa đơn giản:
AAAAABCDEE => A5BCDE2
AAAAXBCDEE => A4XBCDE2
ABABABABXYZT => AB4XYZT
ABABABACXYZT => AB3ACXYZT
ABCABCABCABCMNPQ => ABC4MNPQ
ABCABCABCABXMNPQ => ABC3ABXMNPQ
Từ các ví dụ trên, ta nhận thấy LZ77 sẽ encode ra các chuỗi có độ dài khác nhau nếu số chuỗi ký tự lặp khác nhau, cụ thể là lặp càng nhiều chuỗi, càng liên tục thì kết quả sau khi LZ77 càng ngắn. Mà CTR là một MODE encrypt giữ nguyên độ dài plaintext. Như vậy ta sẽ brute từng ký tự với một độ lặp nhất định và xem sự thay đổi độ dài của ciphertext, và ta sẽ chọn ký tự nào cho ra ciphertext có độ dài ngắn hơn. Minh họa:
FLAG = "crypto{thisIsFlag}"
input = "xxxx"
=> plaintext = "xxxxcrypto{thisIsFlag}"
len(zlib(plaintext)) min <=> input = "cccc" => Xác định được byte đầu của FLAG là 'c'
input2 = 'cxcxcxcx'
=> plaintext = "cxcxcxcxcrypto{thisIsFlag}"
len(zlib(plaintext)) min <=> input = "crcrcrcr" => Xác định được phần tiếp theo là 'cr'
Áp dụng vào code:
import requests
from tqdm import tqdm
def encrypt(plaintext):
url = "https://aes.cryptohack.org/ctrime/encrypt/"
url += plaintext.encode().hex() + "/"
r = requests.get(url)
js = r.json()
return js['ciphertext']
flag = ""
len_dict = {}
while True:
for i in tqdm(range(127,31,-1)):
payload = flag + chr(i)
res = encrypt(payload * 5)
len_dict[chr(i)] = len(res)
char = min(len_dict, key=lambda x: len_dict[x])
flag += char
print(flag)
len_dict.clear()
Flag: cryto{CRIME_571ll_p4y5}
Logon Zero
Before using the network, you must authenticate to Active Directory using our timeworn CFB-8 logon protocol.
Connect at nc socket.cryptohack.org 13399
Attachment: 13399.py
#!/usr/bin/env python3
from Crypto.Cipher import AES
from Crypto.Util.number import bytes_to_long
from os import urandom
from utils import listener
FLAG = "crypto{???????????????????????????????}"
class CFB8:
def __init__(self, key):
self.key = key
def encrypt(self, plaintext):
IV = urandom(16)
cipher = AES.new(self.key, AES.MODE_ECB)
ct = b''
state = IV
for i in range(len(plaintext)):
b = cipher.encrypt(state)[0]
c = b ^ plaintext[i]
ct += bytes([c])
state = state[1:] + bytes([c])
return IV + ct
def decrypt(self, ciphertext):
IV = ciphertext[:16]
ct = ciphertext[16:]
cipher = AES.new(self.key, AES.MODE_ECB)
pt = b''
state = IV
for i in range(len(ct)):
b = cipher.encrypt(state)[0]
c = b ^ ct[i]
pt += bytes([c])
state = state[1:] + bytes([ct[i]])
return pt
class Challenge():
def __init__(self):
self.before_input = "Please authenticate to this Domain Controller to proceed\n"
self.password = urandom(20)
self.password_length = len(self.password)
self.cipher = CFB8(urandom(16))
def challenge(self, your_input):
if your_input['option'] == 'authenticate':
if 'password' not in your_input:
return {'msg': 'No password provided.'}
your_password = your_input['password']
if your_password.encode() == self.password:
self.exit = True
return {'msg': 'Welcome admin, flag: ' + FLAG}
else:
return {'msg': 'Wrong password.'}
if your_input['option'] == 'reset_connection':
self.cipher = CFB8(urandom(16))
return {'msg': 'Connection has been reset.'}
if your_input['option'] == 'reset_password':
if 'token' not in your_input:
return {'msg': 'No token provided.'}
token_ct = bytes.fromhex(your_input['token'])
if len(token_ct) < 28:
return {'msg': 'New password should be at least 8-characters long.'}
token = self.cipher.decrypt(token_ct)
new_password = token[:-4]
self.password_length = bytes_to_long(token[-4:])
self.password = new_password[:self.password_length]
return {'msg': 'Password has been correctly reset.'}
listener.start_server(port=13399)
Bỏ qua sơ đồ trên vì đây là CFB-8.
Ở đây chúng ta sẽ để ý hàm decrypt bởi lẽ đọc code, bạn sẽ thấy chúng ta không động được vào hàm encrypt:
def decrypt(self, ciphertext):
IV = ciphertext[:16]
ct = ciphertext[16:]
cipher = AES.new(self.key, AES.MODE_ECB)
pt = b''
state = IV
for i in range(len(ct)):
b = cipher.encrypt(state)[0]
c = b ^ ct[i]
pt += bytes([c])
state = state[1:] + bytes([ct[i]])
return pt
Hàm này sẽ lấy ra iv và ct từ ciphertext. Khởi tạo giá trị đầu của state là iv. Sau đó, đối với mỗi byte trong ct, ta sẽ thực hiện:
XOR byte đó với byte đầu tiên của khối mã hóa ECB tạo ra từ state
Kết quả thu được sẽ là byte đầu của plaintext, ở đây gọi là c
Cập nhật state bằng cách bỏ đi byte đầu của state và thêm c vào cuối state
Do IV = ciphertext[:16] và ct = ciphertext[16:], chúng ta có thể kiểm soát được 2 thứ này. Đề bài đã gợi ý cho chúng ta về một lỗ hổng trong CFB-8 - ZeroLogon hay CVE-2020-1472. Mục tiêu của lỗ hổng này là làm cho plaintext trả về sẽ toàn là số 0. Vậy ta sẽ làm như nào?
Đầu tiên, ta sẽ truyền vào ciphertext toàn là số 0. Giả sử là 32 số 0. Khi đó:
Như vậy thì new_password sẽ rỗng. Hoàn toàn có thể lấy được flag thông qua option authenticate.
Vậy vấn đề ở đây là làm sao cho cipher = AES.new(self.key, AES.MODE_ECB) trả về giá trị có byte đầu là 0. Ở đây ta có option reset_connection:
if your_input['option'] == 'reset_connection':
self.cipher = CFB8(urandom(16))
return {'msg': 'Connection has been reset.'}
Mỗi khi thực thi nó sẽ trả về một key mới. Vì vậy ta chỉ cần thực hiện option này tới khi nào mà giá trị cipher = AES.new(self.key, AES.MODE_ECB)có byte đầu bằng 0.
Áp dụng những lý thuyết trên:
from pwn import *
import json
from tqdm import tqdm
server = "socket.cryptohack.org"
port = 13399
conn = remote(server, port)
payload = b'\x00' * 32
re_conn = json.dumps({"option":"reset_connection"}).encode()
re_pass = json.dumps({"option":"reset_password", "token":payload.hex()}).encode()
au_pass = json.dumps({"option":"authenticate", "password":""}).encode()
conn.recvline()
for _ in tqdm(range(1000)):
conn.sendline(re_pass)
conn.recvline()
conn.sendline(au_pass)
res = conn.recvline().decode()
if('flag' in res):
print(res)
break
else:
conn.sendline(re_conn)
conn.recvline()
Flag: crypto{Zerologon_Windows_CVE-2020-1472}
STREAM OF CONSCIOUSNESS
Talk to me and hear a sentence from my encrypted stream of consciousness.
source.py
from Crypto.Cipher import AES
from Crypto.Util import Counter
import random
KEY = ?
TEXT = ['???', '???', ..., FLAG]
@chal.route('/stream_consciousness/encrypt/')
def encrypt():
random_line = random.choice(TEXT)
cipher = AES.new(KEY, AES.MODE_CTR, counter=Counter.new(128))
encrypted = cipher.encrypt(random_line.encode())
return {"ciphertext": encrypted.hex()}
Sau khi thử gen ra nhiều kết quả, mình nhận ra có nhiều kết quả trùng nhau. Điều này chứng tỏ, dòng cipher = AES.new(KEY, AES.MODE_CTR, counter=Counter.new(128)) tạo ra một khối mã hóa không giống nhau mỗi lần gọi.
Sau khi chạy hẳn mấy trăm request thì mình thấy có tổng cộng 22 đoạn mã. Vì flag bắt đầu bằng crypto{ nên mình sẽ thử XOR 'crypto{' với từng đoạn mã một để tìm ra 7 bytes đầu của khối mã hóa. Sau đó, mình sẽ XOR từng khối mã hóa mình có với từng 7 bytes đầu của 22 đoạn mã hóa. Khối mã hóa hợp lệ là khối cho ra nhiều đoạn text có thể decode() nhất.
Mình tìm ra nó là b'\x89d\x82\x04\x1d\xcc\xf4'
Vấn đề ở đây là tìm ra các kí tự còn lại của flag. Mình sẽ dựa trên 21 đoạn mã còn lại, dựa trên nghĩa của chúng để đoán kí tự tiếp theo, XOR nó với kí tự đã bị mã hóa ở cùng vị trí và thêm vào key. Dần dần, sẽ ra được flag (XOR Crib Attack)
Mình có viết một chương trình để phục vụ điều đó
from pwn import *
global results_list
results_list = ['c044f16c7ca0984105f8b380c1b37b53d802b4b75986d4b5c545a56d625303a9b1cd2d4bcd92a4a5fd3b3812c3e1d606094fe3ece68a', 'de0cfb2479a3d4194dd4edcccafc3753c547e4b3468dd2a5df4aec626b174aade4cc240fc093a6f6f2702012d4e6de435d44efeab2', 'de0bf76879ecbd4d4dd0e2898df17250c202e2b74bc3d2a4d443ec776d121eefd8852b04dc91a5f6e1792d51c8aec8164a45a2ebe8d4da7ac54e71e5c6f15ba9b2852d290a81fd5bd7', 'c043ef2468a29c0c55c1edc08dda3758ce14f1a0598686a5c501ec776d164aa9f0d0241f8e8ee1bbfa72291e80ecce170964a5e2add1c07ad71e6efac6f842a8fb9d2c2d5e9bf3588d39408a0d97ccd8591ebc', 'c80ae62454ec870544ddf8ccc4f47953d902b4bb5bcd', 'c611f03b3d9b9c1405dee19e92', 'dd0cf06178ec96025cc2b49ed8fd7955c500b8f25f8fc7b5d843ab2364074aa7fed73b0edad1e185f66e355ddae6da42', 'd901f06c7cbc874d4dd4b484cce03751c214e7b74bc3d2a4d40db871641a04eff0cb2c4bc08ee1b4f27f2712c2f79b0d465aacafdac5c066960371f183b946b1b68028211f9cfb5a8634', 'de0ce3703dadd40344c2e0958de07a59c70bb4a6478ad5ecc14ca56d715302aef58b', 'ea16fb7469a38f0616c8a1dbdfa02351f415a7a71ad0f9fd8472aa37324706b2', 'c50bf46131ec841f4ad3f58ec1ea281cff0ff1ab0f87c9a29659ec686b1c1deff9ca3f4bcd8fa4b7e1656c5bd4aed210050deae0fa84c667db0772ea87ed47aabcc76a665e9cfa50c8541687068ed9d85515f6cb616bd7c6460452f38a', 'c044f16c7ca0984d49dee7898df66159d91ee0ba468dc1ecd043a8236b1c1eeff6c03c4bc194acf6f17d2f598e', 'cd0bee6864ec830449ddb498c5fa79578b13fcb35bc3efebdc0da066640503a1f685294bda98a2b9fd786c5ad5fdd9024749a2eee3c08e66de0f6aa392f14bb6be8f2b3a1bc8db15856013964888c98a5117ebcb60669ec34c5643f5c1c30c081952e2a9', 'cd16e7776ee1990c4ed8fa8b8df279588b2afdbe438ac8a9c354', 'dd0ce77778ec9c0257c2f19f81b36354c214b4b14e91d4a5d04aa923285302a0e685014bc592a0a2fb796c5fd9fdde0f4f0debe1add0c67bc54e7de294eb47a5bc8c64655e9cfa5091321287489ad0941413fb983923dcdf56567ebdd78b1a161a1cebe2cda0f5998d692f6842d1ce900f4c451d4aa8b2', 'de0ce3703dadd4014ac5b483cbb36354c209f3a10f97ceadc50db86b601d4abcf4c0250ecdddb5b9b3712912d3e19b0e485ff4eae1c8c167c54e7fed82b95baaba9d30291786f35784704cc2009aca9d1419f7886d6edb8a4b1844f4c38d121c1f5fe4e9cfe9a7d890686a3c5edc8b8947444c1b50e6d5a8357198a4b9e3b314748d167b32c60b6b051b147fe64ce7751d3bddba7174611a5f9d13925cb326', 'c817a26d7becbd4d4dd0f0ccccfd6e1cdc0ee7ba0f97c9ecd348ec6a6b531ea7f4853a02ce95b5f7b3556c51c1e09c1708', 'c70bae2454eb980105d6fbccc4fd3748c447d0bd438fdfecd043a823711606a3b1cd2d19898eb5a4f2752b5ad4aed4165d', 'cb11f62454ec830449ddb49fc5fc601cc30ef9fc', 'dd0ce72469a9861f4cd3f8898de77f55c500b4bb5cc3d2a4d059ec776d164abff0d63c4bca9caff1e73c2e5780fad411470dedfaf984cc6b96076af0c6eb41abaf9a6a', 'c010a2677ca2d31905d3f1ccd9fc65528b08e1a603c3c4b9c50da57725100ba1b1c72d4bc09aafb9e179281c', 'c10bf5246dbe9b184191f582c9b37f5ddb17edf2478681a0dd0dae66250402aaff85200e899aa4a2e03c214b80e0d4174c0c']
key = b'\x89d\x82\x04\x1d\xcc\xf4'
def print_xor_results():
global key
count = 0
for j in results_list:
print(count, xor(key, bytes.fromhex(j)[:len(key)]))
count += 1
def extend_key():
global key
char = input("Nhập vào 1 kí tự: ").strip('\n')
choice = int(input("Chọn một phần tử từ danh sách trên (nhập số): "))
chosen_item = results_list[choice]
add = xor(char.encode(), bytes.fromhex(chosen_item)[len(key)])
key += add
print("Key: ", key)
print_xor_results()
while(True):
extend_key()
print_xor_results()
Sau một hồi ngồi đoán:
Key: Nhập vào 1 kí tự: n
Chọn một phần tử từ danh sách trên (nhập số): 12
Key: b'\x89d\x82\x04\x1d\xcc\xf4m%\xb1\x94\xec\xad\x93\x17<\xabg\x94\xd2/\xe3\xa6\xcc\xb1-\xcc\x03\x05sj\xcf'
0 b"I shall, I'll lose everything if"
1 b'Why do they go on painting and b'
2 b'Would I have believed then that '
3 b"I'm unhappy, I deserve it, the f"
4 b'And I shall ignore it.n\xc6W\t\x98\xef\x82v.\x12'
5 b'Our? Why our?U\x06\xcc\x90Z\x0fN;\xe6x-/\xbf\n\x12\xf5HWT'
6 b'Three boys running, playing at h'
7 b'Perhaps he has missed the train '
8 b'What a nasty smell this paint ha'
9 b'crypto{k3y57r34m_r3u53_15_f474l}'
10 b"Love, probably? They don't know "
11 b'I shall lose everything and not '
12 b"Dolly will think that I'm leavin"
13 b'Dress-making and Millinery\x01\x15\xe2\x04\x04.'
14 b'These horses, this carriage - ho'
15 b'What a lot of things that then s'
16 b'As if I had any wish to be in th'
17 b"No, I'll go in to Dolly and tell"
18 b'But I will show him.\xe4\xf2P\xe8\xe5\xc1O\x07L\xae\xdeP'
19 b'The terrible thing is that the p'
20 b"It can't be torn out, but it can"
21 b"How proud and happy he'll be whe"