改造hexstrike-ai,读懂”看不见的密钥”

admin 2026-04-29 05:20:44 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文章记录改进HexStrikeJS密钥扫描器时遇到的三个核心问题:扫描器遗漏动态加载的JS文件、混淆字符串导致误报、运行时生成密钥无法静态捕获。解决方案包括三级chunk发现机制、严格噪声过滤算法和三层架构推理方法,将动态密钥转化为可执行攻击路径。改进后支持多框架和主流平台密钥格式,实现从漏扫误报到精准指引的升级。 综合评分: 85 文章分类: 渗透测试,WEB安全,安全工具,漏洞分析,安全建设


cover_image

改造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",
&nbsp; &nbsp; &nbsp; &nbsp;"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个文件:

  1. Pattern 1: __vite__mapDeps 数组(最完整)m\.f\s*=\s*(\[[^\]]{0,2000}\])
  2. Pattern 2: dynamic import()(含相对路径和裸文件名)import\s*\(\s*["']\.{0,2}/?([A-Za-z0-9_\-]{3,35}\.js)["']
  3. Pattern 2b: 任意位置的裸文件名字符串(兜底)["']([A-Za-z0-9_\-]{4,20}\.js)["']
  4. 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() {
&nbsp; const n = ["ms4XCMvT", "5Rov5yQH6Ag+", "qg9HlxbSyxq", "B2XHtMi", ...]
}
// 访问时通过旋转函数间接引用
n(0, 0, 452) &nbsp;// 取第 452-offset 个元素

更麻烦的是,数组里的每个字符串,还用了一个自定义base64变体(Jzzwqn编码)加密,解码算法如下:

ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/="

def jzzwqn_decode(s):
&nbsp; &nbsp; idx = [ALPHA.index(c) for c in s if c in ALPHA]
&nbsp; &nbsp; bs = bytearray()
&nbsp; &nbsp; for i in range(0, len(idx)-3, 4):
&nbsp; &nbsp; &nbsp; &nbsp; a, b, c, d = idx[i], idx[i+1], idx[i+2], idx[i+3]
&nbsp; &nbsp; &nbsp; &nbsp; bs.append((a << 2) | (b >> 4))
&nbsp; &nbsp; &nbsp; &nbsp; if c != 64: bs.append(((b & 15) << 4) | (c >> 2))
&nbsp; &nbsp; &nbsp; &nbsp; if d != 64: bs.append(((c & 3) << 6) | d)
&nbsp; &nbsp; # UTF-8/CJK 处理
&nbsp; &nbsp; 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' &nbsp; &nbsp; &nbsp; &nbsp;← 混淆填充噪声
[84] 'eUI' &nbsp; &nbsp; &nbsp; &nbsp;← 混淆填充噪声
[85] 'lamati'
[86] 'messag'

旧版噪声过滤,只排除“大小写混合的3字母”,而 ola(全小写)逃过过滤,导致拼接结果 @oa-plolaeUI 被误报为CRITICAL密钥。

修复:严格噪声过滤 + any()短路,彻底杜绝假阳性

我们优化了噪声过滤规则,同时加入any()短路判断——窗口中任意一个片段是噪声,整个窗口直接跳过:

def _noise(d):
&nbsp; &nbsp; d = d.strip()
&nbsp; &nbsp; # 任意 ≤3 纯字母字符串均为混淆填充噪声(无论大小写)
&nbsp; &nbsp; if len(d) <= 3 and re.match(r'^[A-Za-z]{1,3}$', d): return True
&nbsp; &nbsp; if len(d) <= 2: return True
&nbsp; &nbsp; if len(d) == 4 and re.match(r'^[A-Z]{4}$', d): return True
&nbsp; &nbsp; return False

# 关键:窗口中任意一个片段是噪声,整个窗口跳过
# 旧版用 sum() > len()//2,允许少量噪声混入
if any(_noise(p) for p in pieces):
&nbsp; &nbsp; 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) &nbsp;// token值在这行之前,不存在于任何地方

转变思路:从“找值”到“找流程”

静态扫描的边界很明确:只能看到代码,看不到运行时数据。与其硬找一个不存在于代码里的值,不如重建密钥的完整生命周期。

我们在 _jss_infer_auth_architecture() 里,实现了三层推理:

  1. Layer 1: 扫描明文JS模式          ✅ sessionStorage.setItem(“oa_token”, …) → 找到存储key名称          ✅ Authorization: Bearer ${t} → 找到注入方式          ✅ axios.create({baseURL: “/api”}) → 找到API根路径          ✅ .post(“/auth/login”, …) → 找到获取端点
  2. Layer 2: 扫描混淆字符串解码片段          ✅ ‘getIte’ → getItem调用(读取token)          ✅ ‘setIte’ → setItem调用(写入token)          ✅ ‘removeIt’ → removeItem调用(登出时删除)          ✅ ‘@oa-pl’ → OA platform前缀(推断key命名规律)
  3. Layer 3: 综合推理,输出可执行攻击路径          → “Token格式推断: sk-oa-”          → “ACTION: POST /api/auth/login → capture response.data.token”

最终输出的报告,直接给出明确指引:

[CRITICAL] TokenArchitecture: Inferred API Key Format
&nbsp; DECODED : Suspected format: sk-oa-<service>-<hex8>
&nbsp; Context : Evidence:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (1) Browser storage key 'oa_token' holds the auth token.
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (2) Token injected as Bearer on all API requests.
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (3) Login endpoint: /auth/login → response.data.token.
&nbsp; ACTION &nbsp;: POST /api/auth/login with valid creds to capture the live token.

通用化改造:让扫描器适配所有站点

如果只针对这一个站点优化,意义有限。我们做了两点通用化改造,让扫描器适配更多场景:

1. 补全主流平台key模式

覆盖常见云服务和SaaS平台的密钥格式,避免遗漏:

(r'AKIA[A-Z0-9]{16}', &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;"AWS Access Key ID", &nbsp; &nbsp;"CRITICAL"),
(r'gh[pso]_[A-Za-z0-9]{36}', &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;"GitHub Token", &nbsp; &nbsp; &nbsp; &nbsp; "CRITICAL"),
(r'AIza[0-9A-Za-z\-_]{35}', &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "Google API Key", &nbsp; &nbsp; &nbsp; "CRITICAL"),
(r'sk_live_[0-9a-zA-Z]{24,}', &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "Stripe Secret Key", &nbsp; &nbsp;"CRITICAL"),
(r'xox[bpoa]r?\-[A-Za-z0-9\-]{10,80}', "Slack Token", &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;"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,}',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "JWT Token", &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "CRITICAL"),
(r'-----BEGIN\s+(?:RSA|EC|OPENSSH)?\s*PRIVATE KEY-----',
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; "PEM Private Key", &nbsp; &nbsp; "CRITICAL"),

2. 架构推理与平台解耦

Layer 3推理不再硬编码 oa_token 和 @oa-pl,而是从Layer 1/2的动态发现结果中提取,适配所有“Bearer token + 浏览器存储”模式的SPA:

# 通用:从已发现的登录端点和token字段,组装操作指引
login_ep_str &nbsp;= login_eps[0] &nbsp; if login_eps &nbsp; else "(unknown)"
token_fld_str = token_fields[0] if token_fields else "token"
storage_str &nbsp; = 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,读懂”看不见的密钥”》

评论:0   参与:  0