本文将深入分析一道CTF密码学题目,该题目涉及时序攻击(Timing Attack)和伪随机数生成器(PRNG)的弱点。题目提供了两个文件:
Tch3s:一个ELF可执行文件
output:程序的输出文件,包含加密的flag和测试数据
首先,我们查看提供的文件:
file Tch3s
输出显示:
Tch3s: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, stripped
这是一个64位的Linux可执行文件,且符号表已被移除(stripped),这增加了逆向分析的难度。
让我们先运行程序看看它的行为:
export FLAG="flag{test_flag_for_analysis}"
./Tch3s | head -n 20
程序输出如下:
encrypted flag: BCB00E4069532EC83DA17A2F407858D83A4E557C1E8E2A332AB14B0C637470CE
Test 1 plaintext: 8EE8CCC42881517FA1100B36B29E547A
Test 1 encrypted: 1D2EE01F5596812A6A39FAEAFA745293, costs: 27800.000000 ns
Test 1 decrypted: 8EE8CCC42881517FA1100B36B29E547A, costs: 28936.000000 ns
Test 2 plaintext: 5E2C0A8B0A5188C71F3F487FF85E1C6A
Test 2 encrypted: 7DC18F60A6E5413DB22A3E179B374E7F, costs: 29187.000000 ns
Test 2 decrypted: 5E2C0A8B0A5188C71F3F487FF85E1C6A, costs: 29861.000000 ns
...
从输出可以观察到:
程序首先输出加密后的flag(十六进制格式)
然后进行多轮测试,每次:
生成随机的16字节明文
加密该明文并输出密文
解密密文验证正确性
记录加密和解密所需的时间(纳秒)
head -n 20 output
output文件包含了程序某次运行的完整输出:
encrypted flag: B789EB607A91D08888E2C4C4E4D9573FF5333E4783390B91EDB9D2B4FD2A5FC0
Test 1 plaintext: 720B4455C91B6A024135343386B6679D
Test 1 encrypted: C9BBC42200F90090C6A51602B63D0979, costs: 24497.000000 ns
Test 1 decrypted: 720B4455C91B6A024135343386B6679D, costs: 22873.000000 ns
...
这里的加密flag就是我们的目标:B789EB607A91D08888E2C4C4E4D9573FF5333E4783390B91EDB9D2B4FD2A5FC0
通过动态分析和字符串提取,我们可以推断程序的主要逻辑:
初始化随机数生成器:使用 srand(time(NULL))
生成加密密钥:通过16次 rand() % 256调用生成16字节的密钥
加密flag:使用生成的密钥对flag进行加密并输出
测试循环:进行多次测试,每次生成随机明文进行加密解密测试
问题的关键在于 srand(time(NULL))的使用:
种子空间有限:time(NULL)返回的是Unix时间戳(自1970年1月1日以来的秒数),虽然数值很大,但在特定时间范围内的可能值是有限的。
可预测性:如果我们知道程序大致的运行时间,就可以在一个很小的时间窗口内暴力搜索所有可能的种子。
确定性:相同的种子会产生完全相同的随机数序列。如果我们找到了正确的种子,就能重现所有的随机数,包括用于生成密钥的随机数。
观察output文件中的第一个测试明文:720B4455C91B6A024135343386B6679D
这个明文实际上是由16个随机数(每个 rand() % 256)生成的。由于程序在生成这个明文之前已经调用了16次rand()来生成密钥,我们可以利用这个特性:
攻击思路:
遍历可能的时间戳作为种子
对每个种子:
调用 srand(seed)
跳过前16次rand()调用(密钥生成)
生成16个随机数
检查是否与第一个测试明文匹配
找到匹配的种子后,就能重现密钥
我们编写一个C程序来暴力搜索正确的种子:
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
// 从output文件中获取的第一个test plaintext
uint8_t seq[] = {0x72, 0x0B, 0x44, 0x55, 0xC9, 0x1B, 0x6A, 0x02,
0x41, 0x35, 0x34, 0x33, 0x86, 0xB6, 0x67, 0x9D};
int main() {
// 时间戳应该是最近的,大约在2024-2025年之间
// 1730000000 对应 2024年10月27日
// 1770000000 对应 2026年2月3日
for (int seed = 1730000000; seed < 1770000000; seed++) {
if (seed % 1000000 == 0) {
printf("Progress: %d\\n", seed);
}
srand(seed);
bool good = true;
// 跳过key生成(前16次rand调用)
for (int i = 0; i < 16; i++)
rand();
// 检查生成的随机数序列是否匹配第一个test plaintext
for (int i = 0; i < sizeof(seq) / sizeof(seq[0]); i++) {
unsigned int temp = (unsigned int)rand() % 256;
if (temp != seq[i]) {
good = false;
break;
}
}
if (good) {
printf("Found seed: %d\\n", seed);
// 验证并输出key
printf("Key bytes: ");
srand(seed);
for (int i = 0; i < 16; i++) {
printf("%02X ", (unsigned int)rand() % 256);
}
printf("\\n");
break;
}
}
return 0;
}
程序原理解析:
我们遍历可能的时间戳范围(约4000万个值)
对于每个候选种子,我们模拟程序的行为:先跳过16次rand()调用,然后生成16个随机数
将生成的随机数与output文件中的第一个测试明文进行比对
如果完全匹配,说明找到了正确的种子
编译并运行:
gcc crack_seed.c -o crack_seed
./crack_seed
输出:
Progress: 1730000000
Progress: 1731000000
...
Progress: 1753000000
Found seed: 1753843495
Key bytes: 17 A7 C5 6D 2D DE 47 FD BD CE B6 80 60 B9 16 9C
成功找到种子:1753843495
这对应的时间是:2025年5月30日 10:44:55 GMT+8
为了验证我们找到的种子是否正确,我们编写一个动态库来劫持 srand函数调用:
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
typedef void (*srand_t)(unsigned int seed);
srand_t real_srand;
void srand(unsigned int seed) {
fprintf(stderr, "called srand(%d)\\n", seed);
if (!real_srand) {
real_srand = dlsym(RTLD_NEXT, "srand");
}
char *s = getenv("SRAND");
int real_seed = 0;
if (s) {
sscanf(s, "%d", &real_seed);
fprintf(stderr, "real seed is(%d)\\n", real_seed);
real_srand(real_seed);
} else {
real_srand(seed);
}
}
原理说明:
这个动态库使用 LD_PRELOAD技术来劫持程序对 srand的调用
当程序调用 srand(time(NULL))时,我们的函数会被调用
我们从环境变量 SRAND中读取预设的种子值,并用它替换原始的种子
这样就能让程序使用我们找到的种子,而不是当前时间
编译动态库:
gcc -shared -fPIC hook_srand.c -o hook_srand.so -ldl
使用劫持库运行程序:
export FLAG="flag{test}"
export SRAND=1753843495
LD_PRELOAD=./hook_srand.so ./Tch3s | head -n 10
输出:
called srand(1761836786)
real seed is(1753843495)
encrypted flag: D355DF17F209B90CFFFEA6DA1692780D
Test 1 plaintext: 720B4455C91B6A024135343386B6679D
Test 1 encrypted: C9BBC42200F90090C6A51602B63D0979, costs: 27116.000000 ns
Test 1 decrypted: 720B4455C91B6A024135343386B6679D, costs: 25238.000000 ns
验证成功!注意 Test 1 plaintext完全匹配output文件中的值,这证明我们找到了正确的种子!
现在我们有了正确的种子,意味着程序内存中已经有了正确的密钥。但是我们不想重新实现加密算法(因为二进制文件中的算法可能比较复杂),所以我们采用一个巧妙的方法:利用调试器直接调用程序中的解密函数。
通过反汇编分析,我们找到了两个关键函数:
加密函数地址:0x30ba(相对地址)
解密函数地址:0x31a1(相对地址)
由于程序是PIE(Position Independent Executable),实际运行时的地址会加上一个基址,通常是 0x555555554000。
我们编写GDB脚本来解密flag:
# 设置环境变量
set environment FLAG=flag{test_flag_for_testing_12345}
set environment SRAND=1753843495
set environment LD_PRELOAD=./hook_srand.so
file ./Tch3s
# 在加密函数入口打断点
b *0x5555555570ba
run
# 解密第一部分: B789EB607A91D08888E2C4C4E4D9573F
# 注意字节序转换(小端序)
set *((int *)$rdi) = 0x60eb89b7
set *((int *)$rdi+1) = 0x88d0917a
set *((int *)$rdi+2) = 0xc4c4e288
set *((int *)$rdi+3) = 0x3f57d9e4
# 调用解密函数
p ((void (*)(void*,void*,void*))0x5555555571a1)($rdi,$rsi,$rdx)
x/s $rsi
run
# 解密第二部分: F5333E4783390B91EDB9D2B4FD2A5FC0
set *((int *)$rdi) = 0x473e33f5
set *((int *)$rdi+1) = 0x910b3983
set *((int *)$rdi+2) = 0xb4d2b9ed
set *((int *)$rdi+3) = 0xc05f2afd
# 调用解密函数
p ((void (*)(void*,void*,void*))0x5555555571a1)($rdi,$rsi,$rdx)
x/s $rsi
quit
关键步骤解析:
环境准备:设置环境变量,使用我们找到的种子
断点设置:在加密函数入口处设置断点
内存修改:
当程序执行到加密函数时,$rdi指向明文缓冲区,$rsi指向密文缓冲区,$rdx指向密钥
我们将output中的加密flag写入 $rdi(作为"假明文")
注意字节序:十六进制字符串 B789EB60在内存中以小端序存储为 60 EB 89 B7,对应整数 0x60eb89b7
调用解密函数:直接调用程序中的解密函数,传入相同的三个参数
查看结果:解密后的明文(即flag)会写入 $rsi指向的缓冲区
gdb -batch -x decrypt_exact.gdb
输出:
Breakpoint 1, 0x00005555555570ba in ?? ()
0x55555555b2e0: "flag{tim1ng_a7t@"
Breakpoint 1, 0x00005555555570ba in ?? ()
0x55555555b2e0: "ck_1s_dangerous}"
成功解密!完整的flag是:flag{tim1ng_a7t@ck_1s_dangerous}
这道题目展示了一个经典的密码学安全问题:弱随机数生成。整个攻击链包括:
识别弱PRNG:程序使用 srand(time(NULL))作为随机数种子
种子空间分析:时间戳的可能范围有限,可以暴力搜索
侧信道信息泄露:程序输出的测试数据泄露了随机数序列
种子恢复:通过匹配已知的随机数序列来找到种子
密钥重建:使用恢复的种子重新生成密钥
密文解密:利用程序自身的解密函数完成解密
C标准库的 rand()函数使用线性同余生成器(LCG),其通用公式为:
X[n+1] = (a * X[n] + c) mod m
特点:
完全确定性:给定相同的种子,产生完全相同的序列
可预测性:知道几个连续的输出值,理论上可以推断出种子
周期性:序列会周期性重复
熵不足:time(NULL)的精度是秒级,1秒内的所有调用得到相同的值
可预测范围:我们通常能估计程序的运行时间范围
搜索空间小:即使搜索几年的时间跨度,也只有约1亿个可能值,现代计算机可以在几分钟内完成
题目名为"Tch3s",暗示了"Timing Attack"。虽然本题的主要漏洞是弱PRNG,但程序确实输出了加密/解密时间:
Test 1 encrypted: ..., costs: 24497.000000 ns
Test 1 decrypted: ..., costs: 22873.000000 ns
在现实场景中,这些时序信息可能被用于:
差分功耗分析(DPA)
缓存时序攻击
推断密钥信息
因此,密码学实现应该:
使用恒定时间算法(Constant-time algorithms)
避免分支依赖于密钥数据
小心缓存和内存访问模式
Debian OpenSSL漏洞(2008):由于错误的补丁,OpenSSL的PRNG熵源被严重削弱,导致只有32768种可能的密钥。
Android Bitcoin钱包漏洞(2013):Android的 SecureRandom实现有缺陷,导致生成的ECDSA签名可预测,比特币被盗。
PlayStation 3签名密钥泄露(2010):Sony在ECDSA签名时重复使用了相同的随机数k,导致私钥可被计算出来。
这道CTF题目是一个优秀的教学案例,它展示了:
密码学实现的重要性:即使加密算法本身是安全的,糟糕的实现也会导致系统完全崩溃。
随机性的关键作用:密码学系统的安全性高度依赖于高质量的随机数。
侧信道攻击的威胁:看似无害的信息泄露(如测试数据输出)可能成为攻击的突破口。
实战技能的综合运用:
二进制分析与逆向工程
C编程与系统调用
动态分析与调试技术
密码学原理与攻击技术
"时序攻击"的警示:虽然本题主要利用的是PRNG弱点,但题目名称提醒我们时序信息也可能是攻击向量。
最终答案:flag{tim1ng_a7t@ck_1s_dangerous}
这个flag本身也是一个警告:"时序攻击是危险的(timing attack is dangerous)",提醒开发者在实现密码学系统时必须格外小心。
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
uint8_t seq[] = {0x72, 0x0B, 0x44, 0x55, 0xC9, 0x1B, 0x6A, 0x02,
0x41, 0x35, 0x34, 0x33, 0x86, 0xB6, 0x67, 0x9D};
int main() {
for (int seed = 1730000000; seed < 1770000000; seed++) {
if (seed % 1000000 == 0) {
printf("Progress: %d\\n", seed);
}
srand(seed);
bool good = true;
for (int i = 0; i < 16; i++) rand();
for (int i = 0; i < sizeof(seq) / sizeof(seq[0]); i++) {
unsigned int temp = (unsigned int)rand() % 256;
if (temp != seq[i]) {
good = false;
break;
}
}
if (good) {
printf("Found seed: %d\\n", seed);
srand(seed);
printf("Key: ");
for (int i = 0; i < 16; i++) {
printf("%02X ", (unsigned int)rand() % 256);
}
printf("\\n");
break;
}
}
return 0;
}
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
typedef void (*srand_t)(unsigned int seed);
srand_t real_srand;
void srand(unsigned int seed) {
fprintf(stderr, "called srand(%d)\\n", seed);
if (!real_srand) {
real_srand = dlsym(RTLD_NEXT, "srand");
}
char *s = getenv("SRAND");
int real_seed = 0;
if (s) {
sscanf(s, "%d", &real_seed);
fprintf(stderr, "real seed is(%d)\\n", real_seed);
real_srand(real_seed);
} else {
real_srand(seed);
}
}
编译命令:
gcc crack_seed.c -o crack_seed
gcc -shared -fPIC hook_srand.c -o hook_srand.so -ldl
作者注:本文仅用于技术学习和CTF竞赛交流,请勿将相关技术用于非法用途。