1. 项目概述:为什么我们需要关注JavaScript文件端点?
在Web应用安全测试或前端逆向分析中,我们经常会遇到一个场景:面对一个功能复杂的单页应用(SPA)或一个传统的多页应用,如何快速、准确地找出所有与后端交互的API接口(即“端点”)?这些端点就像是应用与服务器对话的“电话号码”,掌握了它们,我们才能深入理解应用的数据流、业务逻辑,并对其进行有效的安全评估。
传统上,我们依赖浏览器开发者工具的“网络”(Network)面板手动点击、触发功能来捕获请求。这种方法效率低下且不完整,尤其是对于大量依赖用户交互才能触发的“惰性加载”接口,或者隐藏在深层条件逻辑里的接口,很容易遗漏。而现代Web应用,其前端逻辑几乎全部由JavaScript驱动,所有的API调用指令都“写”在了JS文件里。因此,从静态的JavaScript文件中系统性地提取端点信息,成为一种更主动、更全面的方法。
简单来说,JavaScript文件端点提取,就是从应用加载的所有.js文件中,通过静态分析或动态执行的方式,找出所有可能用于发起网络请求(如fetch、XMLHttpRequest、axios调用等)的URL字符串。这不仅是安全测试人员(白盒/黑盒)的必备技能,对于前端开发者进行依赖分析、架构梳理,甚至对于学习大型开源项目的API设计,都极具价值。
今天,我就结合自己多年的实战经验,分享两种经过验证的高效方法:一种是基于正则匹配的静态文本分析法,快速直接;另一种是更智能的抽象语法树(AST)解析法,能精准处理复杂的字符串拼接和变量引用。最后,我们还会深入探讨如何对这些提取到的端点进行初步的安全分析,识别潜在的风险点。
2. 核心思路与方案选型:正则匹配 vs. AST解析
面对一堆压缩过的、变量名混淆的JavaScript代码,我们该如何下手?核心思路是识别出“网络请求”的代码模式,并从中提取出作为参数的URL。根据对代码的理解深度和工具的使用,主要衍生出两种方案。
2.1 方案一:正则表达式匹配法
这是最直观、门槛最低的方法。其核心思想是:无论代码如何混淆,最终发起HTTP请求的调用语句是相对固定的,而URL通常以字符串字面量的形式出现在这些调用附近。我们通过编写一系列正则表达式,去匹配这些模式。
为什么选择它?
- 快速上手:无需深入理解JavaScript语法,只需要知道常见的请求库调用方式。
- 轻量高效:对于一次性或小规模的分析任务,编写几行脚本就能运行,依赖少。
- 处理压缩代码:压缩代码虽然去掉了空格和换行,但函数名和字符串字面量通常保留,正则依然可以匹配。
它的局限性是什么?
- 准确度有限:正则难以处理动态生成的URL。例如,
const url = '/api/' + endpoint + '/list';,正则很难完整提取出最终拼接好的URL。 - 误报与漏报:容易匹配到非API请求的字符串(如HTML片段、日志信息),也可能因为模式编写不全而漏掉一些不常见的请求库(如
umi-request、ky)。 - 无法理解上下文:正则不知道变量定义和传递,如果URL先被赋值给一个变量,然后在十行之后才被使用,正则很可能错过。
适用场景:快速侦察、对目标应用使用的技术栈比较明确、或只需提取简单静态URL的场景。
2.2 方案二:抽象语法树(AST)解析法
这是一种“降维打击”的方法。AST将源代码转化为一棵结构化的树,每个节点对应代码中的一种语法结构(如变量声明、函数调用、字符串字面量)。通过遍历这棵树,我们可以精准地定位函数调用节点,并分析其参数来源。
为什么选择它?
- 高精度:能够追踪变量,处理字符串拼接、模板字符串等动态URL生成逻辑。
- 强上下文感知:可以分析出某个URL在哪个函数中、在什么条件下被调用,有助于理解端点用途。
- 可扩展性:不仅可以提取URL,还可以提取请求方法(GET/POST)、请求头、载荷数据等关联信息。
它的挑战是什么?
- 复杂度高:需要理解AST的基本概念和所选解析器(如
@babel/parser、acorn)的API。 - 依赖混淆对抗能力弱:如果代码经过严重的变量名混淆(如
a.b.c()),虽然AST结构不变,但语义分析会变得困难,需要结合其他策略。 - 性能开销:解析大型JS文件或整个项目构建产物(如
app.[hash].js)时,会有一定的内存和CPU消耗。
适用场景:深度安全审计、架构分析、需要高精度和完整性的端点收集任务。
实操心得:在实际项目中,我通常采用“混合策略”。先用正则法快速扫描全站JS文件,建立一个初步的端点清单,评估目标规模和复杂度。如果发现大量拼接URL或需要深度分析,再对关键文件使用AST解析法进行重点突破。这样既能保证效率,又能确保关键信息不遗漏。
3. 方法一实战:基于正则匹配的快速提取
我们先从最简单实用的正则匹配法开始,手把手实现一个脚本。
3.1 设计匹配模式
我们需要覆盖常见的JavaScript网络请求方式。以下是一些核心的正则模式示例:
Fetch API:
// 匹配 fetch(‘url‘) 或 fetch(`url`) const fetchRegex = /fetch\s*\(\s*['"`]([^'"`]+?)['"`]/g;XMLHttpRequest:
// 匹配 .open(‘METHOD‘, ‘url‘) const xhrRegex = /\.open\s*\(\s*['"`][^'"`]+?['"`]\s*,\s*['"`]([^'"`]+?)['"`]/g;Axios / 类似库:
// 匹配 axios.get(‘url‘), axios({url: ‘...‘}) 等,这是一个简化版 const axiosRegex = /axios\.(?:get|post|put|delete|patch|request)\s*\(\s*['"`]([^'"`]+?)['"`]/g; // 匹配 axios({ url: ‘...‘ }) const axiosConfigRegex = /axios\s*\(\s*\{[^}]*?url\s*:\s*['"`]([^'"`]+?)['"`]/g;jQuery Ajax:
// 匹配 $.ajax({ url: ‘...‘ }) const jqueryAjaxRegex = /\$\.ajax\s*\(\s*\{[^}]*?url\s*:\s*['"`]([^'"`]+?)['"`]/g;通用URL字符串模式(兜底): 这是一个风险较高但可能捕获意外之喜的模式,用于匹配看起来像API端点的字符串。
// 匹配包含 /api/, /v1/, .json, .php 等特征的字符串,且不在注释内(需简单预处理) const genericUrlRegex = /(?<!\/\/.*)(?<!['"`])['"`](\/(?:api|v\d+|graphql|rest)\/[\w\/\-\.\?\&=]+?)['"`]/g;注意:这个兜底正则非常容易误报,它可能匹配到图片路径、CSS链接等。通常需要结合前置的注释过滤和后续的结果过滤来使用。
3.2 编写提取脚本
下面是一个使用Node.js实现的简单脚本,它读取指定目录下的所有.js文件,应用上述正则进行匹配,并去重输出。
const fs = require('fs'); const path = require('path'); // 定义正则表达式数组 const regexPatterns = [ { name: 'fetch', regex: /fetch\s*\(\s*['"`]([^'"`]+?)['"`]/g }, { name: 'xhr', regex: /\.open\s*\(\s*['"`][^'"`]+?['"`]\s*,\s*['"`]([^'"`]+?)['"`]/g }, { name: 'axios_method', regex: /axios\.(?:get|post|put|delete|patch|request)\s*\(\s*['"`]([^'"`]+?)['"`]/g }, { name: 'axios_config', regex: /axios\s*\(\s*\{[^}]*?url\s*:\s*['"`]([^'"`]+?)['"`]/g }, // 谨慎使用通用匹配 // { name: 'generic_api', regex: /(?<!\/\/.*)(?<!['"`])['"`](\/(?:api|v\d+|graphql|rest)\/[\w\/\-\.\?\&=]+?)['"`]/g } ]; // 简单的注释移除函数(不完美,用于基础清理) function removeComments(code) { return code .replace(/\/\/.*$/gm, '') // 移除单行注释 .replace(/\/\*[\s\S]*?\*\//g, ''); // 移除多行注释 } function extractEndpointsFromFile(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const cleanedContent = removeComments(content); const endpoints = new Set(); regexPatterns.forEach(({ name, regex }) => { let match; // 注意:需要重置正则的 lastIndex 属性,因为使用了 ‘g‘ 标志 regex.lastIndex = 0; while ((match = regex.exec(cleanedContent)) !== null) { // match[1] 是第一个捕获组,即URL const url = match[1].trim(); if (url && !url.startsWith('data:') && !url.startsWith('blob:')) { endpoints.add(`${url} (via ${name})`); } } }); return Array.from(endpoints); } function scanDirectory(dirPath) { const allEndpoints = new Set(); function traverse(currentPath) { const items = fs.readdirSync(currentPath); for (const item of items) { const fullPath = path.join(currentPath, item); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { traverse(fullPath); } else if (stat.isFile() && item.endsWith('.js')) { console.log(`扫描文件: ${fullPath}`); const fileEndpoints = extractEndpointsFromFile(fullPath); fileEndpoints.forEach(ep => allEndpoints.add(ep)); } } } traverse(dirPath); return Array.from(allEndpoints).sort(); } // 使用示例:扫描当前目录下的‘js_files‘文件夹 const jsDir = path.join(__dirname, 'js_files'); if (fs.existsSync(jsDir)) { const results = scanDirectory(jsDir); console.log('\n=== 提取到的端点列表 ==='); results.forEach(ep => console.log(ep)); console.log(`总计: ${results.length} 个端点`); } else { console.error(`目录 ${jsDir} 不存在`); }3.3 关键注意事项与优化技巧
- 处理代码压缩:压缩后的代码通常在一行内,上述正则大多仍然工作。但要注意,压缩可能将字符串字面量拆分成多个部分(极少见),这种情况正则很难处理。
- 过滤噪声:脚本中简单过滤了
data:和blob:协议。在实际使用中,你还需要根据目标域名过滤掉明显的外部资源链接(如https://cdn.example.com/),或者通过一个“关键字白名单”(如包含/api/,/v1/)来聚焦目标。 - 性能考虑:对于超大型JS文件(>5MB),一次性读入内存可能有问题。可以考虑流式读取或分块处理。
- 正则的局限性:这是本方法的天花板。如果遇到
const baseUrl = ‘/api‘; const fullUrl = baseUrl + ‘/users‘;,正则无法将两者关联。此时就需要升级到AST方法。
4. 方法二实战:基于AST解析的精准提取
当正则无法满足需求时,我们就需要请出更强大的工具。这里我们使用Babel解析器来构建AST分析工具。
4.1 环境搭建与核心库介绍
首先初始化一个Node.js项目并安装必要依赖:
npm init -y npm install @babel/parser @babel/traverse @babel/generator@babel/parser:将JavaScript源代码转换成AST。@babel/traverse:用于遍历AST节点,并对其进行修改或收集信息。@babel/generator:将修改后的AST转换回代码(本例中主要用于调试,非必需)。
4.2 构建AST端点提取器
我们的目标是:找到所有函数调用节点(CallExpression),判断它是否是网络请求调用,然后尝试解析它的第一个参数(或配置对象中的url属性),最终得到一个完整的URL字符串。
const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const fs = require('fs'); const path = require('path'); // 一个简单的函数,尝试从节点中提取字符串值 // 它能处理:字符串字面量、模板字符串(无表达式)、标识符(变量名) function extractStringValue(node, scopeVariables) { if (node.type === 'StringLiteral') { return node.value; } if (node.type === 'TemplateLiteral') { // 简化处理:仅当所有表达式都为空(即纯字符串模板)时直接拼接 if (node.expressions.length === 0) { return node.quasis.map(q => q.value.cooked).join(''); } // 对于有表达式的模板字符串,我们可以尝试追踪,这里返回null表示复杂情况 return null; } if (node.type === 'Identifier' && scopeVariables) { // 尝试从我们收集的变量作用域中查找这个标识符的值 return scopeVariables[node.name] || null; } // 如果是二元运算符 ‘+‘,尝试递归解析左右两边 if (node.type === 'BinaryExpression' && node.operator === '+') { const left = extractStringValue(node.left, scopeVariables); const right = extractStringValue(node.right, scopeVariables); if (left && right) { return left + right; } } return null; } function extractEndpointsWithAST(code, filePath) { const ast = parser.parse(code, { sourceType: 'module', // 支持ES模块 plugins: ['jsx', 'dynamicImport'] // 支持JSX和动态导入 }); const endpoints = new Set(); // 一个简陋的作用域变量存储,key是变量名,value是字符串值(仅处理简单赋值) const scopeVariables = {}; // 首先,遍历一次,收集所有简单的变量声明(如 const url = ‘/api‘;) traverse(ast, { VariableDeclarator(path) { const { id, init } = path.node; if (id.type === 'Identifier' && init) { const value = extractStringValue(init, scopeVariables); if (value && typeof value === 'string') { scopeVariables[id.name] = value; } } } }); // 主要遍历,寻找网络请求调用 traverse(ast, { CallExpression(path) { const callee = path.node.callee; let urlNode = null; let method = 'UNKNOWN'; // 1. 识别 fetch(‘url‘) if (callee.type === 'Identifier' && callee.name === 'fetch') { urlNode = path.node.arguments[0]; method = 'FETCH'; } // 2. 识别 axios.get(‘url‘) 等形式 else if (callee.type === 'MemberExpression') { const objectName = callee.object.name; // e.g., ‘axios‘ const propertyName = callee.property.name; // e.g., ‘get‘ if (objectName === 'axios' && ['get', 'post', 'put', 'delete', 'patch', 'request'].includes(propertyName)) { urlNode = path.node.arguments[0]; method = `AXIOS.${propertyName.toUpperCase()}`; } // 3. 识别 xhr.open(‘GET‘, ‘url‘) else if (propertyName === 'open' && path.node.arguments.length >= 2) { urlNode = path.node.arguments[1]; method = path.node.arguments[0]?.value || 'XHR'; } } // 4. 识别 axios({ url: ‘...‘ }) 或 $.ajax({ url: ‘...‘ }) else if (callee.type === 'Identifier' && ['axios', '$'].includes(callee.name)) { const firstArg = path.node.arguments[0]; if (firstArg?.type === 'ObjectExpression') { const urlProperty = firstArg.properties.find(prop => prop.key?.name === 'url' || prop.key?.value === 'url'); if (urlProperty?.value) { urlNode = urlProperty.value; method = callee.name.toUpperCase() + '_CONFIG'; } } } // 如果找到了潜在的URL节点,尝试提取其字符串值 if (urlNode) { const urlString = extractStringValue(urlNode, scopeVariables); if (urlString && typeof urlString === 'string') { // 基础过滤 if (!urlString.startsWith('data:') && !urlString.startsWith('blob:')) { endpoints.add(`${urlString} [${method}]`); } } } } }); return Array.from(endpoints); } // 封装文件扫描函数 function scanWithAST(directory) { const allEndpoints = new Set(); function walk(dir) { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { walk(fullPath); } else if (stat.isFile() && /\.(js|jsx|ts|tsx)$/.test(file)) { try { console.log(`AST分析: ${fullPath}`); const code = fs.readFileSync(fullPath, 'utf-8'); const fileEndpoints = extractEndpointsWithAST(code, fullPath); fileEndpoints.forEach(ep => allEndpoints.add(ep)); } catch (error) { console.error(`解析文件 ${fullPath} 时出错:`, error.message); } } } } walk(directory); return Array.from(allEndpoints).sort(); } // 使用 const targetDir = path.join(__dirname, 'src'); // 假设扫描‘src‘目录 if (fs.existsSync(targetDir)) { const results = scanWithAST(targetDir); console.log('\n=== AST提取端点结果 ==='); results.forEach(ep => console.log(ep)); console.log(`总计: ${results.length} 个端点`); } else { console.log(`目标目录 ${targetDir} 不存在,请修改路径。`); }4.3 AST方法的高级技巧与边界处理
上面的示例是一个基础框架。在实际复杂项目中,你需要考虑更多:
- 作用域链分析:我们的
scopeVariables实现非常简陋。一个变量可能在函数内部声明,AST遍历器需要维护一个作用域栈。更健壮的做法是使用@babel/traverse的scopeAPI来解析标识符的绑定(binding)。 - 处理模板字符串和函数调用:
/api/${userId}/profile这样的URL,需要解析TemplateLiteral节点,并尝试计算userId表达式的可能值(这非常困难,通常只能得到/api/${}/profile这样的模式,或者结合动态执行)。 - 识别更多请求库:需要扩展
CallExpression的判断逻辑,加入对umi-request、ky、superagent甚至公司内部封装请求库的识别。 - 提取更多元数据:不仅仅是URL,还可以尝试提取
method、headers、data载荷,甚至相邻的注释,这些对安全分析至关重要。 - 处理TypeScript:Babel解析器需要添加
plugins: ['typescript']来支持TS语法。
踩坑实录:我曾分析一个Vue项目,发现大量API端点定义在
src/api/index.js中,以对象形式导出。单纯匹配函数调用找不到它们。后来调整策略,先寻找export default { ... }或export const api = { ... }这样的对象,再遍历其属性值(这些值往往是返回axios调用的函数),最终成功提取。这说明AST分析需要根据项目结构灵活调整策略。
5. 端点提取后的安全分析实战
提取到端点列表只是第一步,就像拿到了一本通讯录。安全分析是要从这本通讯录里找出“可疑分子”。以下是一些核心分析维度。
5.1 敏感接口识别与分类
将端点按照功能和安全敏感性进行初步分类,可以优先关注高风险类别。
| 分类 | 特征关键词示例 | 潜在风险 |
|---|---|---|
| 身份认证 | /login,/oauth,/token,/auth,/register,/logout | 爆破、撞库、令牌泄露、逻辑缺陷 |
| 用户数据 | /user,/profile,/account,/password,/email | 信息泄露、越权访问(水平/垂直) |
| 文件操作 | /upload,/download,/export,/import,/file | 任意文件上传、路径遍历、敏感文件下载 |
| 管理功能 | /admin,/manage,/config,/system,/root | 未授权访问、特权操作 |
| 数据查询 | /search,/query,/list,/get,/fetch | SQL注入、NoSQL注入、信息泄露 |
| 数据操作 | /create,/update,/delete,/modify,/submit | 数据篡改、权限绕过、CSRF |
操作方法:编写一个简单的分类器,用关键词匹配端点路径,为其打上标签。
5.2 参数分析与攻击面映射
观察端点URL本身和其所在的代码上下文(AST方法优势所在),分析其参数。
- 路径参数:如
/api/users/{id}。关注id是否为数字,是否存在未经验证直接传入数据库查询的情况(可能导致SQL注入或越权)。 - 查询参数:从代码中寻找
?后面的参数名,如/api/search?q=keyword&page=1。重点关注q(搜索词)、sort(排序)、filter(过滤)等,这些是注入和逻辑漏洞的高发区。 - 请求体参数:通过AST分析
fetch或axios调用时,可以尝试定位body或data参数。查看是否发送了JSON或FormData,参数名如username、password、role等。
实操技巧:将端点、参数名和可能的HTTP方法(GET/POST等)记录下来,形成一个简单的“接口字典”。这将成为你后续手动测试或自动化模糊测试的绝佳输入。
5.3 寻找低悬果实
结合提取到的端点信息,可以快速定位一些常见漏洞:
- 未授权访问:直接访问标注为
[ADMIN]的管理端点,看是否返回数据。 - 敏感信息泄露:访问
/api/config,/api/env,/api/debug等开发调试接口。 - 过度的详细信息:错误信息中是否包含堆栈跟踪、数据库错误(如SQL语句片段)。
- API版本遗留:发现
/api/v1/user和/api/v2/user同时存在,旧版本可能缺乏新版本的安全加固。 - HTTP方法滥用:尝试对只应接受GET请求的查询接口发送POST/PUT/DELETE请求。
5.4 构建自动化分析流水线(思路)
对于大型项目,可以将上述步骤串联起来,形成一个自动化分析脚本的雏形:
- 输入:目标网站URL或本地JS文件目录。
- 爬取/收集:使用工具(如
gospider,hakrawler)爬取站点,收集所有JS文件链接并下载。 - 提取:运行我们编写的AST端点提取脚本,输出结构化的端点列表(JSON格式),包含文件来源、URL、可能的方法、上下文代码片段。
- 分析:编写分析脚本,对端点列表进行:
- 分类打标。
- 参数提取。
- 相似度去重(避免
/api/user/1和/api/user/2被重复计数)。 - 生成测试用例:为每个端点生成基础的HTTP请求模板。
- 输出:生成一份HTML或Markdown报告,高亮显示敏感接口,并提供可直接导入到Burp Suite、Postman或自动化扫描工具的测试用例集。
6. 常见问题与排查技巧实录
在实际操作中,你肯定会遇到各种问题。这里记录了几个典型场景和我的解决思路。
6.1 问题一:正则匹配结果太多“垃圾”URL
现象:提取结果里混入了大量静态资源路径(/static/logo.png)、Webpack模块标识符(./src/moduleA)或第三方SDK的CDN链接。
解决方案:
- 后过滤:编写更严格的正则或过滤函数。例如,只保留包含特定路径片段(如
/api/,/graphql,/rest/)的URL。function isLikelyApiEndpoint(url) { const apiPatterns = [/^\/api\//, /^\/v\d+\//, /^\/graphql/, /\.(json|php|asp|aspx|jsp)$/i]; return apiPatterns.some(pattern => pattern.test(url)); } - 结合域名:如果知道目标应用的主域名,可以过滤掉非此域名及子域名的所有绝对URL。
- 上下文判断:在AST方法中,可以通过判断调用函数的“父节点”是否在一个事件监听器、异步函数或特定的业务模块中,来增加置信度。
6.2 问题二:AST解析时遇到奇怪的语法报错
现象:使用@babel/parser解析某些JS文件时,抛出SyntaxError。
可能原因与处理:
- 实验性语法:代码中使用了最新的、甚至提案阶段的JavaScript语法。解决:在
parser.parse的plugins选项中添加对应的插件,如‘decorators-legacy‘,‘classProperties‘等。 - 非JS内容:文件可能是
.vue或.svelte单文件组件,或者包含了大量内联的模板语法。解决:预处理文件,只提取<script>标签内的内容,或使用相应的Vue解析器(如@vue/compiler-sfc)。 - 代码严重损坏:网络爬取的JS文件可能不完整或被篡改。解决:记录错误并跳过该文件,继续处理其他文件。
6.3 问题三:无法解析动态生成的端点
现象:URL由多个变量拼接而成,且部分变量来自函数参数、API响应或环境变量,静态分析无法得到最终值。
思路:
- 模式化记录:即使得不到具体值,也可以记录下模式,如
/api/users/${userId}/posts/${postId}。这在安全测试中仍然有价值,你可以手动或自动化地替换${userId}和${postId}进行测试。 - 动态辅助分析:结合轻量级动态执行。在一个隔离的沙箱环境(如Node.js的
vm模块)中,尝试执行部分代码来获取变量的值。警告:这有安全风险,绝不能用于分析不受信的代码,仅适用于分析自己或完全信任的源代码。 - 人工研判:对于核心敏感功能,直接定位到相关代码块进行人工审计,理解其生成逻辑。
6.4 问题四:提取的端点有重复或变体太多
现象:/api/user,/api/user/,/api/user?id=1,/api/user/被识别为不同端点。
解决方案:在最终输出前进行“规范化”和“去重”。
- 规范化:移除末尾的斜杠、将多个连续的斜杠合并为一个、对查询参数按参数名排序(
/api/user?b=2&a=1和/api/user?a=1&b=2应视为相同)。 - 基于路径模式去重:将数字ID、哈希值等替换为占位符。例如,将
/api/user/123和/api/user/456统一归约为/api/user/{id}。这可以通过简单的正则(如将/\d+/g替换为{id})或更复杂的路由模式识别算法来实现。
最后,我想强调的是,无论是正则还是AST,都只是辅助我们更高效工作的工具。真正的安全分析能力,建立在对Web应用架构、HTTP协议、常见漏洞模式的深刻理解之上。工具帮你发现了“可能有电话打往哪里”,而你的知识则决定了“该如何测试这个电话是否安全”。这套方法我已经在数十个内部渗透测试和代码审计项目中应用,它极大地提升了前期信息收集的效率和深度。希望它也能成为你工具箱里的一件利器。