Add guacd parser fuzz harness and document fuzzing findings

libFuzzer+ASan+UBSan harness for guac_parser_append() — the C state
machine that parses all Guacamole wire-format input in guacd. 3.2M
iterations found no memory corruption; one non-exploitable signed
integer overflow (UBSan) in the length prefix accumulator noted in
FINDINGS.md.

Also adds FINDINGS.md for the Rust protocol parser fuzzer documenting
the UTF-8 boundary panic fix from v0.1.3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave Kempe
2026-02-07 14:17:42 +11:00
parent 1922bd9987
commit a2bbce73ee
8 changed files with 4084 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
fuzz_parser
fuzz_parser_afl
fuzz_parser_standalone
corpus/
findings/
crash-*
oom-*
timeout-*
+58
View File
@@ -0,0 +1,58 @@
# guacd Parser Fuzzing Findings
Harness targets `guac_parser_append()` from guacamole-server's libguac —
the core state machine that parses all Guacamole wire-format data received
from clients.
## Setup
- **Fuzzer**: libFuzzer (clang 19) with ASan + UBSan
- **Target function**: `guac_parser_append()` in `src/libguac/parser.c`
- **Dependencies compiled inline**: `parser.c`, `unicode.c` (no autoconf needed)
Build: `./build.sh` (requires clang)
## Results
**Run date**: 2026-02-07
**Iterations**: ~3.2 million (5 minutes)
**Exec/s**: ~10,800
**Coverage**: 138 edges (saturated — parser state machine is compact)
### No crashes or memory errors
ASan found no heap buffer overflows, use-after-free, or other memory
corruption issues in 3.2M iterations across varying chunk sizes.
### Signed integer overflow in length prefix parser (UBSan, non-exploitable)
**Location**: `parser.c:83`
**Severity**: Informational (undefined behavior, not exploitable)
The length prefix accumulator:
```c
parsed_length = parsed_length*10 + c - '0';
```
uses `int` arithmetic and can overflow with a long digit string (e.g.,
`222222222...`) before the subsequent bounds check:
```c
if (parsed_length > GUAC_INSTRUCTION_MAX_LENGTH) {
parser->state = GUAC_PARSE_ERROR;
return 0;
}
```
In practice, the `GUAC_INSTRUCTION_MAX_LENGTH` (8192) check catches
malicious lengths after the overflow wraps, and the parser transitions to
`GUAC_PARSE_ERROR`. The overflow is technically undefined behavior per the
C standard but has no security impact — the parser rejects the input
regardless.
**Upstream**: guacamole-server (Apache). Not filed — low impact, and the
existing bounds check is sufficient in practice.
## Rust protocol parser fuzzing
See `../fuzz/` for the Rust-side protocol parser fuzzer (cargo-fuzz).
That fuzzer found and fixed a UTF-8 char boundary panic in
`src/protocol.rs` — see commit history for v0.1.3.
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# Build the guacd parser fuzzer.
#
# Usage:
# ./build.sh # build with libFuzzer (default)
# ./build.sh afl # build with AFL++
# ./build.sh standalone # build a standalone test binary (no fuzzer)
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
GUAC_SRC="${SCRIPT_DIR}/../../guacamole-server/src/libguac"
if [ ! -f "${GUAC_SRC}/parser.c" ]; then
echo "ERROR: guacamole-server source not found at ${GUAC_SRC}" >&2
echo "Expected: ../guacamole-server relative to rustguac root" >&2
exit 1
fi
MODE="${1:-libfuzzer}"
SANITIZERS="-fsanitize=address,undefined"
# Include our stubs first (-I.) so config.h and guacamole/socket.h
# resolve to our minimal stubs before the real guacamole-server headers.
INCLUDES="-I${SCRIPT_DIR} -I${GUAC_SRC}"
CFLAGS="-g -O1 ${SANITIZERS} ${INCLUDES}"
SOURCES=(
"${SCRIPT_DIR}/fuzz_parser.c"
"${GUAC_SRC}/parser.c"
"${GUAC_SRC}/unicode.c"
)
case "${MODE}" in
libfuzzer)
echo "Building with libFuzzer..."
CC="${CC:-clang}"
$CC ${CFLAGS} -fsanitize=fuzzer "${SOURCES[@]}" -o "${SCRIPT_DIR}/fuzz_parser"
echo "Built: ${SCRIPT_DIR}/fuzz_parser"
echo "Run: ${SCRIPT_DIR}/fuzz_parser ${SCRIPT_DIR}/corpus/"
;;
afl)
echo "Building with AFL++..."
CC="${CC:-afl-clang-fast}"
$CC ${CFLAGS} -DAFL_MODE "${SOURCES[@]}" -o "${SCRIPT_DIR}/fuzz_parser_afl"
echo "Built: ${SCRIPT_DIR}/fuzz_parser_afl"
echo "Run: afl-fuzz -i ${SCRIPT_DIR}/corpus/ -o ${SCRIPT_DIR}/findings/ -- ${SCRIPT_DIR}/fuzz_parser_afl"
;;
standalone)
# Build a standalone binary that reads from stdin — useful for
# reproducing crashes without a fuzzer installed.
echo "Building standalone test binary..."
CC="${CC:-clang}"
cat > /tmp/fuzz_standalone_main.c <<'MAIN_EOF'
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size);
int main(int argc, char **argv) {
for (int i = 1; i < argc; i++) {
FILE *f = fopen(argv[i], "rb");
if (!f) { perror(argv[i]); continue; }
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *buf = malloc(sz);
fread(buf, 1, sz, f);
fclose(f);
printf("Testing %s (%ld bytes)... ", argv[i], sz);
LLVMFuzzerTestOneInput(buf, sz);
printf("OK\n");
free(buf);
}
return 0;
}
MAIN_EOF
$CC ${CFLAGS} /tmp/fuzz_standalone_main.c "${SOURCES[@]}" \
-o "${SCRIPT_DIR}/fuzz_parser_standalone"
rm /tmp/fuzz_standalone_main.c
echo "Built: ${SCRIPT_DIR}/fuzz_parser_standalone"
echo "Run: ${SCRIPT_DIR}/fuzz_parser_standalone corpus/*.raw"
;;
*)
echo "Usage: $0 [libfuzzer|afl|standalone]" >&2
exit 1
;;
esac
+4
View File
@@ -0,0 +1,4 @@
/* Empty config.h stub for fuzzing — no autoconf defines needed */
#ifndef FUZZ_CONFIG_H
#define FUZZ_CONFIG_H
#endif
+163
View File
@@ -0,0 +1,163 @@
/*
* AFL++/libFuzzer harness for guacd's Guacamole protocol parser.
*
* Fuzzes guac_parser_append() — the core state machine that parses
* Guacamole wire format data. This is the function that processes
* all input from rustguac before any protocol handling occurs.
*
* Build with build.sh or manually:
* libFuzzer: clang -fsanitize=fuzzer,address,undefined \
* -I. -I$GUAC/src/libguac \
* -o fuzz_parser fuzz_parser.c $GUAC/src/libguac/unicode.c \
* $GUAC/src/libguac/parser.c
*
* AFL++: afl-clang-fast -fsanitize=address,undefined \
* -I. -I$GUAC/src/libguac \
* -o fuzz_parser fuzz_parser.c $GUAC/src/libguac/unicode.c \
* $GUAC/src/libguac/parser.c
*
* Our config.h and guacamole/socket.h stubs in this directory override
* the real ones (which need autoconf / pull in the full socket API).
*/
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
/* ── Stubs for libguac internals ────────────────────────────────── */
/* guac_error / guac_error_message are macros expanding to
* (*__guac_error()) / (*__guac_error_message()). Provide the backing
* thread-local storage and accessor functions. */
#include "guacamole/error-types.h"
static __thread guac_status _guac_error_value = GUAC_STATUS_SUCCESS;
static __thread const char* _guac_error_message_value = "";
guac_status* __guac_error(void) {
return &_guac_error_value;
}
const char** __guac_error_message(void) {
return &_guac_error_message_value;
}
/* Overflow-checked multiply (PRIV_guac_mem_ckd_mul) */
int PRIV_guac_mem_ckd_mul(size_t* result, size_t factor_count,
const size_t* factors) {
if (factor_count == 0) return 1;
size_t size = factors[0];
for (size_t i = 1; i < factor_count; i++) {
if (factors[i] && size > SIZE_MAX / factors[i]) return 1;
size *= factors[i];
}
*result = size;
return 0;
}
/* guac_mem_alloc — the macro expands to PRIV_guac_mem_alloc() */
void* PRIV_guac_mem_alloc(size_t factor_count, const size_t* factors) {
size_t size;
if (PRIV_guac_mem_ckd_mul(&size, factor_count, factors)) return NULL;
if (size == 0) size = 1;
return malloc(size);
}
/* guac_mem_free */
void PRIV_guac_mem_free(void* ptr) {
free(ptr);
}
/* Stubs for socket functions referenced by guac_parser_read() —
* we don't fuzz that path but the linker needs symbols. */
typedef struct guac_socket guac_socket;
int guac_socket_select(guac_socket* socket, int usec_timeout) {
(void)socket; (void)usec_timeout;
return -1;
}
int guac_socket_read(guac_socket* socket, void* buf, int count) {
(void)socket; (void)buf; (void)count;
return -1;
}
/* ── Pull in the real parser API ─────────────────────────────────
* We include the headers (not the .c files) — the .c files are
* compiled separately and linked by the build script. */
#include "guacamole/parser.h"
/* ── Fuzz target ── */
#ifdef __AFL_FUZZ_TESTCASE_LEN
/* AFL++ persistent mode */
__AFL_FUZZ_INIT();
#endif
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size == 0) return 0;
/* Work on a mutable copy — guac_parser_append() modifies the buffer
* (it writes NUL terminators at element boundaries). */
char *buf = malloc(size);
if (!buf) return 0;
memcpy(buf, data, size);
guac_parser* parser = guac_parser_alloc();
if (!parser) { free(buf); return 0; }
/* Feed the data in varying chunk sizes to exercise boundary handling */
size_t pos = 0;
size_t chunk = 1;
while (pos < size) {
size_t remaining = size - pos;
size_t len = chunk < remaining ? chunk : remaining;
int parsed = guac_parser_append(parser, buf + pos, (int)len);
if (parser->state == GUAC_PARSE_ERROR) {
/* Reset and continue to test recovery */
guac_parser_free(parser);
parser = guac_parser_alloc();
if (!parser) { free(buf); return 0; }
pos += len;
} else if (parser->state == GUAC_PARSE_COMPLETE) {
/* Successfully parsed an instruction — access fields to
* trigger any potential out-of-bounds reads */
volatile const char* op = parser->opcode;
(void)op;
for (int i = 0; i < parser->argc; i++) {
volatile const char* arg = parser->argv[i];
(void)arg;
}
/* Reset for next instruction */
guac_parser_free(parser);
parser = guac_parser_alloc();
if (!parser) { free(buf); return 0; }
pos += len;
} else {
pos += (parsed > 0) ? (size_t)parsed : len;
}
/* Vary chunk size: 1, 2, 4, 8, ..., 64, then back to 1 */
chunk = (chunk >= 64) ? 1 : chunk * 2;
}
guac_parser_free(parser);
free(buf);
return 0;
}
#ifndef __AFL_FUZZ_TESTCASE_LEN
/* When not using AFL, this is a libFuzzer target (no main needed) */
#else
/* AFL persistent mode main */
int main(void) {
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(100000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
LLVMFuzzerTestOneInput(buf, len);
}
return 0;
}
#endif
+10
View File
@@ -0,0 +1,10 @@
/*
* Minimal socket.h stub for fuzzing — only the forward typedef is needed.
* parser.h includes socket-types.h which defines guac_socket, but parser.c
* also includes this full header. We stub it to avoid pulling in client-types.h
* and the rest of the socket API.
*/
#ifndef _GUAC_SOCKET_H
#define _GUAC_SOCKET_H
#include "socket-types.h"
#endif
+3716
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# Rust Protocol Parser Fuzzing Findings
Fuzz targets for `src/protocol.rs` — rustguac's Guacamole wire-format
parser and streaming instruction parser.
## Setup
- **Fuzzer**: cargo-fuzz (libFuzzer) with nightly Rust
- **Targets**: `protocol_parse` (single instruction), `protocol_stream` (streaming)
Run: `cargo +nightly fuzz run protocol_parse` / `cargo +nightly fuzz run protocol_stream`
## Results
**Run date**: 2026-02-07
**Iterations**: ~52.8 million total (49.5M parse + 3.3M stream, 5 min each)
### Fixed: UTF-8 char boundary panic
**Crash input**: `4.smze,1.\xc7\xbb\x00`
**Severity**: Denial of service (panic/unwrap)
`Instruction::parse()` sliced a string at a byte offset derived from
the length prefix without checking `is_char_boundary()`. When the
length pointed into the middle of a multi-byte UTF-8 character (e.g.,
the 2-byte sequence `\xc7\xbb`), Rust panicked.
**Fix**: Added `remaining.is_char_boundary(len)` check before slicing,
returning `ParseError::Truncated` for invalid boundaries. Fixed in v0.1.3.
### No other issues
After the fix, both fuzz targets ran 52.8M iterations with zero
additional crashes or panics.
## guacd parser fuzzing
See `../fuzz-guacd/` for the C-side guacd protocol parser fuzzer.