西电网安实验班 现代密码学 实验二
题目一
俄罗斯的电子护照编号编码了一个 AES 密钥,我们需要解码出这个密钥然后解密 AES CBC 模式密文。题目说 IV 初始化为 0,那么我们只需要得到密钥。
目标密文(base64):
9MgYwmuPrjiecPMx61O6zIuy3MtIXQQ0E59T3xB6u0Gyf1gYs2i3K9Jxaa0zj4gTMazJuApwd6+jdyeI5iGHvhQyDHGVlAuYTgJrbFDrfB22Fpil2NfNnWFBTXyf7SDI题目已经过期了,现在的 ICAO 文件描述的护照编号格式连长度都不一样。这怎么做?只能抄题解了……
题目二
地址:https://cryptopals.com/sets/2
Implement PKCS#7 padding
不需要特别考虑 content 恰好和块等长的情况,相当于再附加一个只有 padding 的块。
def pkcs_7(content: bytes) -> bytes:
padlen = 16 - (len(content) % 16)
return content + bytes([padlen] * padlen)
pkcs_7("YELLOW SUBMARINE", 20)
# "YELLOW SUBMARINE\x04\x04\x04\x04"Implement CBC mode
from base64 import b64decode
from Crypto.Cipher import AES
def aes_ecb_dec(block: bytes, key: bytes) -> bytes:
assert len(block) % 16 == 0
cipher = AES.new(key, AES.MODE_ECB)
return cipher.decrypt(block)
def bytesxor(a: bytes, b: bytes) -> bytes:
if len(a) > len(b):
return bytes([x ^ y for (x, y) in zip(a[:len(b)], b)])
else:
return bytes([x ^ y for (x, y) in zip(a, b[:len(a)])])
def depkcs_7(content: bytes) -> bytes:
return content[:len(content) - content[-1]]
def aes_cbc_dec(cipher: bytes, key: bytes, iv: bytes) -> bytes:
assert len(cipher) % 16 == 0
result = bytes()
prev_block = iv
for i in range(0, len(cipher), 16):
block = cipher[i:i+16]
decrypted = aes_ecb_dec(block, key)
result += bytesxor(decrypted, prev_block)
prev_block = block
return depkcs_7(result)
key = b"YELLOW SUBMARINE"
with open('10.txt', 'r') as f:
encrypted_b64 = f.read()
cipher = b64decode(encrypted_b64)
print(aes_cbc_dec(cipher, key, b'\x00' * 16))An ECB/CBC detection oracle
通过检查是否有重复块来判断是否是 ECB 模式,用户输入需要有较长的重复 pattern 才能成功。
def aes_ecb_enc(data: bytes, key: bytes) -> bytes:
assert len(data) % 16 == 0
cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(data)
def pkcs_7(content: bytes) -> bytes:
padlen = 16 - (len(content) % 16)
return content + bytes([padlen] * padlen)
def aes_cbc_enc(plain: bytes, key: bytes, iv: bytes) -> bytes:
plain = pkcs_7(plain)
result = bytes()
prev_block = iv
for i in range(0, len(plain), 16):
block = plain[i: i + 16]
encrypted = aes_ecb_enc(bytesxor(block, prev_block), key)
result += encrypted
prev_block = encrypted
return result
def randint(a: int, b: int) -> int:
return randbelow(b - a + 1) + a
def encryption_oracle(plaintext: bytes) -> bytes:
key = token_bytes(16)
plaintext = token_bytes(randint(5, 10)) + \
plaintext + token_bytes(randint(5, 10))
if randbelow(2) == 0:
print("Oracle used: ECB")
ciphertext = aes_ecb_enc(pkcs_7(plaintext), key)
else:
print("Oracle used: CBC")
iv = token_bytes(16)
ciphertext = aes_cbc_enc(plaintext, key, iv)
return ciphertext
def detect_mode(ciphertext: bytes) -> str:
blocks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
if len(set(blocks)) != len(blocks):
return "ECB"
return "CBC"
ciphertext = encryption_oracle(input().encode())
mode = detect_mode(ciphertext)
print(f"Detected mode: {mode}")Byte-at-a-time ECB decryption (Simple)
经典的 ECB Padding Oracle,由于 ECB 对相同 key 相同块的加密无论位置永远相同,我们可以逐字节爆破明文。
由于 key 不变,优化为只初始化 AES cipher 一次。爆破时无需拼接整个 cracked,只需拼接 (padding + cracked)[-15:] 以节省加密时间。
from secrets import token_bytes
unknown = b64decode(
'Um9sbGluJyBpbiBteSA1LjAKV2l0aCBteSByYWctdG9wIGRvd24gc28gbXkgaGFpciBjYW4gYmxvdwpUaGUgZ2lybGllcyBvbiBzdGFuZGJ5IHdhdmluZyBqdXN0IHRvIHNheSBoaQpEaWQgeW91IHN0b3A/IE5vLCBJIGp1c3QgZHJvdmUgYnkK')
key = token_bytes(16)
cipher = AES.new(key, AES.MODE_ECB)
def encryption_oracle(plaintext: bytes) -> bytes:
plaintext = pkcs_7(plaintext + unknown)
ciphertext = cipher.encrypt(plaintext)
return ciphertext
# block_size is 16
# Mode is ECB
cracked = b''
for i in range(len(unknown)):
padding = b'A' * ((16 - 1) - (i % 16))
encrypted = encryption_oracle(padding)
start = i // 16 * 16
target_block = encrypted[start:start + 16]
for j in range(256):
if encryption_oracle((padding + cracked)[-15:] + bytes([j]))[:16] == target_block:
cracked += bytes([j])
break
print(cracked)
assert cracked == unknown
# b"Rollin' in my 5.0\nWith my rag-top down so my hair can blow\nThe girlies on standby waving just to say hi\nDid you stop? No, I just drove by\n"ECB cut-and-paste
同上,我们调整用户输入的邮箱地址,构造一个包含 PKCS#7 padded admin 和 user 这两个块的 k-v 字符串输入。截取加密后 PKCS#7 padded admin 块,替换加密后的最后一个块(PKCS#7 padded user),获得 admin profile 密文。
(如果要验证邮箱地址格式的话这招估计就不管用了。)
def kv_to_dict(input: str) -> dict[str, str]:
result = dict()
for entry in input.split('&'):
k, v = entry.split('=')
result[k] = v.replace('%26', '&').replace('%3D', '=')
return result
def dict_to_kv(input: dict[str, str]) -> str:
entries: list[str] = list()
for entry in input.items():
entries.append(
f'{entry[0]}={entry[1].replace('&', '%26').replace('=', '%3D')}')
return '&'.join(entries)
current_uid = 0
def profile_for(email: str) -> str:
global current_uid
result = dict_to_kv(
{'email': email, 'uid': str(current_uid), 'role': 'user'})
current_uid += 1
return result
print(kv_to_dict('foo=bar&baz=qux&zap=zazzle'))
print(profile_for('foo@bar.com'))
key = token_bytes(16)
cipher = AES.new(key, AES.MODE_ECB)
def send_profile(email: str) -> bytes:
return cipher.encrypt(pkcs_7(profile_for(email).encode()))
def recv_profile(encrypted: bytes) -> dict[str, str]:
return kv_to_dict(depkcs_7(cipher.decrypt(encrypted)).decode())
admin_block = pkcs_7(b'admin')
encrypted = send_profile('A' * (16 - len(b'email=')) +
admin_block.decode() + 'AAAA')
fake_encrypted = encrypted[:-16] + encrypted[16:32]
print(recv_profile(fake_encrypted))
# {'foo': 'bar', 'baz': 'qux', 'zap': 'zazzle'}
# email=foo@bar.com&uid=0&role=user
# {'email': 'AAAAAAAAAAadmin\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0bAAAA', 'uid': '1', 'role': 'admin'}Byte-at-a-time ECB decryption (Harder)
已知随机添加 5 ~ 10 随机字节。我们可以假设额外的随机 padding 长度就是 10,为了检测这种情况已经发生,我们可以在用户输入的 padding 插入一段 b'B' * block_size,检测只有 B 的加密块是否存在,为了检测这种块我们需要先得到它(利用上文提到的 ECB 缺陷),先输入 b'B' * (block_size * 3) 然后检测重复块,重复的就是只有 B 的块。为了使只有 B 的加密块有可能存在(block_size 字节对齐),我们还需要在“B 块”前添加 b'A' * (block_size - 10)。
def hard_encryption_oracle(plaintext: bytes) -> bytes:
plaintext = pkcs_7(token_bytes(randint(5, 10)) + plaintext + unknown)
ciphertext = cipher.encrypt(plaintext)
return ciphertext
def detect_repeat(ecb_enc: bytes) -> int:
assert len(ecb_enc) >= 32
for i in range(0, len(ecb_enc) - 16, 16):
if ecb_enc[i:i+16] == ecb_enc[i+16:i+32]:
return i
cracked = b''
test = hard_encryption_oracle(b'B' * (16 * 3))
offset = detect_repeat(test)
pure_B_block_enc = test[offset:offset + 16]
for i in range(len(unknown)):
extra_padding = b'A' * (16 - 10) + b'B' * 16
padding = b'A' * ((16 - 1) - (i % 16))
encrypted = bytes()
while pure_B_block_enc not in encrypted:
encrypted = hard_encryption_oracle(extra_padding + padding)
start = i // 16 * 16 + 32
target_block = encrypted[start:start + 16]
for j in range(256):
guess_enc = bytes()
while pure_B_block_enc not in guess_enc:
guess_enc = hard_encryption_oracle(
extra_padding + (padding + cracked)[-15:] + bytes([j]))
if guess_enc[32:48] == target_block:
cracked += bytes([j])
break
print(cracked)
assert cracked == unknownPKCS#7 padding validation
def depkcs_7(input: bytes) -> bytes:
padlen = input[-1]
if 0 < padlen <= 16 or input.endswith(bytes([padlen]) * padlen):
raise ValueError("Bad padding")
return input[:-padlen]CBC bitflipping attacks
You're relying on the fact that in CBC mode, a 1-bit error in a ciphertext block:
- Completely scrambles the block the error occurs in
- Produces the identical 1-bit error(/edit) in the next ciphertext block.
KEY = token_bytes(16)
IV = token_bytes(16)
def aes_cbc_enc(plain: bytes, key: bytes, iv: bytes) -> bytes:
plain = pkcs_7(plain)
result = bytes()
prev_block = iv
for i in range(0, len(plain), 16):
block = plain[i:i + 16]
encrypted = aes_ecb_enc(bytesxor(block, prev_block), key)
result += encrypted
prev_block = encrypted
return result
def quote(data: bytes) -> bytes:
return data.replace(b';', b'%3B').replace(b'=', b'%3D')
def encrypt_userdata(userdata: bytes) -> bytes:
prefix = b'comment1=cooking%20MCs;userdata='
suffix = b';comment2=%20like%20a%20pound%20of%20bacon'
plaintext = prefix + quote(userdata) + suffix
return aes_cbc_enc(plaintext, KEY, IV)
def check_admin(ciphertext: bytes) -> bool:
try:
plaintext = aes_cbc_dec(ciphertext, KEY, IV)
return b';admin=true;' in plaintext
except:
return False
# len(b'comment1=cooking%20MCs;userdata=') -> 32
# len(b';comment2=%20like%20a%20pound%20of%20bacon') -> 42
ciphertext = encrypt_userdata(b'A' * (16 + (48 - 42)))
mask = bytesxor(b'AAAAAA;comme', b';admin=true;')
payload = bytesxor(ciphertext[32:32 + len(mask)], mask)
fake_ciphertext = ciphertext[:32] + payload + ciphertext[32 + len(mask):]
print(check_admin(fake_ciphertext))
# True