第二届熵密杯复现
第二届熵密杯复现
备赛ing 尝试不用AI看看往年题
赛题结构如下:

初始谜题1
请下载题目附件,并获取在线场景,双击运行附件中的客户端,输入IP地址和端口号连接服务端。请认真分析题目附件中的代码,根据服务端所给出的密文,解密出所对应的明文消息,并提交至服务端。
1 | from sympy import Mod, Integer |
ljust() 和 rjust() 是 Python 字符串(str)的方法,用于对齐字符串。
ljust= left justify(左对齐)rjust= right justify(右对齐)
本题应该算作签到题,核心加密逻辑在于
其中的k在每一块加密完后自加1
考虑到题目在消息前面加的初始前缀已经超过16字节了 而我们又能够获得加密后的密文 因此首先可以将初始密钥恢复出来,进而恢复完整密文(解密函数已经给出,做一点简单的边界处理即可)
因为没有具体的数据,这里直接copy的特等奖队伍的wp中交互拿到的数据,然后再自己做处理
1 | from Crypto.Util.number import * |
初始谜题2
利用本题的情景对SM3算法进行简单的学习(实则是之前学过但是忘了 悲
SM3
SM3 是一个杂凑函数,输入任意长度消息,输出固定长度 256 bit,也就是 64 个十六进制字符。
在 gmssl 里,SM3 的主入口是:
1 | sm3.sm3_hash(msg) |
其中 msg 是字节列表,不是 bytes。
SM3 的整体结构
SM3 的执行过程可以分成四步:
- 消息填充
- 分组
- 每组进入压缩函数
- 输出最终 256 bit 状态值
这和 SHA 家族很像,属于“迭代压缩型哈希”。
gmssl/sm3.py 中的主要函数分工如下:
sm3_hash(msg):主流程sm3_cf(v_i, b_i):压缩函数sm3_ff_j(x, y, z, j):布尔函数 FFsm3_gg_j(x, y, z, j):布尔函数 GGsm3_p_0(x)、sm3_p_1(x):置换函数
1.初始向量 IV
SM3 从固定的 8 个 32 位整数开始:
1 | IV = [ |
这 8 个数构成 256 bit 内部状态。
之后每处理一个 512 bit 分组,就把旧状态和当前分组一起送进压缩函数,算出新状态。
2.消息填充
在 sm3_hash(msg) 中,第一件事是填充消息,使得总长度满足:
- 最终消息长度对 64 字节取模等于 56
- 然后再追加 8 字节消息原始比特长度的二进制表示
3.消息分组
填充完后,消息会被切成 64 字节一组,也就是 512 bit 一组:
1 | group_count = round(len(msg) / 64) |
4.消息扩展
一个512bits的块先分成16个32bits字,然后扩展成68个字W;随后再计算后64个字W‘
W[0..67]W'[0..63]
源码:
1 | for i in range(16): |
这一步可以理解为:
- 先把原始消息分组拆成 16 个字
- 再通过异或、循环左移、置换函数不断“搅开”
- 让后续轮函数能看到更广范围的信息
消息扩展是密码算法里常见的设计,用于提升扩散性。
5.64轮压缩函数 sm3_cf
这是 SM3 的核心。
它的任务是:把
- 旧状态
v_i - 当前消息块
b_i
混合成新的状态。
每一轮都会更新寄存器中的值
具体到每一轮,会包含下面的操作:(了解即可)
SM3 有两个轮函数:
sm3_ff_jsm3_gg_j
它们在前 16 轮和后 48 轮使用不同表达式。
FF
前 16 轮:
1 | x ^ y ^ z |
后 48 轮:
1 | (x & y) | (x & z) | (y & z) |
GG
前 16 轮:
1 | x ^ y ^ z |
后 48 轮:
1 | (x & y) | ((~x) & z) |
置换函数 P0 和 P1
源码:
1 | def sm3_p_0(x): |
这两个函数本质上都是:
- 原值
- 若干次循环左移后的值
- 再异或起来
这种结构的意义在于:
- 计算快
- 容易在不同 bit 之间传播影响
- 有利于抵抗某些线性分析
P1 用在消息扩展里,P0 用在压缩轮内部。
sm3_cf 里有 8 个工作寄存器:
1 | a, b, c, d, e, f, g, h = v_i |
然后做 64 轮迭代:
1 | for j in range(0, 64): |
每一轮都会:
- 混入常量
T_j[j] - 混入消息扩展字
W[j]和W'[j] - 混入当前状态字
- 再通过循环移位和布尔函数更新状态
这和很多哈希算法类似,核心就是反复做:
- 非线性变换
- 位置置换
- 状态搅拌
最终得到全新的 8 个状态字。
6.链式迭代
一组处理完后,SM3 不是直接把轮输出当成结果,而是和旧状态再异或。也就是将原来寄存器中的值和压缩函数处理后的值进行对应位置上的异或
1 | return [v_j[i] ^ v_i[i] for i in range(8)] |
这个“前后状态异或”的设计,使得每轮结果和历史状态强耦合。
最后所有分组处理完,把 8 个 32 位整数拼起来,就是最终摘要。
SM3 KDF 原理
sm3_kdf(z, klen) 是密钥派生函数,经常在 SM2 加密中使用。
它的思路是:
- 准备共享材料
Z - 拼上计数器
ct = 1, 2, 3, ... - 每次算一个 SM3
- 拼接多个哈希结果
- 截断到所需长度
源码里:
1 | for i in range(rcnt): |
为什么要加计数器?
因为一次 SM3 只有 256 bit,如果要导出更长密钥,就需要连续生成多个块。
题目分析
请下载题目附件,并获取在线场景,双击运行附件中的客户端,输入IP地址和端口号连接服务端。请认真分析题目附件中的代码,根据服务端所给出的counter和token,伪造出一个可以通过验证的counter和token,并提交至服务端。
题目代码如下:
1 | import binascii |
题目需要我们输入一个token,使其和根据服务器的hmac_key以及我们输入的counter计算出来的token相同
current_counter中存储着当前计数器状态,当我们输入时,需要让输入的counter的低32bits比当前计数器中的数字大
根据wp分享,本题会给出服务器的token和当前的counter 需要我们伪造出不同的token和counter
因此本题考察的是SM3长度扩展攻击
由于hmac_key=32bits,counter=4bits 因此给出的token是第一个分组处理后的结果
我们只需要对counter用SM3长度扩展攻击的方法去更新即可,使其前半段和本来的第一部分完全相同,然后后面再填上很多0xff(保证counter更大即可)
1 | from gmssl import sm3 , func |
这里counter_bytes用于扩展counter last_block表示最后一个分组填充后的内容(注意我们构造的消息长度是超过一个字节的,因此last_block最后的长度是512+4*8)
这里关键是使用到了gmssl库中的函数:sm3.sm3_cf
该函数根据我们上面的学习,起到压缩函数的功能,将当前寄存器状态和消息块传入后能够得到新的寄存器状态
此外,gmssl库中的func模块有一些辅助函数可以使用(详见说明文档),这里用到了bytes_to_list(虽然实测直接传字节串也没问题)
初始谜题3
请下载题目附件,并获取在线场景,双击运行附件中的客户端,输入IP地址和端口号连接服务端。请认真分析题目附件中的代码,根据服务端所给出的公钥和密文,解密出所对应的明文消息,并提交至服务端。
1 | import sympy as sp |
核心公式:
我们能够首先将x求出,进而将第二个式子进行变化:
噪声很小,只需要判断每个元素是否大于等于125即可判断m的当前位是0还是1了
这里借用鸡块师傅的数据做一个复现,顺便手搓熟悉下sage的语法
1 | from Crypto.Util.number import * |
Flag1: GITEA 服务器
登录GITEA服务器,分析口令加密代码,解密口令密文,并使用口令明文解压协同签名源码文件。
题目代码如下:
1 |
|
该题实现了一个自定义的对称分组密码
为了与其进行对比,同时对后续sm4的学习打一下基础,这里首先回顾一下AES-ECB模式的流程
对于AES-128
共有10轮操作
Step1 Key Expansion(密钥扩展)
例如
1 | 128 bit Key |
扩展成
1 | 11个Round Key |
因为
1 | 初始轮 |
共需要
1 | 11个Key |
Step2 初始轮(AddRoundKey)
状态矩阵state(即明文按列优先规则放置的一个4*4的块)
1 | State |
得到
1 | State |
Step3 第1~9轮
每轮执行四个步骤:
(1)SubBytes
S盒替换
1 | 每个Byte |
主要提供非线性。
(2)ShiftRows
第二行
左移1
第三行
左移2
第四行
左移3
例如
原来
1 | a b c d |
变成
1 | a b c d |
实现字节扩散。
(3)MixColumns
每列看作
GF(2^8)
上的四维向量
乘固定矩阵
得到新的列。
作用:
增加扩散性。
(4)AddRoundKey
再与本轮Key异或
1 | State |
Step4 最后一轮
最后一轮
1 | SubBytes |
没有MixColumns。
了解完AES的标准流程后,我们来看一下这道题的核心流程:
1 | void encrypt(unsigned char* password, unsigned int key, unsigned char* ciphertext) { |
与AES相比,这题的key只有32bits(可以爆破,具体来说,搓一个解密脚本 然后对每一种key,解出来的明文是否是pwd:开头即可)
对于核心加密函数,内部进行了16轮操作,每轮操作按顺序分别进行:
比特逆序
1 | void reverseBits(unsigned char* state) { |
实现了整个数组的前后翻转和每个字节内部的bit反转,逆向时只需要直接copy即可
S盒替换
1 | void sBoxTransform(unsigned char* state) { |
因为本题的S盒只有4*4,所以这里采用了这样的方法进行处理,即对每一个字节将其拆成高4bits和低4bits,分别S盒替换
字节位移
1 | void leftShiftBytes(unsigned char* state) { |
很神秘的自定义操作
轮密钥加
1 | void addRoundKey(unsigned char* state, unsigned char* roundKey, unsigned int round) { |
和AES的轮密钥加相同,将state和roudkey做异或
总的看下来没有什么很明显的漏洞点,主要就是key的长度相对较短,可以爆破
此外,看到官方的wp分享,我们可以采用下面的方法加快运行速度:
1.使用更高级的编译优化方式来加快运行速度:
-O0:不优化,方便单步调试。
-O2:较全面的优化,通常是性能和编译时间的良好平衡。
-O3:更激进,程序不一定总比 -O2 快。
-g:生成调试信息。
-Wall -Wextra:开启常用编译警告。
-static:静态链接 MinGW 运行库,使 EXE 不依赖对应 DLL。
2.针对字节逆序代码 由于函数是在内部处理每个字节的逆序和字符串的逆序的 效率较低,因此可以将字节逆序做预处理,函数内部只做字符串前后逆序
这边训练一下搓c++多线程脚本的能力
一般情况下,两种类型的多任务处理:基于进程和基于线程。
- 基于进程的多任务处理是程序的并发执行。
- 基于线程的多任务处理是同一程序的片段的并发执行。
线程 (Thread)
- 线程是程序执行中的单一顺序控制流,多个线程可以在同一个进程中独立运行。
- 线程共享进程的地址空间、文件描述符、堆和全局变量等资源,但每个线程有自己的栈、寄存器和程序计数器。
并发 与并行
- 并发:多个任务在时间片段内交替执行,表现出同时进行的效果。
- 并行:多个任务在多个处理器或处理器核上同时执行。
C++11 及以后的标准提供了多线程支持,核心组件包括:
std::thread:用于创建和管理线程。std::mutex:用于线程之间的互斥,防止多个线程同时访问共享资源。std::lock_guard和std::unique_lock:用于管理锁的获取和释放。std::condition_variable:用于线程间的条件变量,协调线程间的等待和通知。std::future和std::promise:用于实现线程间的值传递和任务同步。
创建多个线程
例如:
1 |
|
然而 如果要创建的线程很多,总不能像这样一个一个写,因此可以用vector管理线程:
1 | std::vector<std::thread> pool; |
这里emplace_back和push_back功能类似,但是效率更高
这段代码能够自动创建多个线程:
1 | thread0 |
全部创建。
然后:
1 | for(auto &t:pool) |
等待全部结束。
解题代码如下:
1 |
|
测试结果:根据官方wp分享 赛事中应该有提示说key最高位是f,所以从0xf0000000开始
不做字节逆序预处理优化且顺序爆破需要6min
预处理优化+顺序爆破需要2min
预处理+多线程(8 threads)仅需5s,即使不是从0xf0000000开始爆,而是从0开始,也仅需4min即可遍历完整个空间
1 | g++ -O2 flag1mul.cpp -o flag1off.exe |
Flag2:协同签名源码文件
题目描述:
解压协同签名源码文件,分析协同签名算法客户端和服务端代码,寻找并利用协同签名算法缺陷,求解协同签名服务端私钥。
1 | //协同签名客户端代码 |
先对客户端代码进行一个解读,这边有一些vue3和typescript的代码 之前没有接触过,这里和AI做了下交流,进行一个记录:
1 | const form = ref({ |
这段代码中包含了
Vue3 的 ref()
TypeScript 的类型标注 : any
ref是一个响应式函数,能够在变量更新后在浏览器页面自动更新(重新渲染)
而any表示变量k1没有类型,任何类型的数据都是合法的,但是后面用的是””,所以应该是字符串类型
再往下读发现vue的代码要全部搞懂语法比较困难,于是就主要关注注释和主要逻辑了
首先是一个submit提交函数:
1 | smPassword.value = sm3(form.value.password); |
这里首先把用户的口令通过sm3计算其消息摘要作为smpassword
然后利用函数lientsign1的输出对这些变量进行赋值
看到client sign1函数:
首先需要声明,这里的所有BN都代表大整数的意思
然后捋一下clientsign1的核心操作
这里的e是消息摘要,d1是私钥,k1是生成的一个随机数,因此最后能够呈现的信息是点P,Q,签名(r,s),e
现在回到提交函数:
看注释:
客户端将 e, p1x, p1y, q1x, q1y, r1, s1发送给服务端
服务端用服务端私钥d2计算服务端协同签名值 s2, s3, r 发送给客户端
再看client sign2函数:
还是用的和sign1相同的曲线参数,核心操作:
利用服务器传过来的这些数据计算协同签名值s
看到目前没有发现什么可疑的点,再看下服务端代码:
1 | //协同签名服务端代码 |
我们首先看到这里:
1 | if ( |
这里是在对消息能否正确转换类型做检查
可以看到最后有一个BN_copy,所以说正常情况下,k2=k3
服务器首先会对客户端传过来的签名进行常规的sm2验签流程(不过好像跟核心流程关系不大),然后做了个这样的操作:
1 | //k2*G + k3*Q1 = (x1, y1) |
然后将相关的值打印出来:
根据官方wp分享,服务端会给出r,s2,s3的值,又因为刚才审计发现k2=k3,因此可以用下面的方法得到d2:
解题代码比较简单 这里就不贴了(我说这里代码审计30+分钟 解题1分钟)
Flag3:数据库管理系统 [登录]
题目描述:
分析数据库管理系统服务端登录校验代码,寻找并利用登录校验缺陷,登录数据库管理系统。
题目代码:
1 | package controllers |
数据库管理系统管理员证书.cer:
1 | -----BEGIN CERTIFICATE----- |
题目需要我们以shangmibeiadmin作为用户名登陆进入系统,我们同时需要输入的还有私钥以及公钥证书
因此第一想法是注册shangmibeiadmin这个用户,并生成一个公钥直接传给服务器 让服务器返回公钥证书
但是发现用户已注册
不过审计题目代码能够发现校验是并没有检验证书持有者和用户名是否一致,因此我们可以随便注册一个用户,然后指定其公私钥即可
现在问题在于如何生成一对裸露的公私钥
官方wp给出的是找到了示例代码中的裸露公私钥,小鸡块师傅的博客中给出的方法是直接修改源码 进行私钥打印
我感觉都不是很好
想了下直接在sagemath中过一遍sm2流程即可,只需要随机生成一个数作为私钥,然后求G的d倍点即可
sagemath代码如下:
1 | p = 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF |
1 | 0xbb00c2985990dec5ac9b69339d2d20de3e649cbbaf6b0c9c77f10637f2587bf8 |
这里需要注意:需要在公钥前面加上04
因为公钥是一个椭圆曲线点。
一个点有很多编码方式:
1 | 04 || X || Y 未压缩 |
因此需要一个字节告诉别人
我采用的是哪一种编码。
然后本题就只需要把这些数据上传上去就能解决了
Flag4:数据库管理系统 [流量包解密]
题目描述:
登录数据库管理系统,下载并分析总经理协同签名流量包(加密后的文件).dat、SM4加密解密代码(用于加密流量包文件).py、总经理协同签名流量包加密使用的iv.txt,解密总经理协同签名流量包。
1 | from gmssl.sm4 import CryptSM4, SM4_ENCRYPT, SM4_DECRYPT |
总经理协同签名流量包加密使用的iv.txt:
1 | 90fc5cf2e2f47488a257fd51e0ae615b |
这里有两种思路:
思路一
因为这个iv是完整的128bits,所以我们可以取前32bits和33-64bits作为我们的校验点
针对iv0先左移32位,然后爆破低位后 得到完整的当时的global_seed 然后再调用一次getrandom判断是否和iv1相同即可
找到正确的低位后直接再往后过4遍getrandom即可恢复key
为了加速,这里对getrandom函数进行重构:
显然这里面的循环过程是一个LCG循环,我们可以写成这样的形式:
所以我们这里也可以进行预处理优化,来简化getrandom的过程
1 | for (int i=0;i<1000;i++){ |
这里巩固一下之前学的c++多线程操作:
1 |
|
实测秒出
1 | mult = 16758435521279586465 |
思路二
可以将题目转化成HNP问题:
首先有
1000 次 LCG 合并后得到
代入得到
其中
对于四个连续状态,一共有三条方程:
因此最终真正送进 LLL 的矩阵就是
这个格中存在一个特别短的向量,对应于未知量
将它与矩阵相乘后,可以得到
1 | c = "90fc5cf2e2f47488a257fd51e0ae615b" |
Final Challenge: 伪造总经理签名
题目描述:
根据以上题目所获得的信息,对指定的消息摘要计算总经理签名值并提交,提交的签名值需保证通过验证。
我们需要利用两侧路径的解题结果,伪造总经理签名
首先看到flag4的流量包中的tcp流:

除了flag4外,剩下的数据很显然是和flag2的协同签名有关的,而相比于flag2来说,这里多给了一个client_sign字段的值,再回头看看可以发现这是clientSign2函数的返回值,也就是
联立方程组:
做一下变换:
设
构造矩阵:
1 | A = Matrix(Zmod(n),[ |