Skip to content

Commit ad962f9

Browse files
authored
Handle real segfaults in fuzz targets (#602)
This adds handling for real segfaults in native code in our fuzz targets. Unfortunately, the only way to do that effectively was to have the C++ code in the fuzzer detect the error, print a message, write the crash file, and quit rather than returning control to JS so the result isn't as nice as a normal error but should be more informative than before.
1 parent eebee61 commit ad962f9

File tree

16 files changed

+287
-24
lines changed

16 files changed

+287
-24
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ node_modules/
5353
*.tgz
5454

5555
# Dictionaries generated by Jazzer.js
56-
.JazzerJs-merged-dictionaries
56+
.JazzerJs-merged-dictionaries

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"packages/*"
6161
],
6262
"lint-staged": {
63-
"**/*": "prettier --write --ignore-unknown --allow-empty --loglevel debug"
63+
"**/!(compile_commands.json)*": "prettier --write --ignore-unknown --allow-empty --log-level debug"
6464
},
6565
"engines": {
6666
"node": ">= 14.0.0",

packages/fuzzer/CMakeLists.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
154154
${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH} -Wl,-no-whole-archive)
155155
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
156156
target_link_libraries(
157-
${PROJECT_NAME} -Wl,-all_load ${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH}
158-
-Wl,-noall_load)
157+
${PROJECT_NAME} -Wl,-all_load ${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH})
159158
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
160159
# Force MSVC to do an MT build, suggested by cmake-js
161160
cmake_policy(SET CMP0091 NEW)

packages/fuzzer/fuzzing_async.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
// limitations under the License.
1414

1515
#include "napi.h"
16+
#include <csetjmp>
17+
#include <csignal>
1618
#include <cstdlib>
1719
#include <future>
1820
#include <iostream>
@@ -66,6 +68,15 @@ using FinalizerDataType = void;
6668

6769
TSFN gTSFN;
6870

71+
const std::string SEGFAULT_ERROR_MESSAGE =
72+
"Segmentation fault found in fuzz target";
73+
74+
std::jmp_buf errorBuffer;
75+
76+
// See comment on `ErrorSignalHandler` in `fuzzing_sync.cpp` for what this is
77+
// for
78+
void ErrorSignalHandler(int signum) { std::longjmp(errorBuffer, signum); }
79+
6980
// The libFuzzer callback when fuzzing asynchronously.
7081
int FuzzCallbackAsync(const uint8_t *Data, size_t Size) {
7182
std::promise<void *> promise;
@@ -107,6 +118,14 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function jsFuzzCallback,
107118
// thread and continue with the next invocation.
108119

109120
try {
121+
// Return point for the segfault error handler
122+
// This MUST BE called from the thread that executes the fuzz target (and
123+
// thus is the thread with the segfault) otherwise longjmp's behavior is
124+
// undefined
125+
if (setjmp(errorBuffer) != 0) {
126+
std::cerr << SEGFAULT_ERROR_MESSAGE << std::endl;
127+
exit(EXIT_FAILURE);
128+
}
110129
if (env != nullptr) {
111130
auto buffer = Napi::Buffer<uint8_t>::Copy(env, data->data, data->size);
112131

@@ -288,6 +307,7 @@ Napi::Value StartFuzzingAsync(const Napi::CallbackInfo &info) {
288307
context->native_thread = std::thread(
289308
[](std::vector<std::string> fuzzer_args, AsyncFuzzTargetContext *ctx) {
290309
try {
310+
signal(SIGSEGV, ErrorSignalHandler);
291311
StartLibFuzzer(fuzzer_args, FuzzCallbackAsync);
292312
} catch (const JSException &exception) {
293313
}

packages/fuzzer/fuzzing_sync.cpp

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,16 @@
1515
#include "fuzzing_sync.h"
1616
#include "shared/libfuzzer.h"
1717
#include "utils.h"
18+
#include <csetjmp>
1819
#include <csignal>
1920
#include <cstdlib>
21+
#include <iostream>
2022
#include <optional>
2123

2224
namespace {
25+
const std::string SEGFAULT_ERROR_MESSAGE =
26+
"Segmentation fault found in fuzz target";
27+
2328
// Information about a JS fuzz target.
2429
struct FuzzTargetInfo {
2530
Napi::Env env;
@@ -36,10 +41,24 @@ std::optional<FuzzTargetInfo> gFuzzTarget;
3641
// This is only necessary in the sync fuzzing case, as async can be handled
3742
// much nicer directly in JavaScript.
3843
volatile std::sig_atomic_t gSignalStatus;
44+
std::jmp_buf errorBuffer;
3945
} // namespace
4046

4147
void sigintHandler(int signum) { gSignalStatus = signum; }
4248

49+
// This handles signals that indicate an unrecoverable error (currently only
50+
// segfaults). Our handling of segfaults is odd because it avoids using our
51+
// Javascript method to print and instead prints a message within C++ and exits
52+
// almost immediately. This is because Node seems to really not like being
53+
// called back into after `longjmp` jumps outside the scope Node thinks it
54+
// should be in and so things in JS-land get pretty broken. However, catching it
55+
// here, printing an ok error message, and letting libfuzzer make the crash file
56+
// is good enough
57+
void ErrorSignalHandler(int signum) {
58+
gSignalStatus = signum;
59+
std::longjmp(errorBuffer, signum);
60+
}
61+
4362
// The libFuzzer callback when fuzzing synchronously
4463
int FuzzCallbackSync(const uint8_t *Data, size_t Size) {
4564
// Create a new active scope so that handles for the buffer objects created in
@@ -62,15 +81,24 @@ int FuzzCallbackSync(const uint8_t *Data, size_t Size) {
6281
// nice for efficiency if we could use a pointer instead of copying.
6382
//
6483
auto data = Napi::Buffer<uint8_t>::Copy(gFuzzTarget->env, Data, Size);
65-
auto result = gFuzzTarget->target.Call({data});
66-
67-
if (result.IsPromise()) {
68-
AsyncReturnsHandler();
69-
} else {
70-
SyncReturnsHandler();
84+
if (setjmp(errorBuffer) == 0) {
85+
auto result = gFuzzTarget->target.Call({data});
86+
if (result.IsPromise()) {
87+
AsyncReturnsHandler();
88+
} else {
89+
SyncReturnsHandler();
90+
}
7191
}
7292

7393
if (gSignalStatus != 0) {
94+
// if we caught a segfault, print the error message and die, letting
95+
// libfuzzer print the crash file. See the comment on `ErrorSignalHandler`
96+
// for why
97+
if (gSignalStatus == SIGSEGV) {
98+
std::cerr << SEGFAULT_ERROR_MESSAGE << std::endl;
99+
exit(EXIT_FAILURE);
100+
}
101+
74102
// Non-zero exit codes will produce crash files.
75103
auto exitCode = Napi::Number::New(gFuzzTarget->env, 0);
76104

@@ -111,7 +139,7 @@ void StartFuzzing(const Napi::CallbackInfo &info) {
111139
info[2].As<Napi::Function>()};
112140

113141
signal(SIGINT, sigintHandler);
114-
signal(SIGSEGV, sigintHandler);
142+
signal(SIGSEGV, ErrorSignalHandler);
115143

116144
StartLibFuzzer(fuzzer_args, FuzzCallbackSync);
117145
// Explicitly reset the global function pointer because the JS

tests/signal_handlers/SIGSEGV/fuzz.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@
1414
* limitations under the License.
1515
*/
1616

17+
const native = require("native-signal");
18+
19+
const RUN_ON_ITERATION = 1000;
20+
1721
let i = 0;
1822

1923
module.exports.SIGSEGV_SYNC = (data) => {
20-
if (i === 1000) {
24+
if (i === RUN_ON_ITERATION) {
2125
console.log("kill with signal");
2226
process.kill(process.pid, "SIGSEGV");
2327
}
24-
if (i > 1000) {
28+
if (i > RUN_ON_ITERATION) {
2529
console.log("Signal has not stopped the fuzzing process");
2630
}
2731
i++;
@@ -30,9 +34,26 @@ module.exports.SIGSEGV_SYNC = (data) => {
3034
module.exports.SIGSEGV_ASYNC = (data) => {
3135
// Raising SIGSEGV in async mode does not stop the fuzzer directly,
3236
// as the event is handled asynchronously in the event loop.
33-
if (i === 1000) {
37+
if (i === RUN_ON_ITERATION) {
3438
console.log("kill with signal");
3539
process.kill(process.pid, "SIGSEGV");
3640
}
3741
i++;
3842
};
43+
44+
module.exports.NATIVE_SIGSEGV_SYNC = (data) => {
45+
if (i === RUN_ON_ITERATION) {
46+
native.sigsegv(0);
47+
}
48+
if (i > RUN_ON_ITERATION) {
49+
console.log("Signal has not stopped the fuzzing process");
50+
}
51+
i++;
52+
};
53+
54+
module.exports.NATIVE_SIGSEGV_ASYNC = async (data) => {
55+
if (i === RUN_ON_ITERATION) {
56+
native.sigsegv(0);
57+
}
58+
i++;
59+
};

tests/signal_handlers/SIGSEGV/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"fuzz": "JAZZER_FUZZ=1 jest"
88
},
99
"devDependencies": {
10-
"@jazzer.js/jest-runner": "file:../../packages/jest-runner"
10+
"@jazzer.js/jest-runner": "file:../../packages/jest-runner",
11+
"native-signal": "file:../native-signal"
1112
},
1213
"jest": {
1314
"projects": [

tests/signal_handlers/SIGSEGV/tests.fuzz.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@
1414
* limitations under the License.
1515
*/
1616

17-
const { SIGSEGV_ASYNC, SIGSEGV_SYNC } = require("./fuzz.js");
17+
const {
18+
SIGSEGV_ASYNC,
19+
SIGSEGV_SYNC,
20+
NATIVE_SIGSEGV_SYNC,
21+
NATIVE_SIGSEGV_ASYNC,
22+
} = require("./fuzz.js");
1823

1924
describe("Jest", () => {
2025
it.fuzz("Sync", SIGSEGV_SYNC);
2126
it.fuzz("Async", SIGSEGV_ASYNC);
27+
it.fuzz("Native", NATIVE_SIGSEGV_SYNC);
28+
it.fuzz("Native Async", NATIVE_SIGSEGV_ASYNC);
2229
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
cmake_minimum_required(VERSION 3.15)
2+
cmake_policy(SET CMP0091 NEW)
3+
cmake_policy(SET CMP0042 NEW)
4+
5+
project(signal_impl)
6+
7+
set(CMAKE_CXX_STANDARD 17) # mostly supported since GCC 7
8+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
9+
set(LLVM_ENABLE_LLD TRUE)
10+
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
11+
12+
# Avoid warning about DOWNLOAD_EXTRACT_TIMESTAMP in CMake 3.24:
13+
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0")
14+
cmake_policy(SET CMP0135 NEW)
15+
endif()
16+
17+
# To help with development, let's write compile_commands.json unconditionally.
18+
set(CMAKE_EXPORT_COMPILE_COMMANDS 1)
19+
20+
include_directories(${CMAKE_JS_INC})
21+
22+
file(GLOB SOURCE_FILES "*.cpp")
23+
24+
add_library(${PROJECT_NAME} SHARED ${SOURCE_FILES} ${CMAKE_JS_SRC})
25+
set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node")
26+
target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB})
27+
28+
if(MSVC AND CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET)
29+
# Generate node.lib
30+
execute_process(COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS})
31+
endif()
32+
33+
# Enable the functionality of Node-API version 4 and disable everything added
34+
# later, so that we don't accidentally break compatibility with older versions
35+
# of Node (see https://nodejs.org/api/n-api.html#node-api-version-matrix).
36+
#
37+
# Note that prebuild recommends in its README to use ${napi_build_version} here,
38+
# but the variable is only set when cmake-js is invoked via prebuild (in which
39+
# case the API version is taken from "binary.napi_versions" in package.json).
40+
# Since we want the build to work in other cases as well, let's just use a
41+
# constant. (There is currently no point in a dynamic setting anyway since we
42+
# specify the oldest version that we're compatible with, and Node-API's ABI
43+
# stability guarantees that this version is available in all future Node-API
44+
# releases.)
45+
add_definitions(-DNAPI_VERSION=4)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
build/compile_commands.json

0 commit comments

Comments
 (0)