客户端JavaScript插桩技术深度解析

本文详细介绍了客户端JavaScript插桩的技术方法,包括原生函数钩子、非原生代码插桩、Eval Villain工具使用等,帮助安全研究人员在复杂代码库中高效发现深层漏洞。

客户端JavaScript插桩

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

本文介绍了我对客户端JavaScript进行插桩的思考过程和实用方法。这些过程帮助我相对轻松地在复杂代码库中发现深度嵌入的错误。我使用这些技巧已经很久,以至于我在名为Eval Villain的浏览器扩展中实现了它们。虽然我会向你介绍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

构建自己的扩展

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

要在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
// 示例代码:转储所有对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确实阻止了你,只需通过浏览器配置、浏览器扩展或拦截代理禁用CSP。

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

异步和竞争

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

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

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

这就是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>

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

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

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

第一步半:快速提示

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

现代Web应用程序通常在console.trace的顶部有polyfill和其他杂乱代码。例如,我在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 设计