文章总结: 文章记录改进HexStrikeJS密钥扫描器时遇到的三个核心问题:扫描器遗漏动态加载的JS文件、混淆字符串导致误报、运行时生成密钥无法静态捕获。解决方案包括三级chunk发现机制、严格噪声过滤算法和三层架构推理方法,将动态密钥转化为可执行攻击路径。改进后支持多框架和主流平台密钥格式,实现从漏扫误报到精准指引的升级。 综合评分: 85 文章分类: 渗透测试,WEB安全,安全工具,漏洞分析,安全建设
改造hexstrike-ai,读懂”看不见的密钥”
原创
Andy Andy
实在安全
2026年4月15日 09:15 中国香港
在小说阅读器读本章
去阅读
JS密钥扫描踩坑实录:明明有API Key,扫描器却搜不到?
渗透测试中,有个特别令人头疼的场景:
你明明知道目标系统用了某个API Key,Burp里也抓到了带 Authorization: Bearer sk-oa-internal-9c2b4f7a 的请求,但打开JS源码,搜遍所有文件,却什么都找不到。
工具报告零发现,可密钥确实存在。
这篇文章,就记录我们在改进 HexStrike JS Secret Scanner 过程中,遇到的3个核心难题,以及如何系统性解决它们。
问题一:扫描器只扫到1个JS,实际有8个
现代SPA的JS分发方式,藏着“漏网之鱼”
现在的前端应用(React/Vue/Vite构建),不会把所有代码打包成一个文件。以目标站点为例,HTML里只引用了一个入口:
<script src="/assets/index-Bc3k3igX.js"></script>
但这个index.js内部,通过Vite的懒加载机制,悄悄注册了7个额外的chunk:
// __vite__mapDeps 注册的chunk列表
m.f = ["assets/vYocJttA.js", "assets/BiH54Xkx.js",
"assets/CHO-by0t.js", "assets/BunUXZxJ.js", "assets/DlHwJb_7.js"]
// import() 动态引用(mapDeps里没有!)
import("./BrtFTLIW.js")
import("./gGTZk6IE.js")
其中 BrtFTLIW.js 和 gGTZk6IE.js,只在import()调用里出现,且路径是相对的裸文件名——老版本扫描器,直接把这两个漏了。
解决方案:三级chunk发现,一个都跑不掉
我们设计了3级匹配规则,递归扫描每个已下载的JS文件,直到队列为空,成功从1个文件扩展到8个文件:
- Pattern 1: __vite__mapDeps 数组(最完整)
m\.f\s*=\s*(\[[^\]]{0,2000}\]) - Pattern 2: dynamic import()(含相对路径和裸文件名)
import\s*\(\s*["']\.{0,2}/?([A-Za-z0-9_\-]{3,35}\.js)["'] - Pattern 2b: 任意位置的裸文件名字符串(兜底)
["']([A-Za-z0-9_\-]{4,20}\.js)["'] - Pattern 3: 框架路径(适配Next.js / Nuxt.js)
/_next/static/chunks/*.js、/_nuxt/*.js
问题二:字符串混淆后,拼接出的全是噪声
javascript-obfuscator 混淆手法,藏起真实字符串
目标站点的JS用了javascript-obfuscator工具混淆,核心手法是「字符串数组替换」,把所有字符串字面量提取到一个大数组里,访问时通过旋转函数间接引用。
原始代码:
sessionStorage.setItem("oa_token", response.data.token)
混淆后代码:
// 所有字符串字面量被提取到一个大数组里
function t() {
const n = ["ms4XCMvT", "5Rov5yQH6Ag+", "qg9HlxbSyxq", "B2XHtMi", ...]
}
// 访问时通过旋转函数间接引用
n(0, 0, 452) // 取第 452-offset 个元素
更麻烦的是,数组里的每个字符串,还用了一个自定义base64变体(Jzzwqn编码)加密,解码算法如下:
ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="
def jzzwqn_decode(s):
idx = [ALPHA.index(c) for c in s if c in ALPHA]
bs = bytearray()
for i in range(0, len(idx)-3, 4):
a, b, c, d = idx[i], idx[i+1], idx[i+2], idx[i+3]
bs.append((a << 2) | (b >> 4))
if c != 64: bs.append(((b & 15) << 4) | (c >> 2))
if d != 64: bs.append(((c & 3) << 6) | d)
# UTF-8/CJK 处理
return urllib.parse.unquote("".join("%%%02X" % x for x in bs))
解码后我们发现,DlHwJb_7.js的247个数组条目里,第82个能解码出 @oa-pl(OA platform前缀)。
陷阱:拼接字符串时,噪声导致假阳性
扫描器尝试用滑动窗口拼接相邻解码字符串,寻找密钥模式,但混淆时会填充大量噪声,导致误报:
[82] '@oa-pl'
[83] 'ola' ← 混淆填充噪声
[84] 'eUI' ← 混淆填充噪声
[85] 'lamati'
[86] 'messag'
旧版噪声过滤,只排除“大小写混合的3字母”,而 ola(全小写)逃过过滤,导致拼接结果 @oa-plolaeUI 被误报为CRITICAL密钥。
修复:严格噪声过滤 + any()短路,彻底杜绝假阳性
我们优化了噪声过滤规则,同时加入any()短路判断——窗口中任意一个片段是噪声,整个窗口直接跳过:
def _noise(d):
d = d.strip()
# 任意 ≤3 纯字母字符串均为混淆填充噪声(无论大小写)
if len(d) <= 3 and re.match(r'^[A-Za-z]{1,3}$', d): return True
if len(d) <= 2: return True
if len(d) == 4 and re.match(r'^[A-Z]{4}$', d): return True
return False
# 关键:窗口中任意一个片段是噪声,整个窗口跳过
# 旧版用 sum() > len()//2,允许少量噪声混入
if any(_noise(p) for p in pieces):
continue
修复后,@oa-pl 后面紧跟噪声 ola,整个窗口被跳过,假阳性彻底消失。
问题三:最棘手的情况——密钥根本不在JS里
穷举所有JS文件、所有数组条目、所有滑动窗口,sk-oa-internal-9c2b4f7a 还是找不到——原因很简单:它是服务端在登录时动态生成并返回的。
index.js里的明文代码,其实早就告诉我们答案了:
const t = await Qh.post("/auth/login", {username: i, password: u})
sessionStorage.setItem("oa_token", t.data.token) // token值在这行之前,不存在于任何地方
转变思路:从“找值”到“找流程”
静态扫描的边界很明确:只能看到代码,看不到运行时数据。与其硬找一个不存在于代码里的值,不如重建密钥的完整生命周期。
我们在 _jss_infer_auth_architecture() 里,实现了三层推理:
- Layer 1: 扫描明文JS模式 ✅ sessionStorage.setItem(“oa_token”, …) → 找到存储key名称 ✅ Authorization:
Bearer ${t}→ 找到注入方式 ✅ axios.create({baseURL: “/api”}) → 找到API根路径 ✅ .post(“/auth/login”, …) → 找到获取端点 - Layer 2: 扫描混淆字符串解码片段 ✅ ‘getIte’ → getItem调用(读取token) ✅ ‘setIte’ → setItem调用(写入token) ✅ ‘removeIt’ → removeItem调用(登出时删除) ✅ ‘@oa-pl’ → OA platform前缀(推断key命名规律)
- Layer 3: 综合推理,输出可执行攻击路径 → “Token格式推断: sk-oa-
– ” → “ACTION: POST /api/auth/login → capture response.data.token”
最终输出的报告,直接给出明确指引:
[CRITICAL] TokenArchitecture: Inferred API Key Format
DECODED : Suspected format: sk-oa-<service>-<hex8>
Context : Evidence:
(1) Browser storage key 'oa_token' holds the auth token.
(2) Token injected as Bearer on all API requests.
(3) Login endpoint: /auth/login → response.data.token.
ACTION : POST /api/auth/login with valid creds to capture the live token.
通用化改造:让扫描器适配所有站点
如果只针对这一个站点优化,意义有限。我们做了两点通用化改造,让扫描器适配更多场景:
1. 补全主流平台key模式
覆盖常见云服务和SaaS平台的密钥格式,避免遗漏:
(r'AKIA[A-Z0-9]{16}', "AWS Access Key ID", "CRITICAL"),
(r'gh[pso]_[A-Za-z0-9]{36}', "GitHub Token", "CRITICAL"),
(r'AIza[0-9A-Za-z\-_]{35}', "Google API Key", "CRITICAL"),
(r'sk_live_[0-9a-zA-Z]{24,}', "Stripe Secret Key", "CRITICAL"),
(r'xox[bpoa]r?\-[A-Za-z0-9\-]{10,80}', "Slack Token", "CRITICAL"),
(r'SG\.[A-Za-z0-9_\-]{22}\.[A-Za-z0-9_\-]{43}', "SendGrid Key","CRITICAL"),
(r'eyJ[A-Za-z0-9+/=]{10,}\.[A-Za-z0-9+/=]{10,}\.[A-Za-z0-9+/=_\-]{10,}',
"JWT Token", "CRITICAL"),
(r'-----BEGIN\s+(?:RSA|EC|OPENSSH)?\s*PRIVATE KEY-----',
"PEM Private Key", "CRITICAL"),
2. 架构推理与平台解耦
Layer 3推理不再硬编码 oa_token 和 @oa-pl,而是从Layer 1/2的动态发现结果中提取,适配所有“Bearer token + 浏览器存储”模式的SPA:
# 通用:从已发现的登录端点和token字段,组装操作指引
login_ep_str = login_eps[0] if login_eps else "(unknown)"
token_fld_str = token_fields[0] if token_fields else "token"
storage_str = storage_keys[0] if storage_keys else "(unknown)"
"Obtain via: POST %s → extract response.data.%s" % (login_ep_str, token_fld_str)
最终效果对比:从“漏扫误报”到“精准指引”
| 对比项 | 改进前 | 改进后 | | — | — | — | | 扫描JS文件数 | 1 | 8(全部chunk) | | 假阳性 | 4个(oa-plolaeUI等) | 0 | | 能发现的key类型 | sk-、硬编码 | + AWS/GitHub/Google/Stripe/Slack/JWT/PEM | | 动态key处理 | 跳过 | CRITICAL级架构推理报告 | | 框架支持 | Vite | + Next.js / Nuxt.js / webpack |
结语
静态扫描器的能力边界,不在于“能不能读懂混淆代码”,而在于“运行时数据本质上不可见”。
正确的做法不是绕过这个边界,而是在边界处做最有价值的事情:把“这里有一个密钥,但它在运行时生成”这一事实,转化为可执行的攻击路径。
这正是渗透测试中“信息收集”和“漏洞利用”的分界线:扫描器告诉你去哪里、发什么请求、看哪个字段——剩下的,就是动手的事了。
本文所有测试均在授权环境中进行。HexStrike 工具仅用于授权安全测试。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:实在安全 Andy Andy《改造hexstrike-ai,读懂”看不见的密钥”》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。











评论