宝塔 Linux 面板 (BaoTa Panel) 代码安全审计报告
- 审计对象: 宝塔 Linux 面板 v11.8.0 源码(约 1042 个 Python 文件 / 43 万行)
- 审计日期: 2026-08-11
- 审计范围: 反序列化、命令执行/RCE、SQL 注入、未授权访问、硬编码凭据、XSS、SSRF、越权/IDOR 等可获取系统/用户/应用权限或数据的问题
- 运行环境: Flask + gevent WSGIServer,以 root 权限运行于
/www/server/panel
⚠️ 前置说明:本面板按设计即以 root 运行,本身就是一个"登录后等于 root"的管理系统。因此本次审计的核心风险判断标准是:漏洞是否能让攻击者突破其原本拥有的权限边界——即未认证访问、低权限子账号提权到 root、或从"仅操作数据"升级为"执行系统命令/读取系统文件"。
风险总览
| 严重程度 |
数量 |
类别 |
| 🔴 严重 (Critical) |
9 |
越权提权链(未认证/子账号 → root RCE)、任意文件读取、存储型 XSS |
| 🟠 高危 (High) |
10 |
命令注入、SQL 注入、SSRF、存储型 XSS |
| 🟡 中危 (Medium) |
10 |
条件性命令注入、zip-slip、未授权分支、二次注入 |
| 🟢 低危 (Low) |
4 |
硬编码默认值、反序列化链(需前置条件)、信息泄露 |
一、反序列化
1.1 【低危】pickle 反序列化汇点(需前置条件链)
| 项 |
内容 |
| 文件 |
class/cachelib/serializers.py:50 |
| 汇点 |
BaseSerializer.loads → pickle.loads(dumps) |
| 用途 |
Flask session / cache 序列化 |
| 可达性 |
仅当攻击者能写入 cache/session 文件并知晓对应键名时才可触发;面板 session 为服务端文件存储,不直接可控 |
| 结论 |
不可独立利用,属于纵深缺陷 |
1.2 【低危】task.py pickle.loads(MD5 完整性校验保护)
| 项 |
内容 |
| 文件 |
class/task.py:2455 |
| 汇点 |
pickle.loads(f_data) |
| 保护 |
task.py:2452 校验 MD5 完整性(需要先能写文件 + 伪造 MD5) |
| 结论 |
需命令注入/文件写前置,独立利用难度高 |
1.3 【信息】PluginLoader 动态库非 pickle
class/PluginLoader.x86_64.Python3.7.so 经 strings 分析:含 AES 加密(_ZN3AES7EncryptEPKcPcli)、db_encrypt/db_decrypt、base64、rapidjson、popen/system 符号——为 AES 加密插件加载器,不存在 pickle 反序列化接口。
反序列化小结:三个潜在汇点均需"先获得文件写入 + 键名/完整性控制"的前置条件,而获取该前置条件本身已经是更严重的漏洞。无反序列化直接可利用的 RCE。
二、命令执行 / RCE
前提:public.ExecShell()(class/public.py:633)默认 shell=True。以下所有 ExecShell 汇点均已确认。
2.1 🔴【严重】多控制器命令注入(面板任意命令执行)
面板存在 15 处以上 用户参数直拼 shell 的汇点,任意登录用户(含低权限子账号)均可通过 /system、/files、/ftp、/cron、/database、/plugin、/ssl 等路由触发,获取 root 权限。其中已在源码中逐一核实的:
| # |
文件:行 |
入口 |
危险代码 |
注入参数 |
| 1 |
class/system.py:1216 |
/system?action=ServiceAdmin |
execStr = "/etc/init.d/" + get.name + " " + get.type |
name/type 无任何白名单校验 |
| 2 |
class/system.py:1559 |
/system?action=upgrade_panel |
shell = 'nohup ... upgrade_panel {} &>...'.format(get.version) |
version 仅校验非 None |
| 3 |
class/files.py:3514-3516 |
/files?action=SetFileAccess |
'chmod ' + all + ' ' + get.access + " '" + get.filename + "'" 及 'chown ' + all + ' ' + get.user |
access/user 未加引号、未校验(仅 filename 经 CheckDir) |
| 4 |
class/files.py:4724-4729 |
/files?action=exec_git |
nohup git clone {} ... / nohup {} ... |
giturl,正则 ^(https?|git|ssh|ftp|ftps)://[^\s/$.?#].[^\s]*$ 中 [^\s]* 允许 ; & 反引号等元字符,可注入 |
| 5 |
class/ftp.py:713 |
/ftp?action=ModifyFtpUserAccess |
subprocess.check_call(cmd, shell=True) |
time_restrictions/max_files/max_sim_sessions 完全无校验(IP 参数有 ipaddress 校验、username 有正则,但这三个裸奔) |
| 6 |
class/panelSSL.py:1228-1231 |
/ssl?action=GetSSLInfo |
'rm -rf /etc/letsencrypt/archive/' + get.siteName |
siteName 未在 publicObject 的 path/xss 校验列表内(但需有效 partnerOrderId) |
| 7 |
class/panelTask.py:583 |
解压任务 |
unzip -X -P '"+password+"' -o ... |
zip 密码无转义(rar 分支 line 595 有转义、7z 分支用双引号,唯独 zip 分支裸拼) |
| 8 |
class/crontab.py:1544 |
/cron?action=GetShell |
nohup .../modify_second_cron.sh {} {} {} &.format(time_type, second, cronName) |
second/time_type 直拼 |
攻击示例(2.1-#1 ServiceAdmin):
GET /system?action=ServiceAdmin&name=nginx%3Bid%3B&type=start
# 实际执行: /etc/init.d/nginx;id; start → root 权限执行 id
根因:面板功能层对业务参数(服务名、版本号、权限、IP 列表、压缩包密码、站点名)缺少统一的 shell 元字符过滤,只对部分参数做局部校验,且各控制器零权限归属校验(见第五节)。
三、SQL 注入
3.1 🔴【严重】/data getKey — 读取任意表任意字段(SELECT 列/表名注入)
| 项 |
内容 |
| 文件 |
class/data.py:688-714 |
| 入口 |
/data?action=getKey&table=...&key=...&id=...(白名单含 getKey) |
| 根因 |
ORM public.M() → class/db.py getField(keyName) → "SELECT " + __OPT_FIELD + " FROM " + __DB_TABLE。get.table 拼入表名、get.key 拼入列名,均为原生字符串,仅 id 参数化 |
| PoC |
/data?action=getKey&table=users&key=password FROM users WHERE id=? -- &id=1 → 读出 admin 密码哈希 |
| 附加 |
data.py:706-711:当 table=config 且 key=mysql_root 时,直接用读取值执行 /usr/bin/mysql --user=root --password='{password}' → 可借注入拿到 MySQL root |
3.2 🔴【严重】/data GetSql — log_type 直拼 WHERE
| 项 |
内容 |
| 文件 |
class/data.py:758-761(getData) |
| 入口 |
/data?action=getData&table=logs&log_type=...(白名单含 getData) |
| 根因 |
where = " type = '{}' ".format(get.log_type) 直拼,param=() 空参数化 |
| PoC |
log_type=1' UNION SELECT password,2,3 FROM users -- |
3.3 🔴【严重】/database GetMysqlUser — MySQL root 查询注入
| 项 |
内容 |
| 文件 |
class/database.py:3415-3428 |
| 入口 |
/database?action=GetMysqlUser&sid=1&search=... |
| 根因 |
user_data = mysql_obj.query("SELECT user FROM mysql.user WHERE ... and user like '%{}%' GROUP BY user;".format(search)) —— search 零转义直拼,以 MySQL root 身份执行 |
| PoC |
search=x%' UNION SELECT current_user() -- |
SQL 注入小结:三处均为最直接的字符串拼接型注入,经 public.M() ORM 层(仅 where(param) 值参数化,field/table/order/limit 全裸)与直连数据库函数,可读面板用户密码哈希与 MySQL root 数据。均需登录(经 comm.local()),但低权限子账号同样可触发(控制器无权限归属检查)。
四、SSRF
4.1 🟠【高危】/cron check_url_connecte — 任意 URL 请求(内网探测)
| 项 |
内容 |
| 文件 |
class/crontab.py:1906-1921 |
| 入口 |
/cron?action=check_url_connecte&url=... |
| 根因 |
response = requests.get(get['url'], timeout=30) 无 scheme/IP 限制,返回 status_code + public.xsssec(response.text) |
| 利用 |
访问 http://127.0.0.1:8888/... 探测内网/云元数据(如 http://100.100.100.200/latest/meta-data/),读取内网服务响应文本 |
| 危害 |
内网横向探测、云厂商元数据访问(可凭 EC2/阿里云 InstanceRole 提权) |
4.2 🟡【中危】/site check_ssl — 443 端口连接探测
| 项 |
内容 |
| 文件 |
class/panelSite.py:9234-9252 |
| 根因 |
socket.create_connection((get.hostname, 443)) + TLS 握手,通过错误差异做内网 443 端口连通性 oracle |
五、越权 / IDOR(权限边界突破)
5.1 🔴【严重】Referer 缺失绕过子账号菜单鉴权
| 项 |
内容 |
| 文件 |
BTPanel/__init__.py:221-224 |
| 根因 |
user_Authority() 子账号功能权限检查仅在 request.referrer 存在时才生效;无 Referer 头的请求(curl 直连)直接跳过菜单授权 |
| 利用 |
低权限子账号对任意受限接口(如 /files?action=ExecShell、/system)用 curl --referer '' 直连 → 绕过功能授权,直接操作 root 级功能 |
| 验证 |
grep 确认 panelSite.py/database.py/crontab.py/files.py 中 0 处 session['uid']/get_user_power 归属判断 |
5.2 🔴【严重】set_temp_login — 子账号/任意用户创建临时登录 → 全面板权限
| 项 |
内容 |
| 文件 |
class/config.py:2850-2883;链:class/userlogin.py:285-340 |
| 根因 |
set_temp_login 仅检查 'tmp_login_expire' in session,不检查 session['uid'] != 1。当临时登录表为空时设置 pdata['id'] = 101;request_temp 据此设置 session['uid'] = 101(users 表中不存在该 uid)→ 后续菜单鉴权查询用户为空 → 跳过权限检查,获得完整面板(含文件管理、终端)访问 |
| 利用链 |
低权限账号 → 调用 set_temp_login 创建 48 位临时 token → 用该 token 请求 request_temp → 以"幽灵 uid=101"身份登录 → 完整 root 面板 |
| 同类 |
remove_temp_login(2886)、clear_temp_login(2903)、get_temp_login_logs(2922)同样缺 uid 检查 |
5.3 🟠【高危】set_admin_path — 无 uid 校验修改面板路径并重启
| 项 |
内容 |
| 文件 |
class/config.py:797 |
| 危害 |
任意登录用户可改面板访问路径;配合其他点可制造配置破坏 |
5.4 🟠【高危】IDOR — 数据库/站点/FTP 操作无归属校验
所有站点/数据库/FTP 管理接口(删除站点 DeleteSite、改 FTP 权限等)均不校验操作对象归属。低权限子账号可:
- 删除/修改其他子账号甚至管理员的站点(
DeleteSite 无 owner 校验)
- 查看/导出任意站点配置、数据库备份
- 修改任意 FTP 用户权限
六、未授权访问 / 任意文件读写
6.1 🔴【严重】/download — 认证后任意文件下载(root)
| 项 |
内容 |
| 文件 |
BTPanel/__init__.py:1442-1494 |
| 根因 |
filename = request.args.get('filename') → html.unescape(filename) → 仅检查非设备/目录 → send_file(filename, as_attachment=True)。无 path_safe_check、无路径白名单 |
| 利用 |
任意已登录用户下载 /etc/shadow、/www/server/panel/data/userInfo.json、config/api.json、SSL 私钥等 |
| 验证 |
comm.local() 确认需登录;但低权限子账号同样可达(见 5.1/5.2 提权) |
6.2 🟠【高危】/down/<token> — 预认证任意文件分享
| 项 |
内容 |
| 文件 |
BTPanel/__init__.py:1497、/down 分享(token 校验)→ line 1599 发送 find['filename'] |
| 根因 |
token 文件分享机制对目标文件不做 path_safe_check(仅对分享子路径 fname 检查)。若攻击者可构造/猜出分享记录(token 为随机串,需一定条件),可下载任意路径 |
| 风险 |
分享的 filename 完全由创建者指定;配合 download_file 等任意读可把任意文件放进分享池 |
6.3 🔴【严重】/files download_file — 回收站路径穿越任意文件读取
| 项 |
内容 |
| 文件 |
class/files.py:1842-1873 |
| 根因 |
rname = get.rname.strip() 无 path_safe_check;file_path = os.path.join(recycle_bin, rname) 对 ../../ 会规范化 → shutil.copyfile(file_path, "/.Recycle_bin/download/"+name) → 再从 /.Recycle_bin/download 下载 |
| 利用 |
rname=../../etc/shadow&name=x → os.path.exists 通过 → 复制 /etc/shadow 到可下载目录 → 读取任意文件 |
| 验证 |
已亲自核对代码,os.path.join 接受 .. 穿越,os.path.exists 对目标存在时为 True |
6.4 🟡【中危】/static/、/code 仅检查 session 键存在性
| 项 |
内容 |
| 文件 |
BTPanel/__init__.py:279-285 |
| 根因 |
静态文件与 /code 分发只判断 'login'/'admin_auth'/'down' 键是否存在,不校验值是否有效 |
| 说明 |
session 键值本身难以伪造(服务端存储),但属于弱校验模式 |
6.5 🟡【中危】/mail_sys/send_mail_http.json 未认证分支
| 项 |
内容 |
| 文件 |
BTPanel/__init__.py:2235-2381 |
| 根因 |
若 mail_sys 插件已安装,该接口跳过 comm.local()(无认证),参数白名单 ['mail_from','password','mail_to','subject','content','subtype','data'],可被未认证者滥用发信(垃圾邮件/钓鱼),且 line 2359-2367 对插件模板做 render_template_string(t_body, data=data) |
| 风险 |
未认证 SMTP 滥发 + 插件模板内容若含用户数据则有模板注入面(当前 data 为插件自有数据,风险受限于插件) |
6.6 🟡【中危】get_module_template — 路径穿越读取任意 .html
| 项 |
内容 |
| 文件 |
class/config.py:3569-3591(sms 分支:render_template_string)、class/panelPush.py:154-161 |
| 根因 |
sfile = '{}/class/msg/{}.html'.format(panelPath, module_name) —— module_name 未校验可含 ../,读取面板目录外任意 .html |
七、XSS
7.1 🔴【严重】存储型 XSS — 未认证 User-Agent → 管理员登录日志页
| 项 |
内容 |
| 写入端 |
class/public.py:8302-8322 record_client_info:user_agent = request.headers.get('User-Agent','') 未清洗直接入库;class/userlogin.py:582-587 __login_error() 在登录失败时(未认证触发,20+ 处调用)写入 client_info 表 |
| 回显端 |
class/logsModel/panelModel.py:462-521 get_panel_login_log:直接返回 user_agent 字段,无转义(仅 remote_addr 过 return_area) |
| 渲染端 |
BTPanel/static/js/utils.js:5132-5133 $reader_column_type case 'text':value 原样入 config → line 4601-4613 直接拼接进 <td><span>template</span></td> raw HTML → 管理员打开"登录日志"即执行 |
| 利用 |
攻击者用恶意 User-Agent(如 <script>fetch('/system?action=...')</script>)发起登录请求 → 写入库 → 管理员查看日志页触发 → 窃取管理员 session / 以管理员身份操作 |
| 验证 |
三条链路均亲自核对源码确认 |
7.2 🔴【严重】存储型 XSS — 操作日志内 raw innerHTML 渲染
| 项 |
内容 |
| 写入端 |
class/public.py:454-491 WriteLog:args[i](站点名、域名、描述等用户可控参数)经 line 478 logMsg.replace 直接拼入,无转义清洗即插入 logs 表 |
| 回显端 |
class/logsModel/panelModel.py:46-83 get_logs_bytype:对 type in ('项目管理','网站管理') 的日志 select() 原样返回 log 字段,无转义 |
| 渲染端 |
BTPanel/static/js/panel-view-ops.js:123-124:render 函数 innerHTML:gt(new DOMParser().parseFromString(e.log,'text/html').body.innerHTML) —— 操作日志内容经 innerHTML 直接渲染(gt 仅全角→半角转换,不转义) |
| 利用 |
低权限子账号/管理员创建含 <script>/<img onerror> 的站点名、域名或描述 → 写操作日志 → 管理员打开"网站管理"操作日志页 → JS 在管理员上下文执行 → 窃取会话/以管理员调用命令注入 → root RCE |
| 验证 |
写入端(WriteLog 无清洗)、回显端(get_logs_bytype 无转义)、渲染端(innerHTML sink)三处均亲自核对源码确认 |
7.3 🟠【高危】系统级 XSS 渲染缺陷 — bt_tools.table type:'text' raw HTML
utils.js 的表格组件对 type:'text' 列不做 HTML 转义(仅 type:'html' 才显式渲染)。任何后端返回的未转义字段(站点名、FTP 文件名、日志内容、备注、域名)在 type:'text' 列中均可执行 HTML/JS。这是一个系统级放大器,令所有后端未转义回显点都升级为存储型/反射型 XSS。
7.4 🟡【中危】FTP 操作日志文件名回显
class/ftplog.py:391-493 get_action_log 将 FTP 日志行中的 file 字段(攻击者可控的 FTP 文件名)原样返回;前端 templates/default/ftp.html:255-272 用 type:'text' 渲染 → 存储型 XSS。
7.5 【低危】xsssec 过滤分析
public.xsssec/xsssec3(class/public.py:2111/2121)将 < > " ' 替换为全角字符,对 HTML 正文场景可阻断标签注入;但 & 未处理,且错误页 panel_error.html 为 .format() 模板——在属性/URL 上下文中仍存在绕过空间,属低危残留。
八、硬编码凭据
8.1 🟢【低危】login_token 默认值 '1234567890'
| 项 |
内容 |
| 文件 |
class/public.py:5623 get_login_token_auth() |
| 根因 |
data/login_token.pl 文件缺失时返回硬编码 '1234567890';该值经 common.py:251-255 在已登录 session 上做二次校验 |
| 风险 |
需要先有有效 session 才能触发该校验,单独不构成认证绕过;但属于"依赖文件存在才安全"的脆弱默认值。若文件被误删/权限异常,所有会话用同一个已知 token |
8.2 🟢【低危】API token 默认兜底
class/panelApi.py set_token:token_crypt 缺失时重新生成随机 token,默认 config/api.json 中 token 为空串时理论上存在空 token 风险,但代码路径会在开启 API 时强制生成随机 token,实际被 t_type=='2' 分支兜底 → 无法真实空态利用。
九、其他
9.1 🟡【中危】zip-slip — tar 解压路径穿越
| 项 |
内容 |
| 文件 |
class/files.py:3374+ __untargz |
| 根因 |
filename = item.name(tar 内原始文件名),unzip_path = os.path.join(get.dfile, filename);Python <3.12 的 tarfile.extract 对 ../../ 不做防护 → 恶意 tar 包可写到解压目录之外 |
| 缓解 |
Python 3.12+ tarfile 默认 filter 防护;宝塔面板当前 Python 版本需现场确认 |
9.2 🟡【中危】panelTask 任务队列二次注入
class/panelTask.py:189(wget)/:212(cp)/_zip(442-463)/_unzip(583-622):任务队列中的 task_shell/other 以单引号包裹但未转义,存储值中若含单引号可闭合注入。需任务写入前置(通常本身需要面板权限)。
9.3 🟡【中危】panelPlugin 插件安装命令注入
class/panelPlugin.py:442(input_package→bash {})、:3906-3916(input_zip cp -a -r {tmp_path}/*)、:2957(install version 入库)、:3017-3019(uninstall)等多处插件安装参数直拼 shell。需已登录(插件安装本身是面板功能)。
9.4 🟢【低危】crontab GetShell 设计性风险
class/crontab.py:1518-1599:计划任务 shell 本身就是"root 执行用户脚本"的设计功能——任何可建"shell 类任务"的账号即可 root RCE。这与 2.1-#8 的 second 参数注入(独立绕过)不同,核心风险是子账号是否有建 shell 任务的权限被 5.1/5.2 绕过。
十、攻击链组合(严重度最高)
以下链使"未认证 → root RCE"成为可能,是本次审计最重要的结论。
链 A:子账号 → root RCE(完全利用 5.1+2.1)
低权限子账号登录
→ 5.1 Referer 绕过(无 Referer 直连)跳过菜单鉴权
→ 2.1-#1 ServiceAdmin(name 注入) / 或 files.ExecShell
→ root 命令执行
链 B:子账号 → 幽灵 uid → 完整面板
低权限子账号登录
→ 5.2 set_temp_login(无 uid 校验)生成临时 token
→ userlogin.request_temp 设置 session['uid']=101(幽灵 uid)
→ 权限表查询为空 → 跳过所有菜单鉴权 → 完整 root 面板(含终端、文件、数据库)
链 C:未认证 → 存储型 XSS → 管理员会话
任意访客发送恶意 User-Agent 的登录请求
→ 7.1 写入 client_info(未认证触发)
→ 管理员打开登录日志页
→ 7.1+7.2 raw HTML 渲染 → 执行 JS
→ 以管理员 session 调用 2.1 任一命令注入 → root RCE
链 D:认证(任意子账号) → 任意文件读取
5.1/5.2 获得面板访问
→ 6.1 /download(filename=../../etc/shadow) 或 6.3 download_file(rname=../../etc/shadow)
→ 读取 /etc/shadow、面板数据、SSL 私钥
十一、修复建议(按优先级)
| 优先级 |
措施 |
| P0 |
1) public.ExecShell 全部改为参数化/禁用 shell 或增加全局 shell 元字符白名单校验助手;2) ServiceAdmin 的 name 用服务名白名单、type 用 start/stop/restart/reload 白名单;3) upgrade_panel version 用 \d+\.\d+\.\d+ 校验 |
| P0 |
4) 修 5.2:所有 temp_login 相关接口校验 session['uid']==1(管理员专属);5) 修 5.1:user_Authority 判断不再依赖 Referer 有无 |
| P0 |
6) ORM field/table/order/limit 参数一律白名单校验;修 3.1/3.2/3.3 三处 SQL 直拼 |
| P0 |
7) /download、download_file、/down 增加 path_safe_check + 面板路径白名单 |
| P1 |
8) 修复 2.1-#5 FTP、-#6 GetSSLInfo、-#7 unzip 密码、-#8 crontab 注入 |
| P1 |
9) 修复 4.1 SSRF(限制 scheme 与内网 IP);10) 7.1 入库前清洗 UA、7.2 前端 text 列默认转义 |
| P1 |
11) 控制器层统一增加资源归属校验(站点/库/FTP/文件按 uid 过滤) |
| P2 |
12) 移除 login_token 硬编码默认值;13) mail_sys 接口恢复认证或加签名;14) get_module_template module_name 白名单;15) tar/zip 解压走安全过滤 |
附录:已排除/降级项
| 项 |
结论 |
| PluginLoader.so |
AES 插件加载器,无 pickle → 非反序列化 RCE |
| cachelib pickle / task.py pickle |
需文件写+键名/完整性前置,独立不可利用 |
| render_template_string(SSTI) |
6 处调用点均为静态插件模板 + \w+ 校验 + 空/插件自有数据上下文,非用户可控模板串 |
/public get_ping(panelPing.Test) |
类不存在,死代码返回 404,不可达 |
| xsssec 全角替换 |
HTML 正文可阻断,属性上下文残留低危 |
| API token 空值 |
set_token 开启时强制生成随机 token,无法空态伪造 |
| /code 路由 |
仅校验 session 键存在,非值;单独不可利用 |