From bed2d889e0b2babe019b59a538441b71c362c1e3 Mon Sep 17 00:00:00 2001 From: Ilshat Sultanov Date: Fri, 4 Sep 2026 21:41:42 +0300 Subject: [PATCH 1/2] fix: handle null root in ClojureLangHooks.clojureMarkContains The AFTER hook on clojure.lang.Var.getRawRoot dereferenced the result without a null check, throwing a NullPointerException for non-dynamic vars with a nil root (e.g. riddley 0.2.2) and aborting fuzzing during target class loading. Skip the contains check when the result is null and add a regression test. --- .../jazzer/sanitizers/BUILD.bazel | 4 ++ .../jazzer/sanitizers/ClojureLangHooks.java | 2 +- .../jazzer/sanitizers/BUILD.bazel | 10 ++++ .../sanitizers/ClojureLangHooksTest.java | 49 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooksTest.java diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel index 59dcf55b9..9d1c394e4 100644 --- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel @@ -6,6 +6,10 @@ load("//sanitizers:sanitizers.bzl", "SANITIZER_CLASSES") java_library( name = "clojure_lang_hooks", srcs = ["ClojureLangHooks.java"], + visibility = [ + "//sanitizers:__pkg__", + "//sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers:__pkg__", + ], deps = ["//src/main/java/com/code_intelligence/jazzer/api:hooks"], ) diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooks.java b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooks.java index bd56117c0..755d7c16a 100644 --- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooks.java +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooks.java @@ -54,7 +54,7 @@ public final class ClojureLangHooks { targetMethod = "getRawRoot") public static void clojureMarkContains( MethodHandle method, Object thisObject, Object[] arguments, int hookId, Object result) { - if (stringContainsFuncNames.contains(result.getClass().getCanonicalName())) { + if (result != null && stringContainsFuncNames.contains(result.getClass().getCanonicalName())) { stringContainsFuncs.get().add(result); } } diff --git a/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel b/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel index 3de4f58a0..111063f1a 100644 --- a/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel +++ b/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel @@ -1,5 +1,15 @@ load("@contrib_rules_jvm//java:defs.bzl", "JUNIT5_DEPS", "java_junit5_test") +java_junit5_test( + name = "ClojureLangHooksTest", + srcs = ["ClojureLangHooksTest.java"], + deps = JUNIT5_DEPS + [ + "//sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers:clojure_lang_hooks", + "@clojure_jar//jar", + "@maven//:org_junit_jupiter_junit_jupiter_api", + ], +) + java_junit5_test( name = "FilePathTraversalTest", srcs = ["FilePathTraversalTest.java"], diff --git a/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooksTest.java b/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooksTest.java new file mode 100644 index 000000000..1be17c66c --- /dev/null +++ b/sanitizers/src/test/java/com/code_intelligence/jazzer/sanitizers/ClojureLangHooksTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.code_intelligence.jazzer.sanitizers; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * Regression tests for the {@code clojure.lang.Var#getRawRoot} AFTER hook. A non-dynamic Clojure + * var can have a {@code null} root, e.g. {@code (def ^:private bb? (System/getProperty ...))} in + * riddley 0.2.2 on the JVM, which previously caused a NullPointerException in the hook. + */ +public class ClojureLangHooksTest { + @Test + void clojureMarkContainsDoesNotThrowOnNullResult() { + assertDoesNotThrow(() -> ClojureLangHooks.clojureMarkContains(null, null, null, 0, null)); + } + + @Test + void clojureMarkContainsIgnoresNonStringContainsFunctions() { + Object func = new Object(); + ClojureLangHooks.clojureMarkContains(null, null, null, 0, func); + assertFalse(ClojureLangHooks.stringContainsFuncs.get().contains(func)); + } + + @Test + void clojureMarkContainsTracksStringContainsFunctions() throws Exception { + Object func = + Class.forName("clojure.string$includes_QMARK_").getDeclaredConstructor().newInstance(); + ClojureLangHooks.clojureMarkContains(null, null, null, 0, func); + assertTrue(ClojureLangHooks.stringContainsFuncs.get().contains(func)); + } +} From 161505941af9d1ac4f1c10adcacf3f7676ec173d Mon Sep 17 00:00:00 2001 From: Ilshat Sultanov Date: Fri, 4 Sep 2026 21:45:01 +0300 Subject: [PATCH 2/2] fix: skip traceGenericCmp for keys of different runtime classes mapHookInternal and setHookInternal find bracketing keys/elements via compareTo, which can succeed across incompatible Number implementations (e.g. clojure.lang.Ratio vs. java.lang.Double). The unboxing in traceGenericCmp requires both operands to have the same runtime class and otherwise throws a ClassCastException. Only trace comparisons when the bracketing key/element has the same class as the lookup key and add regression tests. --- .../jazzer/runtime/TraceCmpHooks.java | 15 ++-- .../jazzer/runtime/TraceCmpHooksTest.java | 78 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java b/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java index 43f513fa9..6a446ae17 100644 --- a/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java +++ b/src/main/java/com/code_intelligence/jazzer/runtime/TraceCmpHooks.java @@ -1025,11 +1025,15 @@ private static void mapHookInternal(Map map, K currentKey, int hook // map was modified by another thread, skip this invocation return; } - // Modify the hook ID so that compares against distinct valid keys are traced separately. - if (lowerBoundKey != null) { + // traceGenericCmp requires both operands to have the same runtime class. + // Bracketing keys are found via compareTo, which may succeed across incompatible Number + // implementations + // (e.g. clojure.lang.Ratio vs. java.lang.Double) and would make the unboxing in traceGenericCmp + // throw a ClassCastException. + if (lowerBoundKey != null && lowerBoundKey.getClass() == currentKey.getClass()) { TraceDataFlowNativeCallbacks.traceGenericCmp(currentKey, lowerBoundKey, hookId); } - if (upperBoundKey != null) { + if (upperBoundKey != null && upperBoundKey.getClass() == currentKey.getClass()) { TraceDataFlowNativeCallbacks.traceGenericCmp(currentKey, upperBoundKey, 31 * hookId + 11); } } @@ -1063,10 +1067,11 @@ private static void setHookInternal(Set set, E currentElement, int hookId return; } - if (lowerBoundElement != null) { + // See the comment in mapHookInternal on why the element classes have to match. + if (lowerBoundElement != null && lowerBoundElement.getClass() == currentElement.getClass()) { TraceDataFlowNativeCallbacks.traceGenericCmp(currentElement, lowerBoundElement, hookId); } - if (upperBoundElement != null) { + if (upperBoundElement != null && upperBoundElement.getClass() == currentElement.getClass()) { TraceDataFlowNativeCallbacks.traceGenericCmp( currentElement, upperBoundElement, 31 * hookId + 11); } diff --git a/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java b/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java index 521097b41..0c71d8008 100644 --- a/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java +++ b/src/test/java/com/code_intelligence/jazzer/runtime/TraceCmpHooksTest.java @@ -19,7 +19,10 @@ import static org.junit.Assert.assertEquals; import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -64,6 +67,81 @@ public void handlesNullValuesInArrayCompare() { TraceCmpHooks.arraysCompare(null, null, new Object[] {b1, b2}, 1, 1); } + /** + * Map/set hooks compare the lookup key against bracketing elements via {@code + * TraceDataFlowNativeCallbacks.traceGenericCmp}, which assumes that both operands have the same + * runtime class. Comparisons may succeed across incompatible {@link Number} implementations + * without throwing, e.g. {@code clojure.lang.Ratio.compareTo} accepts any {@code Number}. Such a + * heterogeneous match previously caused a {@link ClassCastException} in the unboxing casts of + * {@code traceGenericCmp}; this happens, for instance, when the Clojure compiler registers {@code + * Double} constants under the Jazzer agent. The hooks must simply skip the guidance in that case. + */ + @Test + public void mapGetShouldTolerateHeterogeneousComparableKeys() { + Map map = new HashMap<>(); + map.put(new LenientNumber(2), "two"); + map.put(new LenientNumber(4), "four"); + // A Double sorts between the two LenientNumber keys, so lower and upper bracketing keys exist + // and are found via compareTo despite the different classes. + TraceCmpHooks.mapGet(null, map, new Object[] {3.1d}, 1, null); + } + + @Test + public void setContainsShouldTolerateHeterogeneousComparableElements() { + Set set = new HashSet<>(); + set.add(new LenientNumber(2)); + set.add(new LenientNumber(4)); + TraceCmpHooks.setContains(null, set, new Object[] {3.1d}, 1, false); + TraceCmpHooks.setRemove(null, set, new Object[] {3.1d}, 1, false); + } + + @Test + public void containsKeyShouldTolerateHeterogeneousComparableKeys() { + // Mirror the actual Clojure compiler call site, which uses an IdentityHashMap. + Map map = new IdentityHashMap<>(); + map.put(new LenientNumber(2), 1); + map.put(new LenientNumber(4), 2); + TraceCmpHooks.containsKey(null, map, new Object[] {3.1d}, 1, false); + } + + /** + * A {@link Number} whose {@code compareTo} accepts any other {@code Number}, like {@code + * clojure.lang.Ratio}. + */ + @SuppressWarnings("ComparableType") + private static final class LenientNumber extends Number implements Comparable { + private final long value; + + LenientNumber(long value) { + this.value = value; + } + + @Override + public int compareTo(Number other) { + return Long.compare(value, other.longValue()); + } + + @Override + public int intValue() { + return (int) value; + } + + @Override + public long longValue() { + return value; + } + + @Override + public float floatValue() { + return value; + } + + @Override + public double doubleValue() { + return value; + } + } + @Test public void traceCmpDoubleWrapperShouldMatchDcmpSemantics() { assertEquals(0, invokeTraceCmpDoubleWrapper(-0.0d, +0.0d, /* nanResult= */ -1));