tinyCTF2014–ECB,it’seasyas123|详细技术解析

admin 2026-03-06 18:43:35 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析了tinyCTF2014的一道密码学题目,展示了AES-ECB模式加密图像的安全隐患。作者通过识别OpenSSL文件头、统计分析密文块高重复率确认ECB模式,并结合文件大小与线索逆向推算出图像分辨率与色深。最终通过构造合法BMP文件头附加密文数据,成功还原出图像轮廓并获取Flag,生动证明了ECB模式无法隐藏明文结构特征。 综合评分: 98 文章分类: CTF,漏洞分析,逆向分析


第一步:识别文件格式——OpenSSL Salted 格式

先检查 ecb.bmp 的前 16 个字节:

$ xxd ecb.bmp | head -n 2
0000000: 5361 6c74 6564 5f5f ab31 b5e5 ca3d b94d  Salted__.1...=.M
0000010: f409 1aa5 df88 b72c 0ebd 8a73 9815 ba69  .......,...s...i

开头 8 字节是 ASCII 字符串 Salted__,这是 OpenSSL 使用 enc 命令加密时产生的特征标识。

OpenSSL 加密文件的固定格式如下:

偏移  长度   内容
0     8     魔数 "Salted__"(ASCII)
8     8     盐值(Salt),用于密钥派生的随机数
16    N     实际的加密密文数据(AES 分组对齐)

因此,真正的加密数据从偏移 16 字节处开始。

文件尺寸分析:

ecb.bmp 总大小:4,147,344 字节
减去 16 字节 OpenSSL 头部:4,147,328 字节密文

这 4,147,328 字节就是 ECB 模式加密的原始图像数据(含 AES 末尾填充)。


第二步:发现 ECB 模式漏洞——相同密文块的统计分析

将密文数据切分为 16 字节的块,统计每个块出现的频率:

from collections import Counter

with open('ecb.bmp', 'rb') as f:
    data = f.read()

ciphertext = data[16:]  # 跳过 OpenSSL 头部
blocks = [ciphertext[i:i+16] for i in range(0, (len(ciphertext)//16)*16, 16)]
counter = Counter(blocks)

print(f"总块数: {len(blocks)}")
print(f"唯一块数: {len(counter)}")
for block, cnt in counter.most_common(5):
    print(f"  {block.hex()} : {cnt} 次 ({cnt/len(blocks)*100:.1f}%)")

输出结果:

总块数: 259,208
唯一块数: 211

最频繁的块:
  323f8b1eb8209c99fcb546010bc94134 : 225,972 次 (87.2%)
  b01707a0210e6a262b86dbb5b8649b91 :  14,333 次 (5.5%)
  262870b320050ffdca0075999c1ae724 :    673 次 (0.3%)
  fc551cd4ef31ba30373fd7a46499de2f :    657 次 (0.3%)
  65539b0bd11d6b9079c11b8cd667a421 :    582 次 (0.2%)

结论极为清晰:

  • 259,208 个密文块中,有 225,972 个(87.2%)完全相同
  • 仅有 211 种不同的块(远小于理论上的 2^128 种可能)
  • 前两种块合计占 92.7%

这完全印证了 ECB 模式的漏洞:图像中 87.2% 的 16 字节区域是相同的像素组合(白色背景),加密后也产生了完全相同的密文块 32 3f 8b 1e ...加密并没有隐藏图像的结构信息。


第三步:确定图像参数——逆向推算分辨率和色深

要重建图像,需要知道原始图像的三个参数:宽度、高度、色深(每像素位数)。

题目给出了线索:4K 分辨率,黑白 BMP。

尝试推算:

BMP 格式要求每行像素数据按 4 字节边界对齐。设色深为 bpp(bits per pixel),宽度为 W,高度为 H,则:

每行字节数 = ceil(W * bpp / 8),向上取整到 4 的倍数
像素数据总大小 = 每行字节数 * H

密文大小为 4,147,328 字节(含最多 16 字节的 AES 填充),因此纯像素数据约为 4,147,200 字节。

验证 3840×2160、4bpp(16色调色板):

每行字节数 = ceil(3840 * 4 / 8) = 1920 字节(已是 4 的倍数)
像素数据总大小 = 1920 * 2160 = 4,147,200 字节

精确匹配。差值 4,147,328 – 4,147,200 = 128 字节,正好是 AES 填充(最多 16 字节)加上可能的额外块。

因此确定:

| 参数 | 值 | | — | — | | 宽度 | 3840 像素(4K) | | 高度 | 2160 像素(4K) | | 色深 | 4 bpp(16 色调色板) | | 每行字节数 | 1920 字节(已 4 字节对齐) | | 每行 16 字节块数 | 120 块 | | 每块覆盖像素 | 32 像素宽 |


第四步:构造有效的 BMP 文件头

BMP 文件由三部分组成:

1. BITMAPFILEHEADER(14 字节)

| 字节 | 字段 | 值 | | — | — | — | | 0-1 | 魔数 | BM | | 2-5 | 文件总大小 | 4,147,318 | | 6-9 | 保留字段 | 0 | | 10-13 | 像素数据偏移 | 118(= 14 + 40 + 64) |

2. BITMAPINFOHEADER(40 字节)

| 字节 | 字段 | 值 | | — | — | — | | 0-3 | 头部大小 | 40 | | 4-7 | 宽度 | 3840 | | 8-11 | 高度 | 2160(正数 = 从下到上存储) | | 12-13 | 颜色平面数 | 1 | | 14-15 | 每像素位数 | 4 | | 16-19 | 压缩方式 | 0(BI_RGB,不压缩) | | 20-23 | 像素数据大小 | 4,147,200 | | 24-31 | 分辨率(像素/米) | 0 | | 32-35 | 调色板颜色数 | 16 | | 36-39 | 重要颜色数 | 0 |

3. 调色板(64 字节 = 16 色 x 4 字节/色)

使用 Windows 标准 16 色调色板,每个颜色以 BGRA 格式存储(蓝、绿、红、保留):

palette_rgb = [
    (0,0,0), (128,0,0), (0,128,0), (128,128,0),
    (0,0,128), (128,0,128), (0,128,128), (192,192,192),
    (128,128,128), (255,0,0), (0,255,0), (255,255,0),
    (0,0,255), (255,0,255), (0,255,255), (255,255,255)
]

为什么是 4bpp 而不是 1bpp(纯黑白)?

题目描述说”黑白格式”,但从密文块的统计来看,只有 211 种唯一块,超过了 1bpp 只有黑白两色的情况。实际上 4bpp 的 16 色 BMP 在题目所述的”黑白”场景下包含了渐变灰度,且 3840x2160x4/8 = 4,147,200 与密文大小精确匹配,1bpp 不满足。


第五步:完整解题脚本

import struct, os
from collections import Counter

with open('ecb.bmp', 'rb') as f:
    data = f.read()

# 步骤1:去掉 OpenSSL Salted__ 头部(前 16 字节)
# 格式:"Salted__" (8字节) + Salt (8字节)
ciphertext = data[16:]

print(f"Magic: {data[:8]}")
print(f"Salt: {data[8:16].hex()}")
print(f"密文长度: {len(ciphertext)} 字节 = {len(ciphertext)//16} 个16字节块")

# 步骤2:ECB 统计分析
blocks = [ciphertext[i:i+16] for i in range(0, (len(ciphertext)//16)*16, 16)]
counter = Counter(blocks)
print(f"唯一块数: {len(counter)} / {len(blocks)}")

# 步骤3:图像参数
width, height, bpp = 3840, 2160, 4
row_bytes = (width * bpp + 7) // 8          # = 1920
img_data_size = row_bytes * height           # = 4,147,200
pixel_offset = 14 + 40 + 64                 # = 118(文件头 + DIB头 + 调色板)
file_size = pixel_offset + img_data_size     # = 4,147,318

# 步骤4:构造 BMP 文件头
bmp_file_header = struct.pack('<2sIHHI',
&nbsp; &nbsp;&nbsp;b'BM', file_size,&nbsp;0,&nbsp;0, pixel_offset)

dib_header = struct.pack('<IiiHHIIiiII',
&nbsp; &nbsp;&nbsp;40, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 头部大小
&nbsp; &nbsp; width, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 宽度
&nbsp; &nbsp; height, &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 高度(正数 = 从下往上存储)
&nbsp; &nbsp;&nbsp;1, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 颜色平面数
&nbsp; &nbsp; bpp, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 每像素位数
&nbsp; &nbsp;&nbsp;0, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 压缩方式(BI_RGB)
&nbsp; &nbsp; img_data_size, &nbsp;# 像素数据大小
&nbsp; &nbsp;&nbsp;0,&nbsp;0, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 水平/垂直分辨率
&nbsp; &nbsp;&nbsp;16, &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# 调色板颜色数
&nbsp; &nbsp;&nbsp;0&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;# 重要颜色数
)

# 标准 Windows 16 色调色板(BGRA 格式)
palette_rgb = [
&nbsp; &nbsp; (0,0,0), (128,0,0), (0,128,0), (128,128,0),
&nbsp; &nbsp; (0,0,128), (128,0,128), (0,128,128), (192,192,192),
&nbsp; &nbsp; (128,128,128), (255,0,0), (0,255,0), (255,255,0),
&nbsp; &nbsp; (0,0,255), (255,0,255), (0,255,255), (255,255,255)
]
color_table =&nbsp;b''.join(
&nbsp; &nbsp; struct.pack('BBBB', b, g, r,&nbsp;0)&nbsp;for&nbsp;r, g, b&nbsp;in&nbsp;palette_rgb
)

# 步骤5:组合头部 + 密文像素数据(取精确的图像大小)
header = bmp_file_header + dib_header + color_table
pixel_data = ciphertext[:img_data_size]

with&nbsp;open('result.bmp',&nbsp;'wb')&nbsp;as&nbsp;f:
&nbsp; &nbsp; f.write(header + pixel_data)

print(f"成功写入 result.bmp,大小:&nbsp;{len(header) + len(pixel_data)}&nbsp;字节")

第六步:理解为什么这个方法有效

关键原理:ECB 模式下密文保留了明文的结构。

原始图像的像素数据存储方式(BMP,16色,4bpp):

明文数据(像素字节流):
[像素0-31][像素32-63]...[像素3808-3839] <- 第 2160 行(BMP底部,文件最前)
[像素0-31][像素32-63]...[像素3808-3839] <- 第 2159 行
...
[像素0-31][像素32-63]...[像素3808-3839] <- 第 1 行(BMP顶部,文件最后)

每 16 字节 = 32 个像素(4bpp,每字节 2 像素)。

ECB 加密后:

密文数据(对应的密文块):
[加密(像素0-31)][加密(像素32-63)]...[加密(像素3808-3839)]
...

由于白色背景区域的 32 个像素完全相同(全部是白色的 4 位颜色值),ECB 加密后这些块的密文也完全相同(32 3f 8b 1e ...)。

当我们把有效的 BMP 文件头注入到密文前面,图像查看器就会把每个密文块直接当作像素值来渲染。虽然颜色会因为密钥不同而显示为随机颜色,但图像的结构(哪里是背景、哪里是内容)完整保留。这就是为什么图像”可读”——即使没有解密,结构性信息(flag 文字、企鹅图案的轮廓)依然清晰可见。


第七步:验证结果

运行脚本后,用图像查看器打开 result.bmp

$ file result.bmp
result.bmp: PC bitmap, Windows 3.x format, 3840 x 2160 x 4,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; image size 4147200, cbSize 4147318, bits offset 118

文件被正确识别为有效的 BMP 文件。打开图像后,可以清晰看到图像中显示的 flag 文字:

flag{no_penguin_here}

技术总结

ECB 模式对图像加密的危害

| 对比项目 | ECB 模式 | 安全模式(CBC/CTR/GCM) | | — | — | — | | 相同明文块 | 产生相同密文块 | 每次产生不同密文 | | 图像结构 | 完全暴露 | 完全隐藏 | | 统计特征 | 保留原始分布 | 密文均匀分布 | | 是否安全 | 不安全 | 安全 |

攻击方法的本质

本次攻击属于”已知密文攻击”的一种特殊形式——利用 ECB 的确定性特点(相同明文 -> 相同密文),通过统计分析和结构重建,在不知道密钥的情况下恢复了加密图像的可视内容。

解题的三个关键发现

  1. 文件识别Salted__ 魔数表明文件被 OpenSSL 加密,前 16 字节是 OpenSSL 头部,需要剥离才能得到纯密文数据。
  2. ECB 统计分析:259,208 个密文块中,仅有 211 种不同的值,且最常见的块占 87.2%。这直接暴露了 ECB 模式的使用,也说明原始图像颜色非常单一(大量白色背景)。
  3. 参数推算:通过密文长度(4,147,328 字节 ≈ 4,147,200 字节像素数据)与 4K 分辨率提示,精确推算出图像为 3840x2160x4bpp,并据此构造了合法的 BMP 文件头。

Flag

flag{no_penguin_here}

免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:破镜安全 破镜安全 破镜安全《tinyCTF 2014 – ECB, it’s easy as 123 | 详细技术解析》

评论:0   参与:  0