关键词:BT-0x、AES-CBC、div.pl、密钥推导、SQLite、panel.db、database.db、口令还原
难度:高级
前置知识:SQLite 基础、AES 分组加密模式、Base64 编码、Python 密码学库使用
一、概述
从宝塔 7.7 之后的新版架构开始,面板不再明文保存数据库口令。
原本 sqlite3 打开 default.db 就能读到的明文密码,现在变成了形如 BT-0x:kR7v…… 的密文字符串。
这给取证带来一个直接障碍:拿到面板数据库,不等于拿到数据库口令。
但这个障碍是可以跨过去的,而且跨过去的收益极高:
拿到面板 database.db
↓ 解密 databases 表
拿到业务数据库的账号口令
↓ 横向验证
拖库 / 数据泄露的证据链闭合
为什么值得做:服务器案件的核心诉求往往不是"服务器被黑了",而是"数据有没有被拿走"。而数据被拿走的证明,最终要落到数据库访问记录上。没有明文口令,你连验证都做不了——只能写"疑似拖库",写不了"确认拖库"。
先划清本篇的性质:这里给出的是原理说明 + 一个可运行的 Python 脚本,用于在已获授权的取证场景中还原面板自己保存的敏感字段。
这与"破解加密"是两回事——没有暴力破解,密钥材料就躺在同一台机器的另一个文件里(div.pl)。绕开的是一层封装,不是去算一个未知密钥。
密钥若缺失或派生不出明文,就回到口令本身,那属于另一个课题(见 通用密码恢复)。
涉及的证据形态:
| 形态 |
具体位置 |
判读要点 |
| 加密字段 |
各库中以 BT-0x: 开头的值,后跟标准 Base64 密文 |
前缀极其明确,检索成本极低 |
| 密钥材料 |
/www/server/panel/data/div.pl |
本身也是加密的,无 BT-0x: 前缀,需另一对内置参数先解 |
| 内置参数 |
面板程序内置的固定字符串 |
取偶数位字符得到 16 字节;这是版本相关的部分 |
| 架构判据 |
data/db/ 目录存在 + div.pl 存在 |
二者同时存在 = 新版架构 |
| 数据库 |
data/db/database.db、data/db/panel.db、data/db/default.db |
只读打开(file:...?mode=ro),避免运行时写入或锁 |
| 密文范围 |
各表的敏感列 |
不要假设字段名,先 PRAGMA table_info() 查 |
为什么 sqlite3 打开会看到"乱码":这是本篇最常见的第一个困惑。
值不是乱码——它是被 AES-CBC 加密后 Base64 编码的密文,只是长得不像明文。看到 BT-0x: 前缀就是确认信号,没有前缀的纯随机串则要另行判断(可能是其他编码,也可能根本没加密)。
验证是本篇最关键、也最反直觉的一环:
实测发现,用错误的 IV 解密时,PKCS#7 填充校验仍可能通过(末字节恰好落在合法区间 1–16)。这意味着任何"输出看起来像密码""填充是否合法"的启发式判断都不可靠。换句话说,加密机制的每一步都会产生看起来合理的输出,这正是本篇所有陷阱的共同根源。
因此解出的字符串必须经过独立验证才能作为结论。
优先用离线方法验证(与站点代码里的配置比对);确需动态验证时,只能在已授权检材本机范围内进行,不得对任何第三方系统使用检材中的凭据。
能回答什么:面板管理了哪些业务数据库、各自的账号与口令、面板口令与站点代码中的配置是否一致(不一致本身就是"有人改过数据库配置"的反证)、以及由此展开的拖库证据链。
不能回答什么:数据库里存了什么业务数据(本篇只解出口令,不接触数据内容);拖库行为是否发生(要靠 binlog / 慢查询日志 / 通用日志,那是 MySQL 侧的课题);脚本在你的样本上解不出合理明文时,说明该小版本实现有差异——不要强行套用,更不要伪造一个"看起来像密码"的输出。拿不到比假结果好。
涉及的口令怎么处置:解密出的业务口令按密级材料管理,报告中一律脱敏,不做在线使用、不二次扩散。委托方需要轮换的,在报告中作为建议列出。
读者前提:需要 宝塔面板取证 的架构判定与库表结构、SQLite 基础与结构 的只读查询,以及 AES-CBC 与 PKCS#7 填充的基本概念(IV 依赖性是理解本机制的关键)。务必先按 3.1 第 1 步 确认架构与密文位置再动手。
二、核心原理
2.1 识别:BT-0x: 前缀
新版加密字段的识别特征极其明确——值以 BT-0x: 开头,后跟一段标准 Base64:
BT-0x:tXeUcwJk+Lu5t+BTgGgLAIJbFXYxU84sZA8ak1yjvec=
└──┬─┘ └────────────┬───────────────┘
│ └── Base64 密文(AES-CBC 密文)
└── 固定前缀,标识"这是宝塔加密字段"
检索方法:
sqlite3 "file:/evidence/bt-data/db/database.db?mode=ro"
"SELECT name, username, password FROM databases;"
shop_db shop BT-0x:tXeUcwJk+Lu5t+BTgGgLAIJbFXYxU84sZA8ak1yjvec= ← 加密
| 观察 |
含义 |
值以 BT-0x: 开头 |
新版加密字段,可走本篇流程 |
| 值是纯随机串但无前缀 |
可能是其他编码(Base64 / 自定义),需另行判断 |
| 值像明文 |
旧版未加密,或该字段不在加密范围 |
2.2 加密流程的原理
理解原理比拿到脚本更重要,因为版本差异会改变细节,而原理告诉你哪些部分可能变。
【加密侧(面板运行时)】
明文口令(如 MySQL root 口令)
│
├─ ① 固定 KEY:程序内置的 32 字符串,取【偶数位】字符 → 16 字节
│
└─ ② IV:优先读 /www/server/panel/data/div.pl 的内容
该文件本身也是加密的 → 需先解密
解密 div.pl 用另一对固定 KEY/IV(程序内置)
↓
得到的明文 → 取其 MD5 的前 16 字节 作为实际 IV
│
↓
③ AES-CBC 加密(PKCS#7 填充)→ Base64 编码 → 加 "BT-0x:" 前缀
为什么"取偶数位"这个设计很反直觉但很重要:分析公开结果显示,程序内置一个 32 字符的密钥串,只取索引为偶数的那些字符(0, 2, 4, …, 30)拼成 16 字节的 AES-128 密钥。
不同版本里这个内置串本身可能不同——这正是"必须以实际样本验证"的根本原因。
IV 的两级结构是这套机制最关键的设计,也是最容易出错的地方:
div.pl 的密文
↓ 用【内置的第二对固定 KEY/IV】解密(第一层,参数硬编码)
得到一段明文(16 字符的随机串)
↓ 取其 MD5 的前 16 字节
这才是解密 BT-0x: 密文时真正用的 IV
也就是说,密钥是硬编码的,IV 却是每台机器不同的(存在 div.pl 里)。
这解释了为什么同一套解密脚本在不同服务器上会得到不同结果——因为 IV 不同。
2.3 架构判据:div.pl 的存在
div.pl 是新版架构引入的文件,作用是给面板提供一个与机器绑定的 IV 材料。它的存在本身就是最可靠的版本判据:
| 文件状态 |
架构判断 |
有 data/db/ 且有 div.pl |
新版(7.7+),敏感字段加密 |
只有 data/default.db,无 div.pl |
旧版,敏感字段多为明文/可逆编码 |
div.pl 的内容就是那段需要二次解密的 Base64 密文(注意它没有 BT-0x: 前缀)。
2.4 旧版 vs 新版的路径与字段对照
| 项目 |
旧版 |
新版 |
| 主库 |
data/default.db |
data/db/default.db(部分表) |
| 数据库明细 |
在 default.db 的 databases 表 |
data/db/database.db |
| 面板配置(MySQL root 口令等) |
在 default.db 的 config 表 |
data/db/panel.db |
| IV 材料 |
无 |
data/div.pl |
| 口令字段形态 |
多数为明文或简单编码 |
BT-0x: + AES-CBC + Base64 |
MySQL root 口令在哪张表:旧版在 default.db 的 config 表,新版在 panel.db 的 config 表,字段名常见为 mysql_root(具体字段名以 PRAGMA table_info 的实际输出为准)。
2.5 实际看到的现象:为什么 sqlite3 打开是"乱码"
sqlite3> SELECT mysql_root FROM config;
-- BT-0x:t2UpDw7vR5VjXlan0WCaDx150cBgfLlVPxRMe7Vbb1o=
这不是数据库损坏,也不是编码错误。
SQLite 本身不加密,它是忠实存储字符串的存储引擎;密文是宝塔的应用层在写入前主动加密的结果。所以数据库结构完全正常、其他表都能正常读,只有敏感字段的值是密文。
取证的关键是还原应用层的加密,而不是"修复"数据库。
2.6 取证价值
| 价值 |
说明 |
| 闭合拖库证据链 |
拿到口令 → 查 binlog / 慢查询 / 通用日志 → 证明数据被批量读取 |
| 横向扩线 |
数据库口令常与其他系统复用;MySQL root 口令可用于同机其他库 |
| 判断泄露范围 |
知道有哪些数据库、账号,才能界定"可能被拿走了什么" |
| 反证 |
若解密出的口令与站点代码中配置的不一致,说明有人改过数据库口令 |
反证这一条常被忽略但很有价值:业务代码里的连接口令与面板里存的不一致,这个差异本身就是"有人动过数据库配置"的证据(详见第五节案例)。
若 BT-0x: 密文缺了密钥材料、派生不出明文,就得回到口令本身。字典与掩码攻击的合规边界、工具选择与脱敏要求见《Passware Kit 与通用密码恢复》。
三、操作步骤
3.1 第 1 步:确认架构与定位密文
判定架构:有 data/db/ + div.pl = 新版
ls -l /mnt/df/www/server/panel/data/db/ 2>/dev/null
ls -l /mnt/df/www/server/panel/data/div.pl 2>/dev/null
读出 div.pl 的内容(这是二级解密的输入)
cat /mnt/df/www/server/panel/data/div.pl
3UOiALw6JWPa2F02dtN3+ynzUs9oXWcT5llyhivOBaI=
↑ 无 BT-0x: 前缀,Base64 密文,需先用内置参数解密
在各库中检索 BT-0x: 字段,确认哪些字段被加密
sqlite3 "file:/evidence/bt-data/db/database.db?mode=ro"
"SELECT name, username, password FROM databases;"
sqlite3 "file:/evidence/bt-data/db/panel.db?mode=ro" "PRAGMA table_info(config);"
sqlite3 "file:/evidence/bt-data/db/panel.db?mode=ro" "SELECT * FROM config;"
务必只读打开(file:...?mode=ro),避免在面板运行时产生写入或锁。
3.2 第 2 步:确认字段范围(不要假设字段名)
列出每张表的所有列名
sqlite3 "file:/evidence/bt-data/db/database.db?mode=ro" ".schema"
sqlite3 "file:/evidence/bt-data/db/panel.db?mode=ro" ".schema"
快速找出所有以 BT-0x: 开头的值
sqlite3 "file:/evidence/bt-data/db/database.db?mode=ro"
"SELECT name, username, password FROM databases WHERE password LIKE 'BT-0x:%';"
3.3 第 3 步:运行解密脚本
环境准备(Ubuntu/Debian 分析机):
sudo apt install python3 python3-pip sqlite3
pip3 install pycryptodome # 提供 Crypto.Cipher.AES
把下面脚本保存为 bt_decrypt.py。脚本中的 KEY 材料来自公开分析,必须以实际样本验证:
#!/usr/bin/env python3
-- coding: utf-8 --
"""
宝塔新版敏感字段(BT-0x:)解密工具 —— 取证用途
原理:见配套文章「宝塔新版加密数据解密」§二
⚠️ 重要:内置 KEY/IV 材料来自对已公开分析结果的原理复现。
不同小版本实现可能不同,必须以实际样本验证。
若解不出合理明文,说明该版本有差异,请回到分析阶段重新确认,
不要强行套用或采信任何"看起来像"的输出。
"""
import base64
import hashlib
from Crypto.Cipher import AES
PREFIX = "BT-0x:"
---- 内置材料:这些值随版本可能不同,需以实际样本验证 ----
业务字段解密 KEY 的来源:程序内置一个 32 字符串,取其【偶数位】拼成 16 字节
DB_KEYSTORE = "3gP7+k_7lSNg3$+Fj!PKW+6$KYgHtw#R" # 偶数位 → "3P+_lN3+jPW6Kgt#"
div.pl 二次解密用的固定 KEY / IV(16 字节,各版本可能不同)
DIV_KEY = b"Z2B87NEAS2BkxTrh"
DIV_IV = b"WwadH66EGWpeeTT6"
def sgin_key(keystore: str) -> bytes:
"""按公开分析:取 keystr 的偶数位字符作为 AES-128 密钥(32 字符 → 16 字节)。"""
if len(keystore) < 32:
raise ValueError("keystore 长度不足 32,无法取偶数位")
return "".join(keystore[i] for i in range(0, 32, 2)).encode()
def pkcs7_unpad(data: bytes) -> bytes:
if not data:
return data
return data[:-data[-1]] # 末字节即填充长度
def aes_cbc_decrypt_b64(cipher_b64: str, key: bytes, iv: bytes) -> str:
raw = base64.b64decode(cipher_b64.strip())
return pkcs7_unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(raw)).decode("utf-8", "replace")
def get_iv_from_div(div_text: str):
"""div.pl 内容 → 二次解密 → 取 MD5 前 16 字节作为实际 IV。
返回 (iv, 二次解出的明文),后者仅供人工核对。"""
inner_plain = aes_cbc_decrypt_b64(div_text, DIV_KEY, DIV_IV)
return hashlib.md5(inner_plain.encode("utf-8")).digest()[:16], inner_plain
def bt_decrypt(cipher_field: str, iv: bytes, keystr: str) -> str:
if not cipher_field.startswith(PREFIX):
return cipher_field # 未加密,原样返回
return aes_cbc_decrypt_b64(cipher_field[len(PREFIX):], sgin_key(keystr), iv)
if name == "main":
import argparse
ap = argparse.ArgumentParser(description="宝塔 BT-0x: 字段解密(取证用途)")
ap.add_argument("--div", required=True, help="div.pl 文件路径(提供 IV 材料)")
ap.add_argument("--keystr", default=DB_KEYSTORE, help="内置 keystr(默认取本版公开值)")
ap.add_argument("value", help="待解密的 BT-0x: 字段值(不含前缀也可)")
args = ap.parse_args()
div_text = open(args.div, encoding="utf-8").read().strip()
iv, inner = get_iv_from_div(div_text)
field = args.value if args.value.startswith(PREFIX) else PREFIX + args.value
print(bt_decrypt(field, iv, args.keystr))
运行方式:
1) 先单独验证 IV 提取(IV 错则后续全错,这是唯一的结构性检查)
python3 -c "
import bt_decrypt as b
iv, inner = b.get_iv_from_div(open('/evidence/div.pl').read().strip())
print('二次解密明文 =', repr(inner)) # 应为 16 字符的随机串(IV 材料),不是乱码
print('IV =', iv.hex()) # 应为 32 位十六进制
"
注意:即使 IV 步骤输出"看起来合理",也不能据此认为解密成功。实测中,用错误的 IV 解密时,PKCS#7 填充校验仍可能通过(末字节恰好落在 1–16 区间)。填充校验不是可靠的成功判据——唯一可靠的验证是 3.5 节的动态登录。
3.4 第 4 步:批量解出全部敏感字段
mkdir -p /evidence/scripts
cat > /evidence/scripts/bt_batch.py <<'EOF'
import sqlite3, sys
sys.path.insert(0, '/evidence')
import bt_decrypt as b
iv = b.get_iv_from_div(open('/evidence/bt-data/div.pl', encoding='utf-8').read().strip())[0]
con = sqlite3.connect("file:/evidence/bt-data/db/database.db?mode=ro", uri=True)
print("=== databases 表 ===")
for name, user, pwd in con.execute("SELECT