客户端JavaScript插桩技术:高效挖掘漏洞的实用指南

本文详细介绍了客户端JavaScript插桩的技术方法,包括如何通过浏览器原生功能钩子函数、使用Eval Villain工具进行递归解码分析,以及结合条件断点实现非原生代码监控,帮助安全研究人员高效发现深层漏洞。

客户端JavaScript插桩

有大量代码不值得花费时间和脑力去分析。二进制逆向工程师通常使用ltrace、strace或frida直接跳转到重要代码部分。对于客户端JavaScript,你也可以仅使用常见的浏览器功能实现相同目的。这将节省时间,使测试更有趣,并帮助你将注意力集中在值得关注的代码上。

这篇博客介绍了我在插桩客户端JavaScript时的思考过程和实用方法。这些过程帮助我相对轻松地在复杂代码库中发现深度嵌入的漏洞。我使用这些技巧已经很久,以至于我在一个名为Eval Villain的Web扩展中实现了它们。虽然我会介绍Eval Villain的一些全新功能,但也会展示如何在不使用Eval Villain的情况下获得相同结果。

通用方法与思考

测试应用程序常常会引发关于应用程序如何工作的问题。如果应用程序要正常运行,客户端必须知道其中一些问题的答案。考虑以下问题:

  • 服务器接受哪些参数?
  • 参数是如何编码/加密/序列化的?
  • wasm模块如何影响DOM?
  • DOM XSS接收器在哪里?应用了哪些清理措施?
  • 消息后处理程序在哪里?
  • 广告之间的跨源通信是如何实现的?

为了使网页工作,它需要知道这些问题的答案。这意味着我们也可以在JavaScript中找到答案。注意,每个问题都暗示了特定JavaScript函数的使用。例如,客户端如何在不调用addEventListener的情况下实现消息后处理程序?因此,“第一步”是钩住这些有趣的函数,验证用例是否符合我们的兴趣,并回溯。在JavaScript中,它看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(() => {
    const orig = window.addEventListener;
    window.addEventListener = function(a, b) {
        if (a === "message") {
            console.log("postMessage handler found");
            console.log(b); // 你可以点击此输出直接转到处理程序
            console.trace(); // 查找处理程序注册的位置
        }
        return orig(...arguments);
    }
})();

如果处理程序尚未注册,只需将上述代码粘贴到控制台即可工作。然而,关键是在函数被使用之前钩住它。在下一节中,我将展示一种简单实用的方法来始终赢得这场竞赛。

钩住原生JavaScript是“第一步”。这通常有助于你找到有趣的代码。有时你会想插桩该代码,但它是非原生的。这需要一种不同的方法,将在“第二步”部分中介绍。

第一步:钩住原生JavaScript

构建自己的扩展

虽然你可以使用许多Web扩展之一将任意JavaScript添加到页面,但我不推荐这样做。这些扩展通常有bug,存在竞争条件,并且难以开发。在大多数情况下,我发现编写自己的扩展更容易。不要被吓倒,这真的很简单。你只需要两个文件,我已经在这里为你准备好了。

要在Firefox中加载代码,请在URL栏中转到about:debugging#/runtime/this-firefox,点击“加载临时附加组件”,并导航到扩展顶级目录中的manifest.json文件。

对于Chrome,转到chrome://extensions/,在右侧启用开发者模式,然后点击“加载已解压的扩展”。

扩展应显示在附加组件列表中,你可以在其中快速启用或禁用它。启用后,script.js文件将在每个网页中加载。以下代码行记录所有输入到document.write的内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/*********************************************************
 ***  你的代码在这里运行,在页面作用域内  ***
 *********************************************************/

// 示例代码,转储所有参数到document.write
document.write = new Proxy(document.write, {
    apply: function(_func, _doc, args) {
        console.group(`[**] document.write.apply arguments`);
            for (const arg of args) {
                console.dir(arg);
            }
        console.groupEnd();
        return Reflect.apply(...arguments);
    }
});

用你想要的任何代码替换这些行。你的代码将在每个页面和框架中运行,在页面有机会运行自己的代码之前。

工作原理

样板文件使用清单文件注册内容脚本。清单告诉浏览器内容脚本应在每个框架中运行,并在页面加载之前运行。内容脚本无法直接访问它们加载到的页面作用域,但它们可以直接访问DOM。因此,样板代码只是向页面DOM添加一个新脚本。CSP可以禁止这一点,因此扩展检查它是否工作。如果CSP阻止了你,只需通过浏览器配置、Web扩展或拦截代理禁用CSP。

注意,插桩代码最终具有与网站相同的权限。因此,你的代码将受到与页面相同的限制,例如同源策略。

异步和竞争

快速警告一下。上述内容脚本将让你首先访问唯一的JavaScript线程。网站本身无法运行任何JavaScript,直到你放弃该线程。试试看,看看你能否在样板钩住document.write之前运行它。

首先访问是一个巨大的优势,你可以毒化网站即将使用的环境。在你完成毒化之前,不要放弃你的优势。这意味着避免使用异步函数。

这就是许多旨在将用户JavaScript注入页面的Web扩展有bug的原因。在Web扩展中检索用户配置是使用异步调用完成的。在异步查找用户配置时,页面正在运行其代码,并可能已经执行了你想要钩住的接收器。

这就是Eval Villain仅在Firefox上可用的原因。Firefox有一个独特的API,可以注册带有用户配置的内容脚本。

Eval Villain

我很少遇到无法用Eval Villain解决的“第一步”情况。Eval Villain只是一个内容脚本,它钩住接收器并在输入中搜索源。你可以将几乎任何原生JavaScript功能配置为接收器。源包括用户配置的字符串或正则表达式、URL参数、本地存储、cookie、URL片段和窗口名称。这些源被递归解码以查找重要子字符串。让我们看看上面示例的同一页面,这次使用Eval Villain的默认配置。

注意此页面是从本地file://加载的。源代码见下文。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script>
let x = (new URLSearchParams(location.search)).get('x');
x = atob(x);
x = atob(x);
x = JSON.parse(x);
x = x['a'];
x = decodeURI(x);
x = atob(x);
document.write(`Welcome Back ${x}!!!`);
</script>

即使页面没有Web请求,Eval Villain仍然成功钩住了用户配置的接收器document.write,在页面使用它之前。没有竞争条件。

还要注意,Eval Villain不仅显示document.write的输入。它正确突出了注入点。URL参数x包含一个编码字符串,击中了接收器document.write。Eval Villain通过递归解码URL参数发现了这一点。由于参数被解码,向用户提供了一个编码器函数。你可以右键单击,复制消息并将其粘贴到控制台。使用编码器函数可以快速尝试有效负载。下图显示了使用编码器函数将marquee标签注入页面。

如果你阅读了前面的部分,你就知道这一切是如何工作的。Eval Villain只是使用内容脚本将其JavaScript注入页面。它所做的任何事情,你都可以在自己的内容脚本中完成。此外,你现在可以使用Eval Villain的源代码作为你的样板代码,并为其功能定制以应对特定的技术挑战。

第一步.5:快速提示

假设你使用“第一步”从有趣的原生函数获取console.trace。也许URL参数击中了你的decodeURI接收器,现在你正在回溯到URL解析函数。在这种情况下,我经常犯一个错误,我希望你做得更好。当你得到跟踪时,不要开始阅读代码!

现代Web应用程序通常在console.trace的顶部有polyfills和其他冗余。例如,我在Google搜索结果页面上得到的堆栈跟踪以函数iAa、ka、c、ng、getAll开始。不要陷入隧道视野,开始阅读ka,当getAll显然是你想要的。当你看getAll时,不要阅读源代码!继续扫描,注意getAll是一个方法,它的兄弟是get、set、size、keys、entries以及URLSearchParams文档中列出的所有其他方法。

我们刚刚找到了多个自定义URL解析器,在最小化代码中重新实现,而无需实际阅读代码。“扫描”尽可能多,不要开始深入阅读代码,直到找到正确的位置或扫描失败。

第二步:钩住非原生代码

插桩原生代码没有导致漏洞。现在你想插桩非原生实现本身。让我用一个例子来说明这一点。

假设你发现了一个URL解析器函数,它返回一个名为url_params的对象。该对象具有URL参数的所有键值对。我们想监控对该对象的访问。这样做可以为我们提供一个与URL关联的每个URL参数的清晰列表。我们可能通过这种方式发现新参数,并解锁站点中的隐藏功能。

在JavaScript中这样做并不难。在16行代码中,我们可以有一个组织良好、唯一的URL参数列表,与适当的页面关联,并保存在localStorage中以便轻松访问。我们只需要弄清楚如何将代码直接粘贴到URL解析器中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function parseURL() {
    // URL解析代码
    // url_params = {"key": "value", "q": "bar" ...

    // 你想添加的代码
    url_params = new Proxy(url_params, {
        __testit: function(a) {
            const loc = 'my_secret_space';
            const urls = JSON.parse(localStorage[loc]||"{}");
            const href = location.protocol + '//' + location.host + location.pathname;
            const s = new Set(urls[href]);
            if (!s.has(a)) {
                urls[href] = Array.from(s.add(a));
                localStorage.setItem(loc, JSON.stringify(urls));
            }
        },
        get: function(a,b,c) {
            this.__testit(b);
            return Reflect.get(...arguments);
        }
    };
    // 你的代码结束

    return url_params;
}

Chrome的开发工具允许你在JavaScript源中键入自己的代码,但我不推荐这样做。至少对我来说,添加的代码会在页面加载时消失。此外,这种方式不容易管理任何插桩点。

我有一个更好的解决方案,它内置于Firefox和Chrome中。将你的插桩代码用括号括起来,在末尾添加&& false。上面的代码变成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
(url_params = new Proxy(url_params, {
    __testit: function(a) {
        const loc = 'my_secret_space';
        const urls = JSON.parse(localStorage[loc]||"{}");
        const href = location.protocol + '//' + location.host + location.pathname;
        const s = new Set(urls[href]);
        if (!s.has(a)) {
            urls[href] = Array.from(s.add(a));
            localStorage.setItem(loc, JSON.stringify(urls));
        }
    },
    get: function(a,b,c) {
        this.__testit(b);
        return Reflect.get(...arguments);
    }
}) && false

现在右键单击要添加代码的行号,点击“条件断点”。

将你的代码粘贴在那里。由于&& false,条件永远不会为真,因此你永远不会得到断点。浏览器仍将执行我们的代码,并在我们插入断点的函数作用域内。没有竞争条件,断点将继续存在。当你打开开发人员工具时,它将在新选项卡中显示。你可以通过禁用辅助断点来快速禁用单个插桩脚本。或者通过禁用断点或关闭开发人员工具窗口来禁用所有它们。

我使用这个特定示例来展示你可以走多远。插桩代码将按站点将URL参数保存到本地存储条目。在任何给定页面,你可以通过将以下代码粘贴到控制台来自动填充所有已知URL参数到URL栏。

1
2
3
4
5
(() => {
const url = location.protocol + '//' + location.host + location.pathname;
const params = JSON.parse(localStorage.getItem("my_secret_space"))[url];
location.href = url + '?' + params.flatMap( x => `${x}=${x}`).join('&');
})()

如果你经常使用这个,你甚至可以将代码放在书签中。

结合原生和非原生插桩

没有什么能阻止我们同时使用原生和非原生函数。你可以使用内容脚本实现大型花哨的代码库。将该功能导出到全局作用域,然后在条件断点中使用它。

这带来了Eval Villain的最新功能。你的条件可以利用Eval Villain的递归解码功能。在弹出菜单中点击“配置”并转到“全局”部分。确保“sourcer”行已启用,然后点击保存。

我发现自己经常启用/禁用此功能,因此在弹出菜单本身有第二个“启用”标志。它在“启用/禁用”菜单中作为“用户源”。这导致Eval Villain将evSourcer函数导出到全局名称作用域。这将向递归解码源列表添加任何任意对象。

可以看到,第一个参数是你为源命名的名称。第二个是你要搜索接收器的实际对象。除非有Eval Villain不理解的自定义编码,否则你可以直接原始放入。有一个可选的第三个参数,会导致sourcer每次调用时都console.debug。此函数返回false,因此你可以在任何地方将其用作条件断点。例如,你可以将其添加为条件断点,仅在感兴趣的消息后处理程序中运行,当从特定源接收消息时,作为查找消息的任何部分是否会击中DOM XSS接收器的手段。在正确的地方使用这可以减轻对你的插桩代码施加的SOP限制。

就像evSourcer一样,还有一个evSinker。我很少使用这个,因此在弹出菜单中没有“启用/禁用”条目。它接受接收器名称和参数列表,并像你自己的接收器一样行动。它也返回false,因此可以轻松地在条件断点中使用。

结论

编写自己的插桩是漏洞研究的一项强大技能。有时,只需几行JavaScript就能驯服一个巨大的杂乱代码库。通过了解这是如何工作的,你可以更好地洞察像Eval Villain和DOM invader这样的工具能做什么和不能做什么。必要时,当工具不足时,你也可以调整自己的代码。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计