文|腾讯研发安全团队 Spine、martinzhou
Node-RED是IBM开源的低代码物联网编排工具,有广泛应用,包括研华WISE PaaS、西门子Iot2000、美国groov EPIC/groov RIO等工业IoT硬件也都预装了Node-RED。此外,它亦常被作为低代码开发平台使用。
A large proportion of our users are individuals who run Node-RED on personal devices - a laptop, a Raspberry Pi or a virtual machine in the cloud. They are building solutions for themselves - whether it’s home automation, adding skills to their Alexa or Google Home, or doing the types of online services like IFTTT can provide.
Another group of users come from the companies who have integrated Node-RED into their own products and services. Hitachi, Siemens, Samsung, Particle and many others.
Nick O'Leary, What's next with Node-RED?
基于腾讯自研SAST语义分析引擎“啄木鸟”,开展漏洞变体分析。我们对Node-RED平台及其第三方插件进行了人工和自动化白盒代码审计,发现了多个通用高危漏洞(CVE-2021-21298、CVE-2021-21297、CVE-2021-3223、CVE-2021-25864等),并进一步探究了潜在利用方式及危害,本文将详述分析过程及典型案例。
Node-RED平台基于Express.js开发,我们从鉴权方式、http接口、客户端安全以及组件生态等多个维度入手开展分析。
Node-RED的鉴权方式依赖配置,默认没有任何鉴权,当开启鉴权后,会对接口进行鉴权。相关权限在settings.js文件中定义:
adminAuth: {
type: "credentials",
users: [
{
username: "admin",
password: "$2a$08xxxxcxWV9DN.", <-- 加密过后的密钥
permissions: "*", <-- 定义权限
}
]
}
如果开启了鉴权功能,Node-RED会通过passport中的OAuth策略对用户进行登录校验并提供访问token,token通过浏览器的Local Storage存储。OAuth策略中使用账号密码进行登陆校验,通过bcrypt对用户密码进行校验。
bcrypt.compare(password, user.password, function(err, res) {
resolve(res?cleanUser(user):null);
});
此处可以认为不存在绕过方式。access token使用bearer策略进行校验,主要函数是:
var bearerStrategy = function (accessToken, done) {
// is this a valid token?
Tokens.get(accessToken).then(function(token) {
if (token) {
Users.get(token.user).then(function(user) {
if (user) {
done(null,user,{scope:token.scope});
} else {
log.audit({event: "auth.invalid-token"});
done(null,false);
}
});
} else {
log.audit({event: "auth.invalid-token"});
done(null,false);
}
});
}
Tokens采用map存储,此处存在一个潜在的安全风险:当传入accessToken为__proto__时,token会返回prototype的内容,从而绕过if(token)的检查,后续如果有一个原型链污染漏洞能伪造user(用户名)字段,即可绕过token检查。
通过检测用户对象中的scope字段区分读写接口权限,当scope字段为 "*" 时,默认可以读写所有接口,admin默认权限就是 "*"。Node-RED在0.14版本之后对权限做了细分,详细可见 https://nodered.org/docs/user-guide/runtime/securing-Node-RED#user-permissions,权限类型概述如下:
权限 | 说明 |
---|---|
* | 允许read和write的所有权限 |
write | 涉及到写的权限,能做部署、增/删/改、安装第三方插件等等操作 |
read | 涉及到读的权限,只能查看各类flows,但无法做部署、增/删/改等操作 |
Node-RED的权限管控功能由needsPermission函数实现,用例如下:
editorApp.use("/settings/user/keys",needsPermission("settings.write"),info.sshkeys());
该接口就需要一个settings.write权限才能读取。如果接口声明未能和它的行为相符,或者存在漏洞,那么也可以认为是一个越权漏洞。
Node-RED中存在3类http接口:
上一节描述了Node-RED的接口鉴权机制,原生http接口未鉴权的较少,其中2和3类接口通过以下两种方式暴露
RED.httpAdmin.get("xxx", RED.auth.needsPermission("xxx"), handler)
RED.httpNode.get("xxx", handler)
因此,可以在源码中批量搜索来找未鉴权的接口。在对第三方组件分析、扫描过程中,我们发现很多插件都没有采用needsPermission方式鉴权,存在较大安全隐患。
Node-RED通过http头来实现鉴权,这意味着传统的CSRF攻击不再奏效,也有对应的CORS安全策略。
因此,主要关注的风险是XSS漏洞。从功能设计上看,Node-RED 前端只有一个大页面,使用JS操作dom的方式渲染后端数据,这种机制一定程度上收敛了产生XSS漏洞的风险。各组件面临的XSS风险及框架采取的预防措施,如下:
组件 | 细分风险类型 | 现有预防措施 |
---|---|---|
HTTP API接口 | 反射XSS | Content-Type设置为application/json |
vendor.js、red.min.js等前端组件 | DOM XSS | 调用函数sanitize过滤 |
ACE富文本编辑器 | 存储XSS | DOMPurify |
Node-RED的历史CVE数据显示,此前有过一个编号为CVE-2019-15607的XSS漏洞,修复提交记录为:https://github.com/Node-RED/Node-RED/commit/30c3004f27e86377613c06649f939e7f32746ca5。
结合上述信息,我们认为主要存在两类风险:
Node-RED安全过滤方式为:调用RED.utils.sanitize对可能包含HTML特殊字符的变量做转义过滤。如:
title: RED._("workspace.editFlow",{name:RED.utils.sanitize(workspace.label)}),
这意味着,如果开发者忘记调用RED.utils.sanitize,就有可能出现XSS问题。在审计过程中,我们发现了一处相关风险,其位于/@Node-RED/editor-client/src/js/ui/notifications.js第96-103行,在做innerHTML操作前由于未调用对msg变量做处理:
...
n.style.display = "none";
if (typeof msg === "string") {
if (!/<p>/i.test(msg)) {
msg = "<p>"+msg+"</p>";
}
n.innerHTML = msg; <-- 未对msg变量做处理并做了innerHTML操作
} else {
$(n).append(msg);
}
...
当在Node-RED的projects功能中,当用户尝试切换git分支,/editor-client/src/js/red.js会调用Red.notify弹出消息提示气泡。如分支名存在Payload,就会触发XSS漏洞,该问题已由官方确认并修复(https://github.com/node-red/node-red/compare/1.2.9...master)。
主要涉及Markdown富文本编辑场景,虽然DOMPurify的过滤机制已十分完善,但历史上仍有绕过方式被披露,如:CVE-2020-26870。一旦该组件出现新的绕过方式,Node-RED亦可能受到影响。
Node-RED一大特色是其丰富、灵活的第三方插件生态,截至目前,平台提供3063个可用模块。许多的工业控制公司也开发了针对自身产品的node和flow,如:Node-RED-contrib-groov。安装后,插件与Node-RED融为一体,会直接影响到整个平台的安全,正所谓“牵一发动全身”。
上述分析过程中,我们在Node-RED中发现了多个安全风险:
漏洞类型 | CVE编号 |
---|---|
路径穿越 | CVE-2021-21298 |
原型链污染 | CVE-2021-21297 |
同时,在多款Node-RED流行插件中发现了多个高危风险,最严重可导致运行Node-RED的服务器被getshell:
插件 | 漏洞类型 | CVE编号 |
---|---|---|
Node-RED-dashboard | 任意文件读取 | CVE-2021-3223 |
Node-RED-contrib-huemagic | 任意文件读取 | CVE-2021-25864 |
在审计Node-RED的Projects功能时,我们发现了一处任意文件读取漏洞(CVE-2021-21298),位于/editor-api/lib/editor/projects.js内。如下代码片段中,opts.path的输入内容用户可控:
app.get("/:id/files/:treeish/*", needsPermission("projects.read"), function(req,res) {
var opts = {
user: req.user,
id: req.params.id,
path: req.params[0],
tree: req.params.treeish,
req: apiUtils.getRequestLogObject(req)
}
runtimeAPI.projects.getFile(opts).then(function(data) {
res.json({content:data});
}).catch(function(err) {
apiUtils.rejectHandler(req,res,err);
})
});
直接传入了 /runtime/lib/storage/localfilesystem/projects/Project.js
Project.prototype.getFile = function (filePath,treeish) {
if (treeish !== "_") {
return gitTools.getFile(this.path, filePath, treeish);
} else {
return fs.readFile(fspath.join(this.path,filePath),"utf8");
}
};
如果treeish为 "_" 时,会直接调fs.join,而后调用readFile,产生了任意文件读取漏洞。
无独有偶,此类问题在Node-RED的第三方插件中也屡见不鲜。尽管Node-RED框架提供了对接口进行权限保护的方式RED.auth.needsPermission,但出于功能需要,部分Node-RED第三方插件的接口并未设置鉴权。如果未对路径穿越字符做处理,恶意参数值经path.join处理被传入res.sendFile,攻击者就可以窃取服务器上的任意文件。
借助自研白盒代码扫描引擎,我们在Node-RED官方提供的可视化插件Node-RED-dashboard中,发现了一例此类风险案例,CVE编号为CVE-2021-3223。
https://github.com/Node-RED/Node-RED-dashboard/blob/3fd92649bbec36337740df33587af16813a94822/nodes/ui_base.js#L105
RED.httpAdmin.get('/ui_base/gs/*', function(req, res) {
var filename = path.join(path.dirname(gsp), req.params[0]);
res.sendFile(filename, function (err) {
if (err) {
if (node) {
node.warn(filename + " not found. Maybe running in dev mode.");
}
else {
console.log("ui_base - error:",err);
}
}
});
});
此处功能设计的本意是:通过RED.httpAdmin.get('/ui_base/gs/*'暴露接口,提供静态资源。首先,使用req.params[0]获取参数值。随后,拼接出访问的目标文件路径并使用Express框架提供的res.sendFile方法,将相关内容发送给客户端。由于并未对req.params[0]是否包含字符“..”做判断,产生路径穿越漏洞。另一款流行插件Node-RED-contrib-huemagic中也存在类似问题,编号为CVE-2021-25864,该插件用于与Philips Hue系列物联网设备交互。
进一步分析发现,该类路径穿越造成的任意文件读取漏洞,背后存在更严重的隐患。根据Node-RED的设计,认证凭据直接保存在本地文件中,攻击者可进一步读取管理员密钥,绕过鉴权保护。所有用户登录生成的Access Token均存放在.sessions.json文件中,settings.userDir默认为$HOME/.Node-RED,由于可以读取目标服务器上的任意文件,$HOME变量不难预测。
// @Node-RED/runtime/lib/storage/localfilesystem/sessions.js
init: function(_settings) {
settings = _settings;
sessionsFile = fspath.join(settings.userDir,".sessions.json");
},
Node-RED的UI后台借助Local Storage中存储的access_token票据鉴权,在.sessions.json中取得管理员凭证后直接设置相关字段,即可绕过登录鉴权:
{"access_token":"OdAaz8swYOALbvSS6gql6OZJgR0Dk855cvHaJdEGzpy4TwPpuCV2nOhM83I6jyYxxxxssgt9DTdTogUsvCfM=","expires_in":604800,"token_type":"Bearer"}
攻击者登录后台后,可借助Node-RED平台提供的http-in、function、exec等节点,创建一个shell flow,获取对目标服务器的持久控制。
说到JS特有的漏洞,大家肯定第一时间能想到原型链污染。其往往隐藏于一些JS的底层库中,像Lodash、Jquery等库都被爆出过该漏洞,在挖掘Node-RED的漏洞时,我们很幸运找到了一个能导致原型链污染的依赖。
在描述具体详情前,我们需要了解Node-RED的 i18n 功能的实现,作为一个全球都有使用的平台,加上其本身作为low-code平台的特殊场景,Node-RED支持了插件自定义语言。
为了实现插件自定义的语言加载,开发者使用了 i18next 作为他们的i18n实现。i18next本身也是一个易拓展的框架,可以定义不同的backend来自定义翻译文件加载过程。i18next维护着一系列官方backend实现,例如i18next-http-backend,能通过http加载翻译文件,详细列表可见https://www.i18next.com/overview/plugins-and-utils#backends。
而Node-RED自己实现了一套翻译文件读取的backend,如下图。
每个插件可以设置自己的locales目录,一个具体的目录内容如下:
其中目录名是语言名称,json文件存放的是对应语言的翻译。Node-RED通过统一的API访问不同插件的翻译文件,接口形式如下:
其中namespace对应Node-Red的插件类,language对应语言。
可以提供namespace和language来查询对应的翻译文件,访问
http://127.0.0.1:1880/locales/runtime?lng=zh-CN
来获取 namespace 为 runtime、language为zh-CN的json文件,也就是 `runtime/zh-CN/runtime.json` 文件,然而此处 backend 实现有一个“小问题”:
lng和ns参数直接传入了readFile,此处存在目录穿越漏洞,但限定死了后缀,这个file也就是每个插件自定义的固定json文件名。看上去此处目录穿越十分鸡肋,其实不然。
不可忽视的是,i18next就像一个黑盒,虽然Node-RED自己实现了一个读取翻译文件的backend,然而主要的语言管理以及翻译功能,都是i18next提供的,也就是说 i18next内部也必须管理这些资源,我们大致画一个流程图来解释i18next的文件读取工作流程。
在用户发送请求后,i18next会去backend请求对应语言的所有资源,backend返回资源后,i18next会通过addResourceBundle缓存对应资源,最后Node-RED调用getResourceBundle返回用户需要的部分。
问题出现在addResourceBundle中,这个接口适配了两套调用。一套是 addResourceBundle(language, namespace, data),另一套是 addResourceBundle(language, data)。注意以下代码片段第三行的if逻辑,`lng` 可以通过 "." 来分割成ns和path两个变量。到这里还可以发现,i18next内部是通过path来实现存取bundle的,所以可以仔细看setPath和getPath两个函数。
addResourceBundle(lng, ns, resources, deep, overwrite, options = { silent: false }) {
let path = [lng, ns];
if (lng.indexOf('.') > -1) {
path = lng.split('.');
deep = resources;
resources = ns;
ns = path[1];
}
this.addNamespaces(ns);
let pack = utils.getPath(this.data, path) || {};
if (deep) {
utils.deepExtend(pack, resources, overwrite);
} else {
pack = { ...pack, ...resources };
}
utils.setPath(this.data, path, pack);
if (!options.silent) this.emit('added', lng, ns, resources);
}
在setPath中,调用了getLastOfPath这个函数,JS审计经验丰富点的话就可以看出这是个很明显的原型链污染。
export function setPath(object, path, newValue) {
const { obj, k } = getLastOfPath(object, path, Object);
obj[k] = newValue;
}
function getLastOfPath(object, path, Empty) {
...
const stack = typeof path !== 'string' ? [].concat(path) : path.split('.');
while (stack.length > 1) {
if (canNotTraverseDeeper()) return {};
const key = cleanKey(stack.shift());
if (!object[key] && Empty) object[key] = new Empty();
object = object[key];
}
if (canNotTraverseDeeper()) return {};
return {
obj: object,
k: cleanKey(stack.shift()),
};
}
至此,结合3.2.1中的“鸡肋”路径穿越,我们可以给出漏洞利用的POC:
http://127.0.0.1:1880/locales/x?lng=__proto__.test./../zh-CN/
这里的lng能确保读到翻译的json文件,而且会在addResourceBundle中被解析成 ["__proto__", "test", "/", "", "/zh-CN/"]的path, 从而导致test属性被污染。
可惜的是,这个漏洞仅仅能污染key,value并不可控,算是一个小遗憾吧。虽然Node-RED本体可能很难利用,但Node-RED拥有丰富的插件生态,该漏洞同样能影响到插件,从而造成更大的危害。
综上,Node-JS在提供能小至函数级的依赖,给开发者以便利的同时,也面临着大量底层库误用和漏洞导致的问题,这点值得警惕。
除人工审计外,我们还基于自研的SAST语义分析引擎,自定义漏洞变体分析规则,对Node-RED及其第三方开源插件进行了自动化检索分析,下面分享任意文件读取及原型链污染的实现思路。
我们应该秉持知其然、知其所以然的观念,从研发角度去看漏洞是如何产生的,否则在白盒检测层面就永远抓不住规律而浮于表面。以 express 为例,以下是一个很自然,但并不正确的静态文件serve的思路。
从最原始的需求下来,研发往往会认为这个需求是简单的,几行代码就能解决,但如果研发顺着这个思路,那很可能就会写出一个漏洞。
安全的做法是要限制读取的目录,在这个流程中有以下几种做法。
第一种,校验 path.join 的结果是否还在资源目录内,用 JS 的 indexOf 方法判断最终路径是否是资源目录开头。例子如下:
app.get("/*", (req, res) => {
filePath = path.join(root, req.params[0])
if(filePath.indexOf(root) != 0) {
res.status(401)
return
}
res.sendFile(filePath)
})
或者也可以校验用户传入的参数中是否包含 ".."
app.get("/*", (req, res) => {
if(req.params[0].indexOf("..") != -1) {
res.status(401)
return
}
filePath = path.join(root, req.params[0])
res.sendFile(filePath)
})
res.sendFile 可以设置 option 作为第二个参数,可以设置其中的 root 选项来限制读取的目录,但这个参数却是默认缺省的,而且语义上是有区别的,如果设置了root选项,默认是从root开始读取相对路径而非绝对路径。
如果研发使用了sendFile的options,意识到sendFile存在root选项,那么无论是测试也好,读文档也好,能很快的发现不需要自己拼接,从而省略 path.join 的过程。例子如下:
app.get("/*", (req, res) => {
opts = {
root: root,
dotfiles: 'deny',
}
res.sendFile(req.params[0], opts)
})
有意思的是sendFile还有个特性,当传入的路径中包含 ../ 或者 ..\ 的时候,是会直接拒绝访问的,但因为 path.join 调用,导致 ../ 被去掉了,如果把 path.join 直接换成加号,在linux上其实一点问题没有。
第三种,直接采用第三方包实现,方便安全,如serve-static:
var serveStatic=require('serve-static')
app.use(serveStatic(root))
综上,针对以上思路的文件读取漏洞很大程度上是依赖路径拼接这个关键点,所以我们编写规则时要加强对其的检测。通过分析以上漏洞产生的成因,可归纳出漏洞形成的条件,整理成一个检测流程:
1.设置 source点 和 sink点
2.通过污点传播分析,获得传播路径
3.如果 sink 为 res.sendFile,过滤路径中不含 路径拼接函数 的路径
4.过滤路径中包含 值判断 以及 存在 options 设置 的路径
以下表格详细列出了以上检测流程中各个实体代表的值
实体 | 具体函数/变量/操作 |
---|---|
source | req.query.*, req.body.*, req.params.* |
sink | fs.readFileSync, res.sendFile |
路径拼接函数 | path.join,path.resolve |
值判断 | indexOf, includes, startsWith 等 |
前两步会比较简单,通过数据流的方式就能得到结果,后两步需要额外判断变量值。通过这套流程检测出来的规则误报率会很低,但也有一些缺陷。可能导致误报的原因包括接口权限,自定义过滤方式等。同时,因为第三步对路径拼接函数的检测,会导致部分漏报,但这部分比例不会很高。
原型链污染的检测和文件读取的检测存在很大的区别,它更像是C++数组越界写的JavaScript“翻版”,导致原型链污染的根源总会是以下这一行。
obj[key] = expr
但这里要满足一个条件: obj必须是object原型对象。在白盒层面我们该怎么判断这个obj的值呢?笔者这里也没有找到一个完美的方法,只能从漏洞pattern入手。
如果obj是object的原型对象,那么我们可以从来源考虑:
obj = obj[key]
在参考了几个实际的漏洞后,发现以下这个pattern是最常见的,其中key是我们可控的字符串,往往来源于一个数组,且该语句和导致原型链污染的语句往往会在一个大循环内,就像案例中描述的那个 getLastOfPath 一样。
for (xxx) {
...
obj = obj[key]
...
obj[key] = expr
...
}
那么在这种情况下我们可以定义
StoreSet = {}
LoadSet = {}
obj[key] = expr => StoreSet.add(obj)
obj = obj[key] => if isInput(key) then LoadSet.add(obj)
例如以下代码:
a = a[req.query.x]
a["111"] = 111
第二行触发了Store("a"),所以 a 会被加入StoreSet中,同理,因为第一行符合key可控,所以触发了Load("a"),a也会被加入LoadSet中,当一个函数中的变量符合同时在两个Set中的条件时,我们认为它是可疑的。
但仅仅这样还不够,这里有三个问题:
1.语句之间的先后关系
2.怎么判断key是否是输入,也就是isInput函数的具体实现
3.怎么判断是否做了安全检查
首先看第一个问题:
a["111"] = 111
a = a[req.query.x]
如果我们这样写,好像并没有问题,但是它们在for循环中又会有问题,所以我们需要判断语句间的先后关系。当我们发现 a 变量是可疑的时候,我们可以找到 a 变量相关语句,然后再在CFG上寻找先后关系。因为是单函数分析,所以并没有太大开销问题。
第二个问题比较麻烦,也是可能导致漏报的点。
因为该漏洞出现的函数很可能就是一个普通的逻辑函数。这个Input不能直接指向用户输入,这个输入还可能是来自于参数,尤其是一些JS的第三方底层库。如果分析过一些原型链污染漏洞,也可以总结出一个pattern:
vulnerableFunc(a, b, c, d)
发生原型链污染漏洞的key来源大多是来自于单个实参(参数本身是object,例如json对象),像以上例子这个key可能会来自于a,b,c,d中的任意一个。
所以我们还需要对key做一个数据流反向追踪,观察是否满足以下条件:
这里我们要把第三个问题考虑进去,也就是过滤问题,针对原型链污染漏洞,基本的防护方法大致如下
if (obj.hasOwnProperty(key)) obj = obj[key]
or
if(key != "__proto__") obj = obj[key]
我们会发现基本都是对Load前key的判断,所以这里在反向追踪数据流时,有第三个条件就是不能有一些 判断 过程,具体可以是hasOwnProperty,indexOf 或者 != 等操作。
小结一下,具体的检测流程为:
1.遍历函数中的语句,根据语句pattern将变量放入StoreSet和LoadSet
2.提取StoreSet∩LoadSet中的可疑变量
3.对每一个可疑变量,提取变量相关语句
4.判断语句间顺序关系
5.通过反向数据流追踪判断key是否来源于用户输入或单个参数,同时如果存在判断,应该直接返回
新技术领域的背后也有“传统安全战场”。结合人工审计和自研语义分析引擎,我们对IBM开源的低代码物联网编排平台Node-RED进行了白盒审计,发现并协助修复闭环了多处高危安全风险。我们认为,当类似的开源Web组件被引入物联网领域,安全风险并没有随之消失,考虑到使用场景及更新门槛,出现漏洞带来的影响反而可能变大了,因而提出了更高的研发安全要求。
依托自身安全能力,腾讯研发安全团队也将持续帮助业界开源组件提前消灭安全风险。本文中提及的漏洞均已修复,建议Node-RED的用户及时升级规避风险。同时,开源团队可参考DevSecOps理念加强安全建设,详参见TSRC分享的《“安全需要每个工程师的参与”-DevSecOps理念及思考》。
腾讯公司内部与自研业务贴合最紧密的一线安全工程团队之一。团队负责软件生命周期各阶段的安全机制建设,包括:制定安全规范/标准/流程、实施内部安全培训、设计安全编码方案、构建安全漏洞检测(SAST/DAST/IAST)与Web应用防护(WAF)系统等。在持续为QQ、微信、云、游戏等重点业务提供服务外,也将积累十余年的安全经验向外部输出。通过为腾讯云的漏洞扫描、WAF等产品提供底层技术支撑,助力产业互联网客户安全能力升级。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。