diff --git a/.github/BUILD.bazel b/.github/BUILD.bazel index 8b0c07fc1..63d90ac34 100644 --- a/.github/BUILD.bazel +++ b/.github/BUILD.bazel @@ -37,10 +37,28 @@ xcode_version( version = "15.2.0.15C500b", ) +# Xcode version on public GitHub Actions macos-15 runners +xcode_version( + name = "version16_4_0_16F6", + aliases = [ + "16.4.0.16F6", + "16F6", + "16.4.0", + "16.4", + "16", + ], + default_ios_sdk_version = "18.5", + default_macos_sdk_version = "15.5", + default_tvos_sdk_version = "18.5", + default_watchos_sdk_version = "11.5", + version = "16.4.0.16F6", +) + xcode_config( name = "host_xcodes", - default = ":version15_2_0_15C500b", + default = ":version16_4_0_16F6", versions = [ + ":version16_4_0_16F6", ":version15_2_0_15C500b", ":version14_2_0_14C18", ], diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index 4bfca1cf4..d36a8a631 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, macos-14] + os: [ubuntu-22.04, macos-15] jdk: [21, 8] include: - jdk: 21 @@ -28,7 +28,7 @@ jobs: extra_bazel_args: "--jvmopt=-Djava.security.manager=allow" - os: ubuntu-22.04 arch: "linux" - - os: macos-14 + - os: macos-15 arch: "macos-arm64" bazel_args: "--xcode_version_config=//.github:host_xcodes" diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml index f621c0db9..29004c065 100644 --- a/.github/workflows/prerelease.yaml +++ b/.github/workflows/prerelease.yaml @@ -16,7 +16,7 @@ jobs: name: linux-x86-64 - os: ubuntu-22.04-arm name: linux-arm64 - - os: macos-14 + - os: macos-15 name: macos - os: windows-2022 name: windows diff --git a/.github/workflows/run-all-tests-pr.yml b/.github/workflows/run-all-tests-pr.yml index 6720bf5c7..8f8663d09 100644 --- a/.github/workflows/run-all-tests-pr.yml +++ b/.github/workflows/run-all-tests-pr.yml @@ -15,7 +15,7 @@ jobs: name: Build & Test strategy: matrix: - os: [ubuntu-22.04, ubuntu-22.04-arm, windows-2022, macos-14] + os: [ubuntu-22.04, ubuntu-22.04-arm, windows-2022, macos-15] jdk: [21, 8] include: - jdk: 21 @@ -28,7 +28,7 @@ jobs: arch: "linux" - os: ubuntu-22.04-arm arch: "linux-arm64" - - os: macos-14 + - os: macos-15 arch: "macos-arm64" bazel_args: "--xcode_version_config=//.github:host_xcodes" - os: windows-2022 diff --git a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel index 0e90fb0e2..a2c7e746a 100644 --- a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel +++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel @@ -29,7 +29,10 @@ java_test( java_library( name = "coverage_instrumentation_benchmark", - srcs = ["CoverageInstrumentationBenchmark.java"], + srcs = ["CoverageInstrumentationBenchmark.java"] + select({ + ":jdk25": ["FfmCoverageInstrumentationBenchmark.java"], + "//conditions:default": [], + }), plugins = ["//src/jmh/java/com/code_intelligence/jazzer:JmhGeneratorAnnotationProcessor"], runtime_deps = [ "@maven//:com_mikesamuel_json_sanitizer", @@ -39,7 +42,10 @@ java_library( ":strategies", "//src/main/java/com/code_intelligence/jazzer/instrumentor", "@maven//:org_openjdk_jmh_jmh_core", - ], + ] + select({ + ":jdk25": [":ffm_strategies"], + "//conditions:default": [], + }), ) java_library( @@ -47,6 +53,9 @@ java_library( srcs = [ "DirectByteBuffer2CoverageMap.java", "DirectByteBufferCoverageMap.java", + # Uses the FFM API only via method handles, so it compiles with Java 8 source level, but + # only works on JDK 22+. + "FfmMethodHandleCoverageMap.java", "Unsafe2CoverageMap.java", "UnsafeBranchfreeCoverageMap.java", "UnsafeCoverageMap.java", @@ -59,6 +68,30 @@ java_library( ], ) +# The FFM API requires JDK 22+, so the FFM-backed coverage maps are only benchmarked when building +# with remotejdk_25, the only remote JDK >= 22 provided by rules_java (it ships LTS releases only): +# bazel run --java_runtime_version=remotejdk_25 \ +# //src/jmh/java/com/code_intelligence/jazzer/instrumentor:CoverageInstrumentationBenchmark +config_setting( + name = "jdk25", + values = {"java_runtime_version": "remotejdk_25"}, +) + +java_library( + name = "ffm_strategies", + srcs = ["FfmCoverageMap.java"], + javacopts = [ + "-source", + "22", + "-target", + "22", + ], + target_compatible_with = select({ + ":jdk25": [], + "//conditions:default": ["@platforms//:incompatible"], + }), +) + kt_jvm_library( name = "kotlin_strategies", srcs = ["DirectByteBufferStrategy.kt"], diff --git a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java index ac2d797a3..f23480f0e 100644 --- a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java +++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/CoverageInstrumentationBenchmark.java @@ -46,10 +46,12 @@ public class CoverageInstrumentationBenchmark { private static final String TARGET_METHOD = "sanitize"; private static final MethodType TARGET_TYPE = MethodType.methodType(String.class, String.class); - // This is part of the benchmark's state and not a constant to prevent constant folding. - String TARGET_ARG = + static final String SAMPLE_INPUT = "{\"foo\":1123987,\"bar\":[true, false],\"baz\":{\"foo\":\"132รค3\",\"bar\":1.123e-005}}"; + // This is part of the benchmark's state and not a constant to prevent constant folding. + String TARGET_ARG = SAMPLE_INPUT; + MethodHandle uninstrumented_sanitize; MethodHandle local_DirectByteBuffer_NeverZero_sanitize; MethodHandle staticMethod_DirectByteBuffer_NeverZero_sanitize; diff --git a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageInstrumentationBenchmark.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageInstrumentationBenchmark.java new file mode 100644 index 000000000..b68577396 --- /dev/null +++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageInstrumentationBenchmark.java @@ -0,0 +1,58 @@ +/* + * 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.instrumentor; + +import java.lang.invoke.MethodHandle; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; + +/** + * Complements {@link CoverageInstrumentationBenchmark} with coverage maps backed by the Foreign + * Function & Memory API. Since that API requires JDK 22+, this class is only compiled when building + * with {@code --java_runtime_version=remotejdk_25}, see BUILD.bazel. + */ +@State(Scope.Benchmark) +public class FfmCoverageInstrumentationBenchmark { + // This is part of the benchmark's state and not a constant to prevent constant folding. + String TARGET_ARG = CoverageInstrumentationBenchmark.SAMPLE_INPUT; + + MethodHandle staticMethod_Ffm_NeverZero_sanitize; + MethodHandle staticMethod_FfmMethodHandle_NeverZero_sanitize; + + @Setup + public void instrumentWithStrategies() + throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException { + staticMethod_Ffm_NeverZero_sanitize = + CoverageInstrumentationBenchmark.instrumentWithStrategy( + new StaticMethodStrategy(), FfmCoverageMap.class); + staticMethod_FfmMethodHandle_NeverZero_sanitize = + CoverageInstrumentationBenchmark.instrumentWithStrategy( + new StaticMethodStrategy(), FfmMethodHandleCoverageMap.class); + } + + @Benchmark + public String staticMethod_Ffm_NeverZero() throws Throwable { + return (String) staticMethod_Ffm_NeverZero_sanitize.invokeExact(TARGET_ARG); + } + + @Benchmark + public String staticMethod_FfmMethodHandle_NeverZero() throws Throwable { + return (String) staticMethod_FfmMethodHandle_NeverZero_sanitize.invokeExact(TARGET_ARG); + } +} diff --git a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageMap.java new file mode 100644 index 000000000..996c26210 --- /dev/null +++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmCoverageMap.java @@ -0,0 +1,40 @@ +/* + * 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.instrumentor; + +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; + +/** A coverage map backed by the Foreign Function & Memory API (JDK 22+). */ +public final class FfmCoverageMap { + // The current target, JsonSanitizer, uses less than 2048 coverage counters. + private static final long NUM_COUNTERS = 4096; + + // Zero-initialized native memory that is never freed. Its address() could be handed to libFuzzer + // just like the address of the Unsafe-backed map. + private static final MemorySegment counters = Arena.global().allocate(NUM_COUNTERS); + + public static void enlargeIfNeeded(int nextId) { + // Statically sized counters buffer. + } + + public static void recordCoverage(final int id) { + final byte counter = counters.get(ValueLayout.JAVA_BYTE, id); + counters.set(ValueLayout.JAVA_BYTE, id, (byte) (counter == -1 ? 1 : counter + 1)); + } +} diff --git a/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmMethodHandleCoverageMap.java b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmMethodHandleCoverageMap.java new file mode 100644 index 000000000..dfa8c1784 --- /dev/null +++ b/src/jmh/java/com/code_intelligence/jazzer/instrumentor/FfmMethodHandleCoverageMap.java @@ -0,0 +1,89 @@ +/* + * 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.instrumentor; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +/** + * Same as {@link FfmCoverageMap}, but only accesses the FFM API (JDK 22+) via method handles. This + * class therefore compiles with Java 8 source level and could ship in the same jar as the + * Unsafe-backed coverage map, as long as it is only loaded on JDK 22+. + */ +public final class FfmMethodHandleCoverageMap { + // The current target, JsonSanitizer, uses less than 2048 coverage counters. + private static final long NUM_COUNTERS = 4096; + + // MemorySegment#get(ValueLayout.OfByte, long) with the segment and layout bound: (long)byte + private static final MethodHandle GET_BYTE; + // MemorySegment#set(ValueLayout.OfByte, long, byte) with the segment and layout bound: + // (long,byte)void + private static final MethodHandle SET_BYTE; + + static { + try { + Class arena = Class.forName("java.lang.foreign.Arena"); + Class memorySegment = Class.forName("java.lang.foreign.MemorySegment"); + Class ofByte = Class.forName("java.lang.foreign.ValueLayout$OfByte"); + MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + + Object javaByte = + Class.forName("java.lang.foreign.ValueLayout").getField("JAVA_BYTE").get(null); + Object globalArena = + lookup.findStatic(arena, "global", MethodType.methodType(arena)).invoke(); + // Zero-initialized native memory that is never freed. Its address() could be handed to + // libFuzzer just like the address of the Unsafe-backed map. + Object counters = + lookup + .findVirtual(arena, "allocate", MethodType.methodType(memorySegment, long.class)) + .invoke(globalArena, NUM_COUNTERS); + + GET_BYTE = + MethodHandles.insertArguments( + lookup.findVirtual( + memorySegment, "get", MethodType.methodType(byte.class, ofByte, long.class)), + 0, + counters, + javaByte); + SET_BYTE = + MethodHandles.insertArguments( + lookup.findVirtual( + memorySegment, + "set", + MethodType.methodType(void.class, ofByte, long.class, byte.class)), + 0, + counters, + javaByte); + } catch (Throwable t) { + throw new ExceptionInInitializerError(t); + } + } + + public static void enlargeIfNeeded(int nextId) { + // Statically sized counters buffer. + } + + public static void recordCoverage(final int id) { + try { + final byte counter = (byte) GET_BYTE.invokeExact((long) id); + SET_BYTE.invokeExact((long) id, (byte) (counter == -1 ? 1 : counter + 1)); + } catch (Throwable t) { + throw new IllegalStateException("Failed to record coverage", t); + } + } +} diff --git a/src/main/java/com/code_intelligence/jazzer/Jazzer.java b/src/main/java/com/code_intelligence/jazzer/Jazzer.java index 999092a35..1a7b059c4 100644 --- a/src/main/java/com/code_intelligence/jazzer/Jazzer.java +++ b/src/main/java/com/code_intelligence/jazzer/Jazzer.java @@ -128,6 +128,17 @@ private static void start(List args) throws IOException, InterruptedExce Set argsToFilter = Stream.of("--asan", "--ubsan", "--hwasan", "--native").collect(toSet()); ProcessBuilder processBuilder = new ProcessBuilder(); + // The JVM handles these signals itself, e.g. for implicit null checks and safepoint polls. A + // UBSan runtime that is only loaded once the JVM is running, such as the one clang on macOS + // links into every library built with -fsanitize=fuzzer-no-link, would otherwise replace the + // JVM's handlers and turn these signals into crashes. + processBuilder + .environment() + .compute( + "UBSAN_OPTIONS", + (name, currentValue) -> + appendWithPathListSeparator( + name, "handle_segv=0", "handle_sigbus=0", "handle_sigfpe=0")); List preloadLibs = new ArrayList<>(); // We have to load jazzer_preload before we load ASan since the ASan includes no-op definitions // of the fuzzer callbacks as weak symbols, but the dynamic linker doesn't distinguish between diff --git a/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt index 3a44ed1e5..734ab91b0 100644 --- a/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt +++ b/src/main/java/com/code_intelligence/jazzer/agent/RuntimeInstrumentor.kt @@ -20,6 +20,7 @@ import com.code_intelligence.jazzer.instrumentor.ClassInstrumentor import com.code_intelligence.jazzer.instrumentor.CoverageRecorder import com.code_intelligence.jazzer.instrumentor.Hook import com.code_intelligence.jazzer.instrumentor.InstrumentationType +import com.code_intelligence.jazzer.instrumentor.sortMethods import com.code_intelligence.jazzer.utils.ClassNameGlobber import com.code_intelligence.jazzer.utils.Log import io.github.classgraph.ClassGraph @@ -105,7 +106,11 @@ class RuntimeInstrumentor( // Using ClassWriter.COMPUTE_FRAMES as an escape hatch isn't possible in the context of an agent as the // computation may itself need to load classes, which leads to circular loads and incompatible class // redefinitions. - transformInternal(internalClassName, classfileBuffer.takeUnless { loader == null && classBeingRedefined != null }) + transformInternal( + internalClassName, + classfileBuffer.takeUnless { loader == null && classBeingRedefined != null }, + isReconstituted = classBeingRedefined != null, + ) } catch (t: Throwable) { if (dumpClassesDir != null) { dumpToClassFile(internalClassName, classfileBuffer, basenameSuffix = ".failed", pathPrefix = pathPrefix) @@ -180,6 +185,7 @@ class RuntimeInstrumentor( fun transformInternal( internalClassName: String, maybeClassfileBuffer: ByteArray?, + isReconstituted: Boolean = false, ): ByteArray? { val (fullInstrumentation, printInfo) = when { @@ -193,7 +199,7 @@ class RuntimeInstrumentor( } val className = internalClassName.replace('/', '.') val classfileBuffer = - maybeClassfileBuffer ?: ClassGraph() + maybeClassfileBuffer?.let { if (isReconstituted) sortMethods(it) else it } ?: ClassGraph() .enableSystemJarsAndModules() .acceptLibOrExtJars() .ignoreClassVisibility() diff --git a/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt b/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt index 51617ab0f..7e341dee9 100644 --- a/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt +++ b/src/main/java/com/code_intelligence/jazzer/instrumentor/ClassInstrumentor.kt @@ -17,10 +17,30 @@ package com.code_intelligence.jazzer.instrumentor import com.code_intelligence.jazzer.runtime.CoverageMap +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.tree.ClassNode fun extractClassFileMajorVersion(classfileBuffer: ByteArray): Int = ((classfileBuffer[6].toInt() and 0xff) shl 8) or (classfileBuffer[7].toInt() and 0xff) +/** + * Sorts the methods of the given class file by name and descriptor. + * + * Coverage and hook IDs are assigned in method order. The class file the JVM reconstitutes for a + * retransformation lists methods in an internal order that can differ between JVM runs, which would make the + * IDs, and thus fuzzing with a fixed seed, nondeterministic. + */ +fun sortMethods(classfileBuffer: ByteArray): ByteArray { + val reader = ClassReader(classfileBuffer) + val node = ClassNode() + reader.accept(node, 0) + node.methods.sortWith(compareBy({ it.name }, { it.desc })) + val writer = ClassWriter(reader, 0) + node.accept(writer) + return writer.toByteArray() +} + class ClassInstrumentor( private val internalClassName: String, bytecode: ByteArray, diff --git a/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel b/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel index 7d0cf08ac..126e10d1c 100644 --- a/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel +++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/BUILD.bazel @@ -66,6 +66,26 @@ wrapped_kt_jvm_test( ], ) +wrapped_kt_jvm_test( + name = "sort_methods_test", + size = "small", + srcs = [ + "CoverageInstrumentationTarget.java", + "SortMethodsTest.kt", + ], + associates = [ + "//src/main/java/com/code_intelligence/jazzer/instrumentor:instrumentor", + ], + test_class = "com.code_intelligence.jazzer.instrumentor.SortMethodsTest", + deps = [ + ":patch_test_utils", + "@maven//:junit_junit", + "@maven//:org_ow2_asm_asm", + "@maven//:org_ow2_asm_asm_tree", + "@rules_kotlin//kotlin/compiler:kotlin-test", + ], +) + wrapped_kt_jvm_test( name = "hook_validation_test", size = "small", diff --git a/src/test/java/com/code_intelligence/jazzer/instrumentor/SortMethodsTest.kt b/src/test/java/com/code_intelligence/jazzer/instrumentor/SortMethodsTest.kt new file mode 100644 index 000000000..d6ac0560b --- /dev/null +++ b/src/test/java/com/code_intelligence/jazzer/instrumentor/SortMethodsTest.kt @@ -0,0 +1,53 @@ +/* + * 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.instrumentor + +import com.code_intelligence.jazzer.instrumentor.PatchTestUtils.classToBytecode +import org.junit.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.tree.ClassNode +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SortMethodsTest { + private fun readClass(bytecode: ByteArray): ClassNode = ClassNode().also { ClassReader(bytecode).accept(it, 0) } + + private fun methodSignatures(bytecode: ByteArray): List> = readClass(bytecode).methods.map { it.name to it.desc } + + private fun reverseMethods(bytecode: ByteArray): ByteArray { + val reader = ClassReader(bytecode) + val node = ClassNode() + reader.accept(node, 0) + node.methods.reverse() + val writer = ClassWriter(reader, 0) + node.accept(writer) + return writer.toByteArray() + } + + @Test + fun testSortMethodsIsIndependentOfMethodOrder() { + val original = classToBytecode(CoverageInstrumentationTarget::class.java) + val reversed = reverseMethods(original) + assertNotEquals(methodSignatures(original), methodSignatures(reversed)) + + val sorted = sortMethods(original) + assertEquals(methodSignatures(original).sortedWith(compareBy({ it.first }, { it.second })), methodSignatures(sorted)) + assertContentEquals(sorted, sortMethods(reversed)) + } +}