# 【有趣的技术3分钟】前端如何"藏"代码

> 前端代码天生是"公开"的——打开 F12，什么都能看见。
>
> 但"能看见"和"能看懂"，是两回事。

今天这道题，把 JavaScript 能"藏"代码的手段玩到了行为艺术的程度。它来自 Google CTF，名字叫 **JS Safe 6.0**：页面上是一个保险箱，你要在 Console 里输入一段密码 `unlock("CTF{...}")`，密码对了就弹窗解锁。

整个校验逻辑全在客户端，源码也就三百多行，明明白白摆在那儿——但它的每个字符、每个空格、每行看似无害的代码，都可能是一个精心设计的伪装。下面这六招，每一招都值得写进前端工程师和代码审计者的"防忽悠手册"。

---

## 第一招：让变量名"消失"

JavaScript 允许用 Unicode 字符当变量名。出题人挑了一个叫 **U+FFA0**（半角韩文填充符）的字符。它的特点是：**在编辑器里看起来就像一个空格，但它是一个合法的标识符字符**。

于是就有了这样的代码：

```javascript
window.step = 100;      // 你以为的"步数计数器"
window.stepﾠ = 5;       // 变量名其实是 "step" + U+FFA0，另一个完全不同的变量
```

在源码里，`window.step` 和 `window.stepﾠ` 肉眼看几乎一模一样，但 JavaScript 引擎把它们当成两个毫不相干的变量。你在代码里搜索 `step`，可能永远搜不到真正干活的那一个。

更狠的是，题目里有个 `double()` 函数，看起来会把 `window.step` 翻倍：

```javascript
const double = Function.call`window.stepﾠ *= 2`;
```

仔细看——它修改的是 `window.stepﾠ`，不是 `window.step`。你以为核心计数器被翻倍了，其实动的是个根本没人读的影子变量。

**这一招的杀伤力在于**：它不改变代码的任何行为，只改变你阅读代码时的认知。你以为你看懂了变量，其实你连变量名都认错了。

---

## 第二招：让函数调用"看起来像赋值"

这是六招里最阴的一招，需要先说一个 ES6 特性：**标签模板字符串（Tagged Template）**。

普通模板字符串是 `` `hello ${name}` ``。如果在前面加一个函数名，它就变成了"标签函数"调用：

```javascript
myTag`hello`     // 实际上会调用 myTag(["hello"])
```

注意——**没有任何括号，看起来就像一个普通的字符串**。

题目正是这么干的。它先定义了一个函数，变量名是不可见字符 `ﾠ`（U+FFA0），这个函数做的是 ROT13 字母替换。然后用它当标签：

```javascript
let pool = ﾠ`?o>\`Wn0o0U0N?05o0ps}q0|mt...`;
```

这一行**长得就像把一个模板字符串赋值给 pool**。但 `ﾠ` 不是空格，是一个函数名；这行代码实际上调用了 ROT13，对字符串做了一次替换。

你盯着这行看十分钟，都可能把它当成普通的赋值语句。函数调用就这样被"伪装"成了字符串字面量。

---

## 第三招：套娃编码，字符池面目全非

光有一次 ROT13 还不够。题目紧接着又对 pool 做了一次 ROT47（一种作用于所有可见 ASCII 字符的位移编码）：

```javascript
let pool = ﾠ`...`;        // 第一步：隐式 ROT13（上一招）
pool = r(pool).split('');  // 第二步：显式 ROT47
```

两层编码套娃之后，真正的字符池被埋得面目全非：

```
原始:    ?o>`Wn0o0U0N?05o0ps}q0|mt`ne`us&400_pn0ss_mph_0`5
  ↓ ROT13
  ↓ ROT47
最终:    n3m1y2_3_w_pn_d3_47N5_MK812C197Uc__042_770K4F0_1d
```

flag 的每一个字符，都藏在这个最终字符池里，再按一个伪随机算法重新排列顺序取出。

**套娃编码是混淆最朴素也最有效的形式**：解码逻辑就在隔壁几行，但你只要漏掉其中任何一层，算出来的 flag 就会带上一堆非法字符，整个结果全错。

---

## 第四招：借 `debug()` 的尸，还计数器的魂

这一招是全题的灵魂，也最值得安全从业者细品。

打开 Chrome 的 F12，在 Console 里你能用一些"特权函数"，比如 `debug(fn)`——它能给某个函数挂一个**条件断点**。题目滥用这个 API，干了一件出人意料的事：

```javascript
function instrument(f) {
    debug(f, "window.c && function perf(){ const l = `" 
             + f + "`.length; window.step += l; }()");
}
```

它给一大票函数（数组的、字符串的、Math 的、`eval`、`check` 本身……）都挂上条件断点。每次这些函数被调用，Chrome 都会先执行这段条件表达式——**把函数自己的源码长度累加到 `window.step`**。

重点来了：这段表达式最后返回 `undefined`（falsy），所以**断点根本不会触发**，程序照常运行。表面上这是"反调试"——好像在防你调试；实际上，它是在偷偷维护一个**全局计数器**。

出题人甚至在注释里把它叫 "performance counter"（性能计数器）。这是典型的**认知诱导**：让你以为这是个性能监控的副产物，应该绕过它；但其实 `window.step` 就是 flag 校验算法的核心——它决定了每次从字符池里取第几个字符。

**这是整道题最反直觉的设计**：你以为"反调试"是敌人，绕过它就赢了。结果反调试本身就是算法，你一绕过，step 就不对了，flag 就算错了。

---

## 第五招：把数组方法"掉包"成计数器

光靠 debug condition 还不够稳定。题目又用 JavaScript 的两个高级特性，给计数器上了双保险。

**第一层：`Object.defineProperty` 劫持原型链 getter。**

它把 `Array.prototype` 上的方法（shift、splice、map……）从"直接存函数"改成了"访问时调函数"：

```javascript
Object.defineProperty(Array.prototype, "shift", {
    get: () => (step++) && 原始的 shift 函数
});
```

从此以后，每次你写 `[1,2,3].shift`，getter 都会先 `step++`，再把真正的 shift 函数交给你。**调用方式没有任何变化**，但每次访问都偷偷记了一笔。

**第二层：用 Proxy 拦截整条原型链。**

更绝的是，它在 `Array.prototype` 和 `Object.prototype` 之间**塞了一个 Proxy 对象**，拦截所有原型链上的属性查找，每次都 `step++`：

```javascript
Object.setPrototypeOf(Array.prototype,
    new Proxy(Object.prototype, handler));  // handler 里每个操作都 step++
```

于是任何一个数组操作，只要内部顺着原型链找一找 `toString`、`valueOf` 这类方法，就会经过这个 Proxy，step 就涨。一次 `pool.splice(0, 1)`，可能就触发好几次计数。

**对业务代码来说，数组照常工作；对逆向者来说，每一次操作的开销都成了黑箱。** 你在 Python 里用纸笔去模拟"这一步 step 涨了多少"，几乎不可能算对——因为涨多少取决于 V8 内部的实现细节。

---

## 第六招：障眼法——看着吓人，其实什么都没干

最后这一招最"损"。题目里有这么一行：

```javascript
Function`[0].step; if (window.step == 0 || check.toString().length !== 914) 
    while(true) debugger;`
```

第一眼看到 `while(true) debugger`，逆向者的本能反应是："这是自检！一旦我改了源码长度，就死循环断点。我得想办法绕过它。"

但真相是：`Function\`...\`` 也是标签模板。`Function(strings)` 会**用字符串作为函数体创建一个新函数**——注意，是创建，不是调用。这个新函数创建完就被丢弃了，函数体里的 `while(true) debugger` **一次都没执行过**。

它的真正作用，只是因为 `Function` 自己也被 instrument 了，调用它时顺便给 step 加了一段长度。那段吓人的自检代码，纯粹是写给逆向者看的"恐怖故事"。

这一招的精神内核是：**用一段看起来很危险的代码，消耗你最宝贵的资源——注意力。** 让你花时间去研究"怎么绕过一个根本不存在的防御"。

---

## 这六招凑在一起，到底在干嘛？

把六招连起来看，你会发现一个漂亮（也残忍）的整体设计：

- **第二招 + 第三招**：把真正的字符池用两层编码藏起来，再用标签模板把其中一次解码伪装成赋值；
- **第四招 + 第五招**：构造一个遍布全局、无法绕开的 step 计数器，既靠 debug condition，又靠原型链劫持；
- **第一招 + 第六招**：用不可见字符和障眼法，让你认错变量、研究错地方。

flag 的每个字符都明明白白地写在字符池里，**秘密不在内容里，而在顺序里**——而顺序由那个 step 计数器驱动。整个系统没有一处真正"加密"，全是在"藏"：藏变量名、藏函数调用、藏计数器的真实身份、藏某些代码"什么都没干"的事实。

---

## 结尾：前端，能藏住代码吗？

回答这个问题，需要先把"藏"分成两件事：**藏逻辑** 和 **藏秘密**。

**藏逻辑**，前端是能做到的，也确实有正当用途。**反爬虫**用混淆拖慢脚本小子的分析速度；**游戏前端**用混淆防外挂；**SaaS 试用版**用混淆保护核心算法的时效性；**版权敏感的 SDK**用混淆提高二次打包的成本。这些场景里，混淆不是安全手段，而是**抬高攻击成本的经济学手段**——只要对方破解的收益低于成本，混淆就达成了目的。

**藏秘密**，前端做不到，也不该尝试。这道理这道题演示得很清楚：flag 完全暴露在客户端，三百多行源码、所有编码、所有算法、所有计数器，都任由你审视。出题人堆了八层机关，最终也只是把"答案藏在顺序里"，而不是把答案藏起来。一旦攻击者投入足够时间，所有客户端逻辑都会被还原。

所以一个清晰的原则是：

> **混淆负责"让逻辑难读"，服务端负责"守住秘密"。**
>
> 任何把秘密（密钥、密码、授权判断、计费逻辑）放进前端、然后指望靠混淆守住的决定，本质上都是在赌"没人愿意花这个时间"。而 CTF 选手和真实攻击者，最不缺的就是时间。

更进一步，**这道题本身就是一个反面教材，也是一个正面提醒**。

作为反面教材：它示范了客户端校验有多脆弱——一个完全运行在浏览器的密码校验，无论套多少层机关，都能被人在自己机器上完整逆向。

作为正面提醒：它告诉我们，**理解这些"藏"的手段，本身就是一种安全能力**。代码审计者要能一眼看穿不可见字符、标签模板、原型链劫持这些把戏，才能判断一段前端代码到底在做什么、有没有偷偷藏了不该藏的东西。

下次你按下 F12，看到一个看起来人畜无害的字符串赋值、一个长得像空格的变量名、一个"为了性能"的全局计数器——多留个心眼。代码可能正在你看不见的地方，忙着你读不懂的事。

毕竟，前端代码从来不会"藏住秘密"。它只是偶尔会**藏住自己**。
