Summary

November 10, 2025 ยท View on GitHub

Usage

Fuzzing targets

In general, luzer has an ability to write fuzzing tests for Lua functions. However, steps may depend on the implementation of the function under test. Consider three cases:

  • Fuzzing a Lua function implemented in Lua
  • Fuzzing a Lua function implemented in Lua C
  • Fuzzing a shared library via FFI

Fuzzing a module written in Lua

Let's create a fuzzing test for a parser of Lua source code used in the luacheck module.

Set up a target module using luarocks:

$ luarocks install --local luacheck

Create a file luacheck_parser_parse.lua with fuzzing target:

local parser = require("src.luacheck.parser")
local decoder = require("luacheck.decoder")
local luzer = require("luzer")

local function TestOneInput(buf)
    parser.parse(decoder.decode(buf))
end

luzer.Fuzz(TestOneInput, nil, {})

Execute test with PUC Rio Lua:

$ lua luacheck_parser_parse.lua

Fuzzing a function implemented in Lua C

Lua functions could be implemented using the so-called Lua C API. Functions built in the Lua runtime, external modules written in C/C++ are such examples. Learn more about the Lua C API in chapter "24 โ€“ An Overview of the C API" of the "Programming in Lua" book.

Set up the module using luarocks:

CFLAGS="-ggdb -fPIC -fsanitize=address -fsanitize=fuzzer-no-link"
LDFLAGS="-fsanitize=address"
$ luarocks install --local --lua-version 5.1 lua-cjson CC="clang" CFLAGS=$CFLAGS LDFLAGS=$LDFLAGS

Installing https://luarocks.org/lua-cjson-2.1.0.6-1.src.rock

lua-cjson 2.1.0.6-1 depends on lua >= 5.1 (5.1-1 provided by VM)
clang -ggdb -fPIC -fsanitize=address -fsanitize=fuzzer-no-link -I/usr/include/lua5.1 -c lua_cjson.c -o lua_cjson.o
clang -ggdb -fPIC -fsanitize=address -fsanitize=fuzzer-no-link -I/usr/include/lua5.1 -c strbuf.c -o strbuf.o
clang -ggdb -fPIC -fsanitize=address -fsanitize=fuzzer-no-link -I/usr/include/lua5.1 -c fpconv.c -o fpconv.o
gcc -shared -o cjson.so lua_cjson.o strbuf.o fpconv.o
No existing manifest. Attempting to rebuild...
lua-cjson 2.1.0.6-1 is now installed in /home/sergeyb/sources/luzer/build/modules (license: MIT)

Create a file luzer_example_json.lua with a fuzzing target:

local luzer = require("luzer")
local json = require("cjson")

local function TestOneInput(buf)
    local text, err = pcall(json.decode, buf)
    if not err then
        local encoded = json.encode(text)
        assert(encoded == buf)
    end
end

luzer.Fuzz(TestOneInput)

Set up the environment and execute the test:

$ eval $(luarocks path)
$ luajit luzer_example_json.lua

Fuzzing a shared library via FFI

Lua has an FFI library that allows seamless integration with C/C++ libraries. LuaJIT has a built-in FFI library that allows calling external C functions and using C data structures from pure Lua code. The FFI library allows using luzer for fuzzing shared libraries.

Example examples/example_zlib.lua demonstrates a test for the ZLib library using FFI. For better results it is recommended to build ZLib with sanitizers.

Build Zlib library:

git clone https://github.com/madler/zlib
cd zlib
CC=clang CFLAGS="-fsanitize=address -fsanitize=fuzzer-no-link" LDFLAGS="-fsanitize=address" cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build/ --parallel

Run fuzzing target:

LD_DYNAMIC_WEAK=1 LD_PRELOAD_PATH="./zlib/build/" luajit examples/example_ffi_zlib.lua

Visualizing Code Coverage

Examining which lines are executed is helpful for understanding the effectiveness of your fuzzer. luzer is compatible with LuaCov: you can run your fuzzer using the LuaCov module as you would for any other Lua script. Here's an example:

mkdir corpus_dir
lua examples/example_basic.lua -runs=1000 corpus_dir
luarocks install --local https://raw.githubusercontent.com/lunarmodules/luacov/refs/heads/master/luacov-scm-1.rockspec
eval $(luarocks path)
lua -lluacov examples/example_basic.lua -runs=1 corpus_dir/
luacov examples/example_basic.lua luacov.stats.out
cat luacov.report.out
==============================================================================
examples/example_basic.lua
==============================================================================
 2 local luzer = require("luzer")

   local function TestOneInput(buf)
*0     local fdp = luzer.FuzzedDataProvider(buf)
*0     local str = fdp:consume_string(4)

*0     local b = {}
*0     str:gsub(".", function(c) table.insert(b, c) end)
*0     local count = 0
*0     if b[1] == "o" then count = count + 1 end
*0     if b[2] == "o" then count = count + 1 end
*0     if b[3] == "p" then count = count + 1 end
*0     if b[4] == "s" then count = count + 1 end

*0     if count == 4 then assert(nil) end
   end

 2 local args = {
 2     only_ascii = 1,
 2     print_pcs = 1,
   }
 2 luzer.Fuzz(TestOneInput, nil, args)

==============================================================================
Summary
==============================================================================

File                       Hits Missed Coverage
-----------------------------------------------
examples/example_basic.lua 5    10     33.33%
-----------------------------------------------
Total                      5    10     33.33%

Beware, code coverage reports are only generated when the option -runs=1 and a path to a non-empty directory with a corpus are specified. The option -runs=1 is required because of the Lua debug hook needed by LuaCov is also used for code instrumentation. In Lua only one hook can be enabled at the same time, so we disable our own hook for instrumentation when -runs=1 is specified to allow LuaCov work. The message "Lua debug hook is disabled" is printed when the hook for Lua code instrumentation is disabled:

$ lua -lluacov examples/example_basic.lua -runs=1 corpus_dir/
INFO: Lua debug hook is disabled.
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1369707749
INFO: Loaded 1 modules   (77 inline 8-bit counters): 77 [0x7ff92b371933, 0x7ff92b371980),
INFO: Loaded 1 PC tables (77 PCs): 77 [0x7ff92b371980,0x7ff92b371e50),
INFO:       10 files found in corpus_dir/
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 10 min: 1b max: 8b total: 29b rss: 44Mb
#11     INITED cov: 24 ft: 56 corp: 9/27b exec/s: 0 rss: 44Mb
#11     DONE   cov: 24 ft: 56 corp: 9/27b lim: 8 exec/s: 0 rss: 44Mb
Done 11 runs in 0 second(s)

LuaJIT Metrics

luzer has support of LuaJIT metrics, at the end of the test luzer will print LuaJIT metrics: total number of recorded, aborted, exited traces and a number of parsed functions.

An example of output:

ctest -R luzer_luajit_friendly_test -V
<snipped>
Done 100 runs in 0 second(s)
Total number of recorded traces: 12
Total number of aborted traces: 1
Total number of exited traces: 19
Total number of parsed functions: 400

LuaJIT metrics are accounted using LuaJIT hooks that are also used by internal LuaJIT tools (see src/jit), so custom hooks break these tools. By default, LuaJIT metrics are turned on and can be disabled by setting the enviroment variable DISABLE_LUAJIT_METRICS. CMake options ENABLE_LUAJIT and LUAJIT_FRIENDLY_MODE must be enabled in the build for using metrics.

Environment Variables

This section discusses the environment variables used by luzer to expose various functionalities useful for power users or for some types of custom fuzzing setups.

DISABLE_LUAJIT_METRICS, by default, LuaJIT metrics are turned on and can be disabled by setting the enviroment variable DISABLE_LUAJIT_METRICS. Learn more about the enviroment variable in the section LuaJIT Metrics.