CTF Pwn题伪随机数预测:利用ctypes复现libc随机数生成器
1. 项目概述当Pwn题遇上伪随机数在CTF的Pwn类题目里我们常常会遇到一些需要绕过次数限制的关卡比如“你只有99次尝试机会猜对了就给你shell”。新手看到这种限制可能会头皮发麻觉得要在茫茫多的可能性中蒙对几乎不可能。但老手一眼就能看出门道这背后往往依赖的是计算机生成的“伪随机数”。既然是“伪”的就意味着它的序列在特定条件下是可预测、可复现的。这次我们要聊的就是一个非常经典且实用的技巧——不依赖任何外部工具或复杂的逆向工程仅用Python标准库中的ctypes模块直接复现目标程序所使用的libc库的随机数生成器从而精准预测下一次的随机数是什么。这听起来有点魔法但原理其实很直接。绝大多数Linux平台下的C/C程序其rand()、srand()、random()等函数都链接自glibcGNU C Library。这些函数在内部维护着一个状态机只要我们能获取到其初始种子seed或者在某些情况下直接获取到其内部状态就能在自己的Python环境中“克隆”出一个一模一样的随机数生成器。ctypes模块正是我们实现这个“魔法”的桥梁它允许Python直接调用动态链接库如libc.so.6中的函数让我们能在Python的世界里操纵C的随机数状态。掌握这个技巧你就能在面对诸如“猜数字”、“验证码爆破”、“抽奖机制绕过”等类型的Pwn题时从被动尝试变为主动预测轻松将那99次验证变成一次精准打击。下面我们就从原理到实战一步步拆解如何实现。2. 核心原理libc随机数生成器的可预测性要成功复现首先得知道我们在复现什么。这里我们主要针对glibc中两个最常用的伪随机数函数rand()和srand()。2.1 glibc中rand()的实现机制现代的glibc例如2.x版本默认使用的随机数生成算法是一种线性同余生成器LCG和更复杂的加法反馈生成器的组合但为了兼容性其rand()和srand()函数通常仍使用一个简单的线性同余生成器LCG其递推公式通常为state (state * 1103515245 12345) 0x7fffffff而rand()函数的返回值通常是(state 16) 0x7fff。关键在于这个state状态。它是一个全局变量在glibc中具体是__random_r或类似结构体中的一个字段。当你调用srand(seed)时就是用这个seed去初始化state。之后每次调用rand()state就按照上面的公式更新一次并返回对应的部分。为什么说它是可预测的确定性算法给定相同的初始stateLCG算法产生的序列是完全确定的。状态暴露在Pwn题目中我们有时能通过信息泄露如格式化字符串漏洞、堆栈信息泄露直接或间接地读到这个state的值。更常见的是题目会使用一个“已知”或“可预测”的种子比如用time(NULL)的返回值作为种子。time(NULL)返回的是自1970年1月1日以来的秒数这在攻击发生的短暂时间窗口内对我们本地环境来说是可知或可枚举的。状态空间有限即使种子未知state的大小也是有限的例如31位在拥有多次尝试机会比如99次的题目中我们有可能通过观察前几个输出值来暴力破解出当前的state。2.2 ctypes模块的角色Python的ctypes是一个强大的外来函数库它允许Python调用由C编译的动态链接库中的函数并使用C兼容的数据类型。在这个场景中我们用它来做三件事加载libc库直接加载目标系统通常是题目提供的libc.so.6或本机系统的libc。调用srand()设置种子让我们Python侧的随机数生成器与目标程序同步起点。调用rand()获取数值验证我们的序列是否与目标程序一致。但这里有一个更巧妙的用法我们不一定需要完全依赖ctypes去模拟整个序列。一旦我们知道了算法如上面的LCG公式我们完全可以在纯Python中实现一个相同的生成器。ctypes更大的价值在于验证和探索——我们可以先通过ctypes调用本机libc的rand()观察其输出来确认我们理解的算法是否正确或者用于快速生成一个已知种子对应的序列与题目输出进行比对。3. 环境准备与工具链搭建工欲善其事必先利其器。我们的实验环境需要能运行Linux ELF程序和Python脚本。3.1 基础环境配置推荐使用一个Linux虚拟机或WSL2Windows Subsystem for Linux 2环境。Ubuntu 20.04/22.04是一个常见的选择其默认的glibc版本与很多CTF题目环境相近。首先确保Python3和必要的开发工具已安装sudo apt update sudo apt install python3 python3-pip gcc libc6-dev -ylibc6-dev包提供了libc的头文件和链接库方便我们进行一些本地测试。3.2 编写靶机程序为了模拟CTF题目我们需要自己先写一个简单的C程序作为“靶子”。这个程序将模拟那种需要猜随机数的场景。创建一个名为challenge.c的文件#include stdio.h #include stdlib.h #include time.h int main() { int seed, my_random, user_guess; int count 0; const int MAX_TRY 99; // 模拟一种常见的种子设置方式使用当前时间戳 seed time(NULL); srand(seed); // 初始化随机数生成器 printf([] Random number generator initialized with seed: %d\n, seed); // 实际题目通常不会打印这个 my_random rand() % 1000000; // 生成一个0-999999之间的随机数 printf([] System has generated a random number between 0 and 999999.\n); printf([] You have %d chances to guess it.\n, MAX_TRY); while (count MAX_TRY) { printf([%d/%d] Enter your guess: , count1, MAX_TRY); scanf(%d, user_guess); if (user_guess my_random) { printf([] Congratulations! You win!\n); // 这里通常是执行 system(/bin/sh) 或给出flag printf([] Here is your flag: FLAG{You_Predicted_The_Random!}\n); return 0; } else { printf([-] Wrong! Try again.\n); } count; } printf([!] Out of chances! The number was: %d\n, my_random); return 0; }编译它gcc -o challenge challenge.c这个程序会打印出种子这是为了方便教学实际CTF题绝不会这么友好然后生成一个随机数让你猜。我们的目标就是在不知道种子的情况下或者利用种子可预测的特性写一个Python脚本一次性猜中。注意实际CTF中种子可能是time(NULL)也可能是固定的数字如srand(0)或者来自某个文件、用户输入。第一步往往是逆向分析或动态调试确定种子的来源。4. 实战复现使用ctypes克隆随机数序列现在进入核心环节。我们假设已经通过逆向分析确定靶机程序使用了time(NULL)作为种子并且我们能够以较高的时间精度知道或推测出程序运行时的Unix时间戳。4.1 方法一直接使用ctypes调用libc的rand这是一种最“暴力”但最直接的验证方法。我们在攻击脚本中用相同的种子调用相同的libc函数。编写攻击脚本exploit1.py#!/usr/bin/env python3 from ctypes import CDLL import time import subprocess # 加载本机的libc库 libc CDLL(libc.so.6) # 关键步骤1预测种子 # 我们需要模拟靶机程序调用srand(time(NULL))的那一刻。 # 由于网络延迟、程序启动耗时等因素两个时间戳可能有微小差异。 # 一个常见的策略是取当前时间戳并在其前后一个小范围内如±2秒进行枚举。 predicted_seed int(time.time()) print(f[*] Current local timestamp (seed candidate): {predicted_seed}) # 为了演示我们先启动靶机程序并获取它打印出的真实种子模拟信息泄露。 # 在实际CTF中这一步可能是通过漏洞读取内存或者直接就是time(NULL)。 proc subprocess.Popen([./challenge], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue) output, _ proc.communicate(input\n) # 先随便输入一个数触发一次猜测 # 从输出中提取种子依赖于我们靶机程序的打印格式 import re seed_match re.search(rseed: (\d), output) if seed_match: actual_seed int(seed_match.group(1)) print(f[*] Actual seed from target: {actual_seed}) predicted_seed actual_seed # 使用真实种子进行演示 else: print([!] Could not extract seed. Proceeding with predicted seed.) # 在实际攻击中这里就需要进行种子枚举了 for offset in range(-2, 3): # 尝试前后2秒的偏移 test_seed predicted_seed offset # ... 用每个test_seed去生成随机数与目标的一次输出进行比对 # 如果比对成功就确定了种子 # 关键步骤2用预测的种子初始化libc的随机数状态 libc.srand(predicted_seed) # 关键步骤3生成第一个随机数对应靶机程序中rand() % 1000000的那次调用 predicted_random libc.rand() % 1000000 print(f[*] Predicted random number: {predicted_random}) # 我们可以验证一下 print([*] Verifying by interacting with the target again...) # 重新运行靶机并直接输入预测的值 proc subprocess.Popen([./challenge], stdinsubprocess.PIPE, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, textTrue) # 这次我们直接输入预测的数字 output, _ proc.communicate(inputf{predicted_random}\n) if Congratulations in output: print([] SUCCESS! The prediction was correct!) else: print([-] Prediction failed. Check seed prediction or algorithm.)这个脚本演示了完整的流程预测/获取种子 - 用ctypes设置相同种子 - 生成相同随机数 - 验证。其中种子预测的准确性是成败的关键。4.2 方法二纯Python实现LCG算法方法一依赖于本机libc与靶机libc的rand()实现完全一致。虽然常见但为了更通用、更深入理解我们可以实现自己的LCG。根据对glibc旧版rand()的分析我们可以写出如下生成器#!/usr/bin/env python3 import time class GlibcRand: def __init__(self, seed0): self.state seed 0xffffffff # 假设状态是32位 def rand(self): # 经典的glibc LCG参数 self.state (self.state * 1103515245 12345) 0x7fffffff return (self.state 16) 0x7fff def srand(self, seed): self.state seed 0xffffffff # 使用示例 if __name__ __main__: seed int(time.time()) # 假设的种子 print(f[*] Seed: {seed}) # 我们的模拟器 my_rand GlibcRand() my_rand.srand(seed) my_prediction my_rand.rand() % 1000000 print(f[*] Pure Python LCG prediction: {my_prediction}) # 与ctypes的结果对比验证算法正确性 from ctypes import CDLL libc CDLL(libc.so.6) libc.srand(seed) ctypes_prediction libc.rand() % 1000000 print(f[*] ctypes libc rand prediction: {ctypes_prediction}) if my_prediction ctypes_prediction: print([] Our Python LCG matches libcs rand()!) else: print([-] Mismatch! The libc implementation might be different.) print( This is why we need to verify the algorithm against the target libc.)这种方法不依赖本地libc的具体实现只要我们知道靶机libc的确切算法和参数即可。如何获取这些参数可以通过逆向分析libc.so.6二进制文件或者更实际一点通过观察输出序列进行暴力破解。4.3 进阶当种子未知时——从输出序列反推状态这才是CTF中最刺激的部分。题目只给你输出不告诉你种子。假设程序连续生成了两个随机数R1和R2比如先输出一个“幸运数字”再让你猜下一个。我们知道state1 (seed * A C) M R1 (state1 16) 0x7fff state2 (state1 * A C) M R2 (state2 16) 0x7fff其中A1103515245, C12345, M0x7fffffff。虽然state的高16位被右移后取低15位得到了R1我们丢失了state的低16位信息。但是由于M是2^31-1state是31位。R1提供了state1的[30:16]这15位信息。我们可以暴力枚举state1的低16位0-65535结合已知的15位重构出候选的state1然后计算state2和R2看是否与观察到的R2匹配。由于只需要枚举65536种可能在现代计算机上是一瞬间的事。编写脚本crack_state.py#!/usr/bin/env python3 def crack_glibc_rand(r1, r2): 根据连续两个glibc rand()的输出r1和r2破解出内部状态。 假设使用经典的 (1103515245, 12345, 0x7fffffff) 参数。 A 1103515245 C 12345 M 0x7fffffff # r1 (state1 16) 0x7fff known_high_bits r1 16 # 这实际上是state1的[30:16]位放在了[30:16]位置低16位是0 candidates [] for low16 in range(0x10000): # 枚举低16位 (0 - 65535) candidate_state known_high_bits | low16 # 验证这个候选state的高15位是否与r1匹配因为右移16位后我们只取了低15位 if ((candidate_state 16) 0x7fff) ! r1: continue # 快速跳过不匹配的候选其实这个循环里大部分都不匹配 # 计算下一个状态和输出 next_state (candidate_state * A C) M predicted_r2 (next_state 16) 0x7fff if predicted_r2 r2: candidates.append((candidate_state, next_state)) return candidates # 模拟靶机用一个秘密种子生成两个数 secret_seed 123456789 from ctypes import CDLL libc CDLL(libc.so.6) libc.srand(secret_seed) r1_observed libc.rand() 0x7fff # 注意rand()返回的是0到RAND_MAX我们取低15位模拟 r2_observed libc.rand() 0x7fff print(f[*] Observed two consecutive rand() outputs: {r1_observed}, {r2_observed}) found_states crack_glibc_rand(r1_observed, r2_observed) if found_states: print(f[] Found {len(found_states)} possible internal state(s).) for s1, s2 in found_states: print(f State after first rand(): {s1:#x}) print(f Predicted next rand() outputs: {(s2 16) 0x7fff}) # 验证用这个状态继续生成下一个数与真实libc对比 libc.srand(0) # 重置没用我们需要直接设置内部状态glibc没有直接设置状态的公开函数。 # 所以更常见的是我们用自己的Python类从破解出的状态开始生成后续序列。 class RandFromState: def __init__(self, state): self.state state def rand(self): self.state (self.state * 1103515245 12345) 0x7fffffff return (self.state 16) 0x7fff my_rand RandFromState(s2) # 从第二个状态开始 r3_predicted my_rand.rand() r3_actual libc.rand() 0x7fff print(f Prediction for third rand(): {r3_predicted}, Actual: {r3_actual}) if r3_predicted r3_actual: print( - State crack successful! Sequence fully predicted.) else: print([-] Failed to crack state. Algorithm parameters might differ.)这个脚本展示了如何仅凭两个输出就破解随机数生成器的内部状态从而预测所有未来输出。在实际CTF中你可能需要根据题目逆向出的libc版本调整LCG的常数A, C, M。5. 整合利用编写通用化攻击脚本将上述所有技术点整合我们可以编写一个相对通用的攻击脚本框架用于应对多种随机数相关的Pwn题。#!/usr/bin/env python3 # exploit_framework.py from pwn import * # 使用pwntools库简化交互过程 from ctypes import CDLL import time, itertools context.log_level debug def predict_with_time_seed(binary_path, time_window5): 针对使用 time(NULL) 作为种子的题目。 time_window: 猜测的时间误差窗口秒通常±2足够。 # 连接目标可能是本地进程或远程 io process(binary_path) # 对于远程用 remote(host, port) # 或者如果题目先打印了一些信息我们需要接收直到提示输入 # io.recvuntil(bguess:) # 策略1直接暴力枚举时间戳种子 current_time int(time.time()) possible_seeds range(current_time - time_window, current_time time_window 1) # 我们需要获取目标程序生成的一个随机数例如程序可能先显示一个“幸运数字” # 假设我们通过交互得到了第一个随机数 target_first_rand # io.sendline(b1) # 随便猜一次触发程序生成或输出随机数 # output io.recvline().decode() # 这里需要根据具体题目逻辑解析出 target_first_rand # 例如 output The lucky number is: 12345 # target_first_rand int(output.split(:)[1].strip()) # 由于我们的靶机程序是先生成后让我们猜我们无法直接拿到第一个数。 # 更常见的CTF场景是程序用rand()生成一个“验证码”显示给你然后让你输入下一个rand()的值。 # 因此我们需要获取连续两个输出。 # 模拟我们假设通过某种方式格式化字符串、堆栈读取获得了连续两个rand()输出 # 这里为了演示我们直接运行一次程序记录输出。 libc CDLL(libc.so.6) for seed in possible_seeds: libc.srand(seed) r1 libc.rand() % 1000000 r2 libc.rand() % 1000000 # 如果已知目标程序的 r1 和 r2就可以在这里比对 # if (r1, r2) (target_r1, target_r2): # print(f[] Found seed: {seed}) # predicted_next libc.rand() % 1000000 # break # 找到种子后计算下一次要猜的数并发送 # io.sendline(str(predicted_next).encode()) # io.interactive() io.close() def crack_from_known_outputs(r1, r2, a1103515245, c12345, m0x7fffffff): 根据已知的两个连续输出破解LCG状态。 返回一个状态生成器列表。 known_high r1 16 candidates [] for low in range(0x10000): s known_high | low if ((s 16) 0x7fff) ! r1: continue s_next (s * a c) m if ((s_next 16) 0x7fff) r2: candidates.append(s_next) # 返回下一个状态 return candidates # 主函数根据题目情况选择攻击方式 if __name__ __main__: # 示例攻击我们编写的靶机程序 # 假设我们已经通过漏洞读到了前两个随机数这里我们运行程序获取 proc subprocess.Popen([./challenge], stdoutsubprocess.PIPE, stdinsubprocess.PIPE, stderrsubprocess.PIPE) # 我们需要与程序交互两次来获得两个连续的随机数不我们的靶机只生成一个数。 # 所以这个例子更适合“显示验证码输入下一个验证码”的题型。 print([*] This framework needs to be adapted to specific challenge flow.)这个框架提供了两种攻击路径的骨架基于时间种子的枚举和基于输出序列的状态破解。在实际使用时你需要根据题目的具体逻辑漏洞点、信息泄露方式、交互流程来填充细节。6. 常见问题与实战避坑指南在实际操作中你会遇到各种各样的问题。下面是我踩过的一些坑和总结的经验。6.1 算法参数不匹配这是最常见的问题。你以为glibc的rand()是那个经典的LCG但可能题目使用的libc版本不同如glibc 2.24的rand()可能使用了不同的算法或者程序直接使用了random()、rand_r()甚至自定义的随机数算法。排查与解决检查libc版本使用ldd命令查看题目二进制文件链接的libc版本。如果题目提供了libc.so.6文件一定要用它用ctypes加载题目给的libc文件而不是本机的。libc CDLL(./libc.so.6) # 加载题目附带的libc动态验证在本地用题目libc和你的Python脚本用同一个种子生成几个数看是否匹配。写一个小C程序调用srand(0); rand();记录输出然后在Python中用ctypes调用题目libc的srand(0); rand();对比。逆向分析如果输出不匹配可能需要用IDA Pro或Ghidra简单看一下rand()函数的实现。搜索常数1103515245和12345如果找不到可能就是其他算法。6.2 种子预测的精度问题对于使用time(NULL)的题目你的攻击机时间和目标服务器时间可能存在差异。即使使用NTP同步也有网络延迟、程序启动耗时等因素。应对策略扩大枚举窗口不要只枚举当前时间戳枚举当前时间前后10秒甚至30秒的范围。对于本地程序这个窗口可以很小±1秒对于远程题目考虑到网络延迟窗口可能需要扩大到±5到10秒。利用多次尝试如果题目允许你进行多次交互比如99次你可以先用前几次尝试来“校准”时间。例如连续发送几个猜测根据反馈对/错来缩小种子范围。更高级的做法是即使猜错程序可能也会泄露一些信息比如“太大了”、“太小了”这可以帮助你进行二分搜索不仅猜数字还能反推种子的可能范围。寻找其他种子源种子可能不是time(NULL)而是getpid()进程ID、clock()、或者从/dev/urandom读取几个字节转化而来。这需要逆向分析来确定。6.3 多线程与rand_r()如果题目涉及多线程并且使用了rand_r()函数情况会复杂一些。rand_r()需要一个指向状态变量的指针每个线程有自己的状态。这意味着你需要找到每个线程状态在内存中的位置并通过漏洞读取它们才能预测该线程的随机数序列。预测的难度大大增加但原理不变。6.4 实战心得与技巧信息收集是第一要务在尝试预测随机数之前尽一切可能获取信息。程序是否打印了种子是否有格式化字符串漏洞可以泄露栈上的state变量是否可以通过堆漏洞读取到包含状态的内存有时种子可能就是固定的0或1337逆向时看到srand(0x539)这样的硬编码就能瞬间破局。本地测试至关重要在攻击远程服务器前一定要在本地用题目提供的二进制文件和libc进行测试。确保你的攻击脚本在本地100%成功。pwntools是你的好朋友使用pwntools库可以极大地简化与程序的交互发送数据、接收数据、处理字符串。它还能方便地处理整数打包、地址转换等琐事。注意取模操作题目通常不会直接使用rand()的原始返回值而是会进行类似rand() % 100、rand() % 0x1000这样的操作。在你的预测脚本里一定要完全复现这个取模过程包括模数的值。状态重置有些题目在每次验证后会再次调用srand()重置状态有些则不会。如果每次验证后状态重置那你每次预测都需要基于新的种子可能是新的时间戳。如果状态是连续的那你只需要预测一次之后的状态都是递推的。