PK ZIP已知明文攻击
使用条件
前提:加密算法是 ZipCrypto
- 知道压缩包中任何一个文件的内容。
- 甚至不需要知道完整的文件内容,只需要知道其中的任意 12 个字节(其中 8 个字节必须连续)即可。(Store)
例:
winrar、7z 默认使用 AES256 360压缩、2345好压 默认使用 ZipCrypto
压缩格式:zip、7z、xz、gzip、tar
压缩等级:0、1、3、5、7、9
压缩方法:Deflate、Deflate64、Bzip2、LZMA
加密算法:ZipCrypto、AES-256
题目中已知明文攻击使用
1、查看压缩包信息
zipinfo crypto.zip
bkcrack -L crypto.zip
2、一般形式明文攻击
已知明文信息
bkcrack -C crypto.zip -c 123.txt -p 123
bkcrack -C crypto.zip -c 123.txt -p 123
“明文” 是指压缩后、加密前的内容,该压缩包恰好使用了 “仅存储”(Store)的压缩方式,所以明文就是文件的内容。
#提出压缩包内任意文件
bkcrack -C crypto.zip -c 123.pcap -k da3cda9d 4cbfcb95 cfaa7211 -d 456.pcap
#如果是压缩后的文件
bkcrack -C secrets.zip -c 123.pcap -k da3cda9d 4cbfcb95 cfaa7211 -d 456.pcap.deflate
python3 tools/inflate.py < 456.pcap.deflate > 456.pcap
#重置压缩包密码
bkcrack -C crypto.zip -k da3cda9d 4cbfcb95 cfaa7211 -U secrets_with_new_password.zip ""
#尝试获取原密码
bkcrack -k da3cda9d 4cbfcb95 cfaa7211 --recover-password 10 ?p
bkcrack -k da3cda9d 4cbfcb95 cfaa7211 --bruteforce ?b --length 0..9
例题:
challenge1
bkcrack -C challenge_1.zip -c chromedriver_linux64.zip -p chromedriver_linux64.zip -U cha1_dec.zip ""
密码置空
challenge2
.pcapng
格式文件
pcapng 的开头部分是一个叫 Section Header Block 的数据结构:
- 首先四个字节是 0a0d0d0a,是一定已知的。
- 后面的四个字节表示 SHB 的大小。这个数字可能是大端也可能是小端,计算机处理中一般为小端,而且考虑到 SHB 里面记录的信息不多,一般不会超过 255 字节,再怎么说也不会超过 65535 个字节,所以后面两个字节大概是 0000。
- 然后四个字节表示端序。前面已经说了,目前的电脑都是小端,所以它的值一定是 4d3c2b1a。就算你不信这一点,也可以拿大端再试试,无非就是尝试两次。
- 再后面四个字节表示版本号,一定是 01000000。
- 接下来八个字节表示整个文件的长度,实际上大多数抓包软件都会直接设置为 表示不指定文件长度(道理很好懂:抓包软件可能会流式存储 pcapng 文件,它写文件头的时候抓包还在进行,无法知道这里应该填多少),但严格来说我们无法肯定这一点。
好的,到这一步我们已经能够肯定文件头一定是 0a0d0d0a____00004d3c2b1a01000000
,有 14 个字节,其中 10 个字节连续。
bkcrack -C challenge_2.zip -c flag2.pcapng -p pcap_header.bin -o 6 -x 0 0a0d0d0a -U challenge_2_dec.zip ""
(其中 pcap_header.bin 存储的内容如下:)
如果大胆猜测了文件长度字段是 ffffffffffffffff
,它会跑得更快一些。
实战中已知明文攻击使用
当有文件仅存储时,通过扩展名推测明文
.svg .xml 文件 开头为<?xml version="1.0"
echo -n '<?xml version="1.0" ' > plain.txt
bkcrack -C secrets.zip -c spiral.svg -p plain.txt
.pcapng
文件
bkcrack -C challenge_2.zip -c flag2.pcapng -p pcap_header.bin -o 6 -x 0 0a0d0d0a -U challenge_2_dec.zip ""
# pcap_header.bin = 00004d3c2b1a01000000 or 00004d3c2b1a01000000ffffffffffffffff
.png
文件
echo 89504E470D0A1A0A0000000D49484452 | xxd -r -ps > png_header
bkcrack -C png4.zip -c 2.png -p png_header -o 0
.zip
文件
ZIP文件会包含其中文件名,知道或可以猜测该压缩包内的文件名称
一般来说,单个文件打包成zip压缩文件时,压缩包名默认为文件名,且偏移值固定为30byte。
echo -n "flag.txt" > plain1.txt
bkcrack -C test5.zip -c flag.zip -p plain1.txt -o 30 -x 0 504B0304 >1.log
bkcrack -C 20240103BBB.zip -c 20240103AAA.zip -p head.bin -o 30 -x 0 504B0304 >2.log
echo -n -e "\xf6<<< Oracle VM VirtualBox Disk Image >>>" > plain.txt
bkcrack -C Challange.zip -c oracle.vdi -p plain.txt -o -1
https://pkware.cachefly.net/webdocs/APPNOTE/APPNOTE-6.3.9.TXT
压缩包在存储的文件数据之前添加了一个12字节的加密标头,加密标头的最后一个字节是文件CRC值的最高位字节
Signature(标志):4个字节,表示局部文件头的标志。对于非加密文件,这个值为 0x04034b50。
Version Needed To Extract(需要解压的版本):2个字节,表示解压所需的最低ZIP规范版本。
General Purpose Bit Flag(通用位标志):2个字节,包含一些通用标志,其中第13位(从右向左数)表示是否使用了加密。如果加密,则这个位的值为1,否则为0。
Compression Method(压缩方法):2个字节,表示文件的压缩方法。
Last Mod File Time(最后修改文件时间):2个字节,表示文件最后修改的时间。
Last Mod File Date(最后修改文件日期):2个字节,表示文件最后修改的日期。
CRC-32(CRC校验值):4个字节,表示文件内容的CRC-32校验值。
Compressed Size(压缩后的大小):4个字节,表示文件压缩后的大小。
Uncompressed Size(未压缩的大小):4个字节,表示文件未压缩时的大小。
Filename Length(文件名长度):2个字节,表示文件名的长度。
Extra Field Length(额外字段长度):2个字节,表示额外字段的长度。
Encrypted Data(加密数据):12个字节,只有在通用位标志中的第13位为1时,才存在加密数据。这12个字节包含加密算法标识符和加密所需的一些信息。
通过压缩过的文件进行明文攻击
条件: 适用于全部文件都已经压缩过,需要找到其中一个原始文件。 步骤:
- 压缩文件
- 进行明文攻击
- 寻找zip压缩率
- 测试不同压缩软件
先压缩文件
zip -5 deflate_jpg.zip origin.jpg
直接明文攻击可能失败
bkcrack -C secrets.zip -c secrets.jpg -P deflate_jpg.zip -p origin.jpg
寻找zip不同压缩等级
测试不同的压缩格式
import os
import subprocess
def remove_old_zip(zip_filename):
try:
os.remove(zip_filename)
except FileNotFoundError:
pass # Ignore if the file doesn't exist
def create_new_zip(zip_filename, level, source_file):
subprocess.run(f"zip -{level} {zip_filename} {source_file} > /dev/null", shell=True)
def perform_known_plaintext_attack(plaintext, compressed_filename, target_zip):
subprocess.run(f"bkcrack -C {target_zip} -c {compressed_filename} -P {compressed_filename} -p {plaintext}", shell=True)
if __name__ == "__main__":
plaintext_file = "origin.jpg"
compressed_filename = "deflate_jpg.zip"
target_zip = "Desktop.zip"
for level in range(10):
print(f"\nLevel {level}:")
# Remove old zip file
remove_old_zip(compressed_filename)
# Create new zip file
create_new_zip(compressed_filename, level, plaintext_file)
# Perform known plaintext attack
perform_known_plaintext_attack(plaintext_file, compressed_filename, target_zip)
测试不同的压缩格式
import subprocess
import os
def remove_old_zip(zip_filename):
try:
os.remove(zip_filename)
except FileNotFoundError:
pass # Ignore if the file doesn't exist
def create_new_zip(zip_filename, level, source_file):
subprocess.run(f"7z a -tzip -mx{level} {zip_filename} {source_file} > /dev/null", shell=True)
def perform_known_plaintext_attack(plaintext, compressed_filename, target_zip):
subprocess.run(f"bkcrack -C {target_zip} -c {compressed_filename} -P {compressed_filename} -p {plaintext}", shell=True)
if __name__ == "__main__":
plaintext_file = "very_good_advice.jpg"
compressed_filename = "very_good_advice.jpg.zip"
target_zip = "secrets.zip"
for level in range(1, 10):
print(f"\nLevel {level}:")
# Remove old zip file
remove_old_zip(compressed_filename)
# Create new zip file
create_new_zip(compressed_filename, level, plaintext_file)
# Perform known plaintext attack
perform_known_plaintext_attack(plaintext_file, compressed_filename, target_zip)
ZipCrypto
ZIP 的传统加密,本质上也是异或加密。当然,不是用 password 异或,而是用一个伪随机数流来和明文进行异或。而产生这个伪随机数流,需要用到三个 keys,下文分别以 x,y,z 代指。这三个 keys 非常重要,加密解密过程实质上只需要这三个 keys,密码的作用其实是初始化这三个 keys。简要介绍一下加密流程:在加密前,首先会用密码作为种子初始化这个伪随机数流,然后每加密一个 byte,都会用这个 byte 作为输入产生下一个伪随机数(这个随机数称为 k )。解密过程也是差不多的,首先初始化伪随机数流,然后每解密一个 byte,都用解密后的 byte 作为输入产生下一个伪随机数。
/// 贯穿加密算法的三个 keys
/// 加密/解密每个数据块时都会初始化一次 keys
/// 拿到 keys 不一定能还原出密码,但已经足够进行加密/解密了
pub struct Keys {
x: u32,
y: u32,
z: u32,
}
impl Keys {
/// 使用密码初始化 keys
pub fn new(password: &[u8]) -> Self {
// 首先,使用三个魔术常量初始化 Keys
// (这常量真随便
let mut keys = Self {
x: 0x1234_5678,
y: 0x2345_6789,
z: 0x3456_7890,
};
// 然后,获取密码的字节形式,并用它们更新 Keys
for &c in password {
keys.update_keys(c);
}
keys
// 准备工作就绪了,接下来可以用 keys 来加密//解密文件了
}
/// 加密算法最核心的部分 (超短
/// 在加密/解密过程中, 这个函数会被不断调用,以更新 keys
fn update_keys(&mut self, c: u8) {
self.x = crc32(self.x, c);
// .wrapping_xxx(), 即允许溢出的运算
// 虽然 release 模式下默认不检查溢出,但这样写显得严谨
self.y = (self.y + u32::from(lsb(self.x))).wrapping_mul(0x0808_8405).wrapping_add(1);
self.z = crc32(self.z, msb(self.y));
}
/// 对外提供的解密函数
pub fn decrypt(&mut self, data: &mut [u8]) {
for c in data.iter_mut() {
let p = *c ^ self.get_k();
self.update_keys(p);
*c = p;
}
}
/// 不知道这函数该叫啥。。。功能是从 keys 中计算出一个用来加密/解密的 byte
/// 是个实际操作中可以打表的函数
fn get_k(&mut self) -> u8 {
// 标准中这里是 `| 2`, 其实效果都一样,毕竟 `2 ^ 1 == 3`, `3 ^ 1 == 2`
// 不过写成 `| 3` 有助于后续结论的推导
let temp = (self.z | 3) as u16;
lsb(((temp * (temp ^ 1)) >> 8).into())
}
/// 对外提供的加密函数
pub fn encrypt(&mut self, data: &mut [u8]) {
// 和解密过程基本一样(毕竟异或
for p in data.iter_mut() {
let c = *p ^ self.get_k();
self.update_keys(*p);
*p = c;
}
}
}
/// 朴实的 CRC32 函数,实际操作中一般都会打表
/// 其中 0xEDB8_8320 是 ZIP 标准规定魔术常量
fn crc32(old_crc: u32, c: u8) -> u32 {
let mut crc = old_crc ^ c as u32;
for _ in 0..8 {
if crc % 2 != 0 {
crc = crc >> 1 ^ 0xEDB8l_8320;
} else {
crc = crc >> 1;
}
}
crc
}
/// 朴实的 lsb 函数,注意是最低有效字节,不是最低有效位
fn lsb(x: u32) -> u8 {
x as u8
}
/// 朴实的 msb 函数,注意是最高有效字节,不是最高有效位
fn msb(x: u32) -> u8 {
(x >> 24) as u8
}
// 展示一下用法
fn main() {
let mut keys = Keys::new("123456");
let mut data = b"Illyasviel von Einzbern".bytes().collect::<Vec<_>>();
println!("加密前:\n{:?}\n{}", data, String::from_utf8_lossy(&data));
keys.encrypt(&mut data);
println!("加密后:\n{:?}\n{}", data, String::from_utf8_lossy(&data));
// 注意要重新初始化 Keys
let mut keys = Keys::new("123456");
keys.decrypt(&mut data);
println!("解密后:\n{:?}\n{}", data, String::from_utf8_lossy(&data));
}
https://www.aloxaf.com/2019/04/zip_plaintext_attack/#%E6%98%8E%E6%96%87%E6%94%BB%E5%87%BB https://link.springer.com/content/pdf/10.1007/3-540-60590-8_12.pdf
要想攻击首先需要知道 ZipCrypto 的加密方式。这个加密算法是面向字节流的(而现代对称加密算法是对一个分组进行加密),它内部使用了 3 个 32 比特的整数来表示密钥,可以将它们称为 key0,key1 和 key2。通常我们在打包文件时输入的密码会被转换成这 3 个 dword。算法基本就是用一个循环来加密数据,加密函数记为 UpdateKeys,cpp 代码如下
// p 是用来更新内部 key 的明文
// lsb、msb 分别表示取 dword 的最低和最高字节(不是位)
void PKCipher::UpdateKeys(uint8_t p) {
k0 = crc::Crc32(k0, p);
k1 = (k1 + crc::lsb(k0)) * 134775813 + 1; // 134775813 是 magic number
k2 = crc::Crc32(k2, crc::msb(k1));
}
uint8_t PKCipher::GetK3() {
uint16_t tmp = k2 | 3;
return crc::lsb((tmp * (tmp ^ 1)) >> 8);
}
void PKCipher::Encrypt(std::vector<uint8_t>& data) {
for (uint8_t& p : data) {
uint8_t c = p ^ GetK3();
UpdateKeys(p);
p = c;
}
}
UpdateKeys 仅仅是更新了内部 key,真正对明文进行加密用的是从 key2 派生出来的 16 比特的 key3。可以看到,不仅 key3 只有 16 比特长,它的低两位因为和 3 做了位与操作所以必然是 1,使得 key3 的取值范围缩小到 214,这就有了反推的基础。 https://flandre-scarlet.moe/blog/1685/
结论
知道其中一个文件的明文(很常见,尤其是当压缩一些程序的时候,经常会混入一些已知的 DLL 或者 LICENSE.txt),或者知道一个以 “仅存储” 方式压缩的文件的 12 个字节(也很常见,因为 ZIP、JPEG、PNG 这种格式经常以 “仅存储” 方式压缩,而且在文件头和文件尾也很容易凑出 12 字节的已知明文,还有一种情况就是数据量大的压缩包经过两次压缩之后,就不再压缩文件,仅进行存储,所以才有机可乘。