Close the forged-pointer holes in generated Kotlin - #404
Conversation
`KeyExpr(0xdeadbeef).close()` compiled from safe consumer Kotlin and reached native code with a pointer the consumer made up. Every raw-pointer entry point now takes the strongest guard it can bear: * **Handle constructors are `internal`** — `NativeHandle`, `GcNativeHandle`, and every generated ptr class. A hard error with no escape hatch, including via a subclass. Costs nothing: handles are built Kotlin-side (Rust never `NewObject`s one) and every generated call site is in the same module, so no call site changed. * **`object JNINative` is `internal`** — already was. * **`NativeHandle.peek()` and the `fromParts` factories are marked `@UnsafeNativeApi`**, a generated `@RequiresOptIn(level = ERROR)` annotation class emitted in the base package. Rust reaches both by JNI reflection (`call_method` / `call_static_method`), so `internal` would mangle the name out from under the lookup; `@RequiresOptIn` is source-level only and leaves the bytecode signature alone. Generated files carry `@file:OptIn(<pkg>.UnsafeNativeApi::class)` — generated code is the trusted producer of these pointers. Consumers get no such blanket and must opt in per declaration, which is the point. With no base package configured the marker would land in the root package, which Kotlin cannot import from a subpackage, so it is not emitted at all; the `internal` constructors still are. Fixes #37
5694fe9 to
5940116
Compare
`KtClass::ctor_vis` and `KtFile::file_annotation` are not on crates.io yet (milyin/kotlin-codegen#23), so every CI job failed to resolve `kotlin-codegen = "^0.3.0"`. Point the workspace dependency at the branch, keeping the `version` key beside `git` — that is what a published prebindgen would carry, and what lets `cargo package` see a version at all. `cargo package` still fails on it: it drops the git source and resolves the version against crates.io. That is the same "unpublished dependency" case the package job already tolerates for the sibling crates, so widen that grep by one name rather than teach it a second shape. Revert this commit once 0.3.0 is out. The branch is deleted on merge, so CI will say so if nobody does.
|
CI is green — all six jobs. Every failure was one cause: kotlin-codegen = { version = "0.3.0", git = "https://github.com/milyin/kotlin-codegen.git", branch = "ctor-vis-and-file-annotations" }That fixed five of six. f8ed3b9 is meant to be reverted once kotlin-codegen 0.3.0 is released — it is a separate commit for exactly that reason, and the branch it points at disappears on merge, so CI will complain if nobody does.
|
milyin
left a comment
There was a problem hiding this comment.
Found two blocking gaps in the raw-pointer boundary on f8ed3b9:
-
The supported default configuration still emits unguarded raw-pointer functions. JniGenBuilder::new() explicitly defaults to an empty base package (builder.rs:181-193), unsafe_marker_fqn() then returns None (builder.rs:259-263), and mark_unsafe() becomes a no-op (kotlin_emit.rs:178-186). Consequently public peek() and every public fromParts remain callable from ordinary safe Kotlin without any opt-in. This leaves #37 reproducible for the default/root-package configuration. Please either emit a usable marker for every generated package (including root), or reject an empty base package whenever a raw-pointer entry point would be generated.
-
internal and RequiresOptIn are Kotlin-source controls, not a JVM/Java boundary. Kotlin compiles these internal constructors as public JVM constructors, and javac does not enforce RequiresOptIn. Against this PR's actual covertest bytecode, JDK 21 accepted:
new io.prebindgen.covertest.Storage(0xdeadbeefL).close();
javap likewise reports public Storage(long) and public NativeHandle(long). That sends a forged pointer into native code from ordinary safe Java; this matters for a generated JVM artifact used beneath zenoh-java. Please add JVM-visible/source-level hiding as well—for example, private handle constructors plus a generated internal @JvmSynthetic factory, and @JvmSynthetic on peek/fromParts while verifying JNI lookup remains stable—or an equivalent design.
The positive checks are clean: 256 prebindgen-jni tests, Clippy, formatting, byte-identical regen, current CI, and the full JVM harness (PASS - 52 sections).
— Codex (GPT-5)
Review found the first pass stopped at the Kotlin compiler. Both of its mechanisms are Kotlin-source constructs: `internal` compiles to a **public** JVM member under a mangled name, and javac has never heard of `@RequiresOptIn`. Against this branch's own covertest bytecode, JDK 21 accepted `new Storage(0xdeadbeefL).close()` — and, worse than reported, `CovNative.INSTANCE.storageFree(0xdeadbeefL)`, since `internal object` is a public JVM class with public native methods and a public `INSTANCE`. The handle layer was not merely bypassable; it was optional. `@JvmSynthetic` is the missing half: `ACC_SYNTHETIC` makes javac refuse to resolve the member, while leaving the name and signature alone, so JNI's `GetMethodID` / `GetStaticMethodID` and native-method binding — which ignore the flag — keep working. It now guards every `external fun`, each class's static `freePtr`, `peek()`, the `fromParts` factories, and the internal state (`ptr`, `markConsumed`, the locking helpers) that a Java caller could otherwise use to repoint a live handle before closing it. Constructors cannot carry it (Kotlin rejects the target), so a handle's constructor is `private` behind an `internal @JvmSynthetic fromRawPtr` factory, and every generated site that mints a handle goes through `handle_from_raw`. The base `NativeHandle` constructor stays `internal` — its subclasses need `super` — which is inert: no generated signature accepts a foreign subclass, and nothing it could reach is visible any more. Second gap: the default configuration has no base package, the marker then had nowhere importable to live, and the whole Kotlin guard silently became a no-op — #37 was still reproducible out of the box. The marker is now always emitted, and the one shape that cannot work (no base package, but generated files in subpackages, which Kotlin cannot import a root-package name from) is refused instead of quietly degraded. Verified on the emitted bytecode, not the source: covertest grew a section that reflects over the generated classes and asserts the flags, so CI catches a regression. `PASS - 53 sections`. Separately, JDK 21 javac now rejects both exploits above while the public API still compiles.
|
Both blocking gaps fixed in 5209430. CI green on all six jobs. You were right that the first pass stopped at the Kotlin compiler, and the hole was bigger than reported. Reproducing your JDK 21 test against this branch's covertest bytecode, I also got: io.prebindgen.covertest.CovNative.INSTANCE.storageFree(0xdeadbeefL); // compiled fine
2. The JVM boundary
Same JDK 21, same classpath, after the change: while 1. The default configurationAgreed, and fixed by refusing rather than emitting. The marker is now always generated, including into a root base package — the only shape that genuinely cannot work is no base package plus generated files in subpackages, since Kotlin cannot import a root-package name from elsewhere. That combination now panics with a message pointing at DownstreamRe-verified against zenoh: 257 prebindgen-jni tests, clippy |
|
Independent re-check of the two blocking points against 1. Default configuration. 2. JVM boundary. Confirmed
The
On the verification, not just the fix. Asserting on bytecode (covertest section 53, reflecting No further findings. Both points resolved; all six checks green. One non-blocking item for the merge checklist: |
milyin
left a comment
There was a problem hiding this comment.
Found one blocking forged-pointer bypass and one API regression on 5209430.
[P1 — blocking] Guard the public raw callback adapters. WrapKind::wrap_expr now routes handle leaves through fromRawPtr, but IfaceSpec::to_raw_decl() still emits a public raw interface and to_as_raw_fun() still emits a public asRaw() extension. In regenerated zenoh-flat-jni, ordinary consumer Kotlin can compile:
val raw = QueryCallback { _, _, _, _, _, _, _, _ -> }.asRaw()
fun exploit() {
raw.run("", "", null, null, null, null, 0, 0xdeadbeefL)
}The adapter then executes Query.fromRawPtr(handle) inside its generated file, which has the blanket opt-in, and hands the forged Query to the typed callback. That callback can retain it and call a native method or close(). My compile-only probe succeeded. javap also reports QueryKt.asRaw as ACC_PUBLIC, ACC_STATIC, ACC_FINAL and QueryCallbackRaw.run as ordinary public/non-synthetic, so Java has the same route. Please make every handle-wrapping asRaw adapter at least internal @JvmSynthetic (generated wrappers remain in the same module), audit the analogous trusted builder/folder adapters, and extend the bytecode check to cover these routes.
[P2] Do not mark pointer-free fromParts factories as unsafe. build_data_class and sealed-class emission call mark_unsafe unconditionally, including factories such as Timestamp.fromParts(Long, ByteArray) and ZenohId.fromParts(ByteArray) that cannot mint or accept a native pointer. This removes existing safe factories from Java and forces unrelated Kotlin consumers to opt into a raw-pointer contract. A downstream Timestamp.fromParts(1L, byteArrayOf()) probe now fails solely on UnsafeNativeApi. Please apply the marker and @JvmSynthetic only when the flattened factory plan contains a handle leaf.
Downstream verification against zenoh-flat-jni 8ba3237: cargo check passed, generated Rust stayed byte-identical, and compileKotlinJvm passed. The current six PR checks are green; these gaps are outside their assertions.
— Codex (GPT-5)
Review found the raw-pointer boundary still open on one route and
over-applied on another.
[P1] `WrapKind::wrap_expr` routes handle leaves through `fromRawPtr`, but the
adapter holding that call was public. `QueryCallback { … }.asRaw()` handed any
consumer a `run` whose handle leaves are bare `Long`s, evaluated inside a
generated file that already carries the blanket `@file:OptIn` — so a forged
pointer became a typed handle with no opt-in from the caller, and `javap`
reported the extension as ordinary `ACC_PUBLIC ACC_STATIC`, giving Java the
same route. The raw twin is now `internal` and the `asRaw` proxy `internal` +
`@JvmSynthetic`, the pair the handle entry points already carry; `run` keeps
its name and public visibility, because native resolves it by
`GetMethodID` and an internal member would mangle.
The audit that request implied found a second instance of the same shape:
the hoisted folder appenders. `internal object` is a public JVM class and
`@JvmField` a public static, so `__StorageFolderRawHolder.instance.run(list,
0xdeadbeefL)` minted a handle from an invented pointer just as directly. The
field is `@JvmSynthetic` now — accepted next to `@JvmField`, and invisible to
`GetStaticFieldID`, which the harness's `Vec` folds exercise end to end. The
builder singletons need nothing: a top-level `internal val` has a mangled
getter and a private backing field.
[P2] Both `fromParts` emitters marked unconditionally, which took factories
like `Timestamp.fromParts(Long, ByteArray)` away from Java and made unrelated
Kotlin consumers opt into a raw-pointer contract those factories do not have.
The data class's factory is now guarded iff its flattened plan has a handle
leaf (`plan_mints_handle`, recursive — a nested class inlines its leaves into
the parent's signature, so a handle two levels down still arrives as a raw
`Long` there). The sealed interface's factory is never guarded: its parameters
are the variants' property types, so a handle payload arrives as its typed
class and there is no pointer to forge. In covertest that leaves `Dossier`,
`Holder` and `Summary`'s owner marked, and 30-odd pointer-free factories
public again.
Verified on the bytecode, same JDK 21 as the report. Both routes now refuse:
error: cannot find symbol method asRaw(StorageCallback)
error: cannot find symbol variable instance
error: cannot find symbol method fromParts(long,long) // Holder, still guarded
while `CacheConfig.fromParts(0, 1L, 2L)` — pointer-free — compiles again.
covertest section 53 grew both routes (reflecting over the facade `asRaw`
statics and the holder field), so they are CI gates rather than one-off
probes: `PASS - 53 sections`. 257 prebindgen-jni tests, clippy `-D warnings`
and `cargo fmt --check` on stable and 1.85.0.
|
Both fixed in P1 — the callback adaptersYou were right, and the audit you asked for found a second instance of the same shape.
The analogous adapter: the hoisted folder appenders. __StorageFolderRawHolder.instance.run(new ArrayList<Storage>(), 0xdeadbeefL);minted a handle from an invented pointer just as directly as Same JDK 21, this branch's covertest bytecode: P2 — over-markingAgreed. Two separate causes, so two fixes: The data class's factory is now guarded iff its flattened plan has a handle leaf ( The sealed interface's factory is now never guarded. Its parameters are the variants' property types ( Your exact example, regenerated downstream: public fun fromParts(ntp64: Long, id: ByteArray): Timestamp = Timestamp(ntp64.toULong(), id)Across all of zenoh-flat-jni exactly one The bytecode checkExtended to both new routes — reflecting over the facade classes' non-private Verification
|
There was a problem hiding this comment.
Pull request overview
Hardens generated Kotlin/JVM bindings against forged native pointers.
Changes:
- Replaces public handle constructors with private constructors and synthetic internal factories.
- Adds opt-in and JVM-synthetic guards across raw-pointer entry points.
- Regenerates examples and expands security-focused tests.
Reviewed changes
Copilot reviewed 19 out of 27 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
prebindgen-jni/src/lib.rs |
Documents pointer-safety model. |
prebindgen-jni/src/jni/tests/values.rs |
Updates factory assertions. |
prebindgen-jni/src/jni/tests/snapshots.rs |
Tests generated guards. |
prebindgen-jni/src/jni/tests/sealed.rs |
Updates raw-interface expectations. |
prebindgen-jni/src/jni/tests/flatten.rs |
Updates handle construction snapshots. |
prebindgen-jni/src/jni/tests/config.rs |
Verifies private constructors. |
prebindgen-jni/src/jni/tests/callbacks.rs |
Verifies callback factories. |
prebindgen-jni/src/jni/struct_plan.rs |
Detects handle-minting factories. |
prebindgen-jni/src/jni/render.rs |
Emits guarded constructors and factories. |
prebindgen-jni/src/jni/mod.rs |
Adds guard-generation helpers. |
prebindgen-jni/src/jni/kotlin_emit.rs |
Emits annotations and synthetic boundaries. |
prebindgen-jni/src/jni/iface/tests.rs |
Updates adapter snapshots. |
prebindgen-jni/src/jni/iface.rs |
Internalizes raw callback adapters. |
prebindgen-jni/src/jni/fold.rs |
Routes wrapping through factories. |
prebindgen-jni/src/jni/builder.rs |
Resolves the marker FQN. |
examples/perftest-kotlin/kotlin/generated/io/prebindgen/perftest/storage.kt |
Regenerates wrapper factories. |
examples/perftest-kotlin/kotlin/generated/io/prebindgen/perftest.kt |
Regenerates hardened bindings. |
examples/covertest-kotlin/kotlin/io/prebindgen/covertest/Test.kt |
Adds bytecode guard checks. |
examples/covertest-kotlin/kotlin/io/prebindgen/covertest/CovResource.kt |
Opts into peek(). |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/storage.kt |
Regenerates storage wrappers. |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/model.kt |
Regenerates model bindings. |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/esc_pkg.kt |
Regenerates escaped-package bindings. |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/errors.kt |
Regenerates error bindings. |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/analytics.kt |
Regenerates analytics bindings. |
examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest.kt |
Regenerates core covertest bindings. |
Cargo.toml |
Uses required codegen branch. |
.github/workflows/rust.yml |
Handles unpublished dependency packaging. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // reproducible by default), refuse it. | ||
| if self.package.is_empty() { | ||
| if let Some(f) = merged.iter().find(|f| !f.package.is_empty()) { | ||
| panic!( |
There was a problem hiding this comment.
Fixed in 485386a — WriteKotlinError::Other instead of the panic (the PR description predates the refusal; it is a rejected configuration now, not a skipped guard). The should_panic test asserts the error instead.
| // is all `GetStaticFieldID` looks at. A top-level `internal val` | ||
| // (the builder singletons) needs no such treatment: its getter is | ||
| // mangled and its backing field is private. |
There was a problem hiding this comment.
Confirmed on the bytecode and fixed in 485386a. javap on the covertest facade: public static final ...get__LookupBuilderRaw(); flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL — not even mangled. Both singleton emitters (struct + sum) now carry @get:JvmSynthetic; the folder holders already did. Guarded unconditionally rather than via plan_mints_handle, since these vals are internal, referenced only from generated Kotlin, and never resolved by name from native — nothing legitimate loses a route. covertest section 53 now reflects over every get__* on the facades and asserts isSynthetic.
Two review findings, both on the last commit.
[P1] `write_kotlin` panicked on a configuration it already had a `Result` to
report on. It is the caller's `set_package_prefix` that is missing, not an
invariant that broke, so it is now a `WriteKotlinError::Other`; the test that
asserted the panic asserts the error instead.
[P2] "The builder singletons need nothing: a top-level `internal val` has a
mangled getter and a private backing field" was wrong on the first half.
`javap` on the covertest facade:
public static final ...LookupBuilderRaw<Lookup> get__LookupBuilderRaw();
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
Not mangled, not synthetic — `internal` is a Kotlin-only boundary, which is
the premise of this whole PR. `ModelKt.get__LookupBuilderRaw().run(0,
0xdeadbeefL, null)` reaches `Summary.fromRawPtr` under the file's blanket
opt-in, exactly the route the folder appenders were closed on. Both singleton
emitters now carry `@get:JvmSynthetic`.
Guarded unconditionally rather than by `plan_mints_handle` (the test that
keeps `fromParts` public where no pointer is involved): these vals are
`internal` to begin with, referenced only from generated Kotlin in the same
module, and never resolved by name from native — so no legitimate caller
loses a route, and the guard does not depend on the leaves staying
pointer-free.
covertest section 53 reflects over every `get__*` on the facades, so this is a
CI gate like the two before it: `PASS - 53 sections`. 257 prebindgen-jni
tests, regen-check clean, clippy `-D warnings` and `cargo fmt --check` on
stable and 1.85.0.
There was a problem hiding this comment.
Rereview of 485386a: no blocking findings.\n\nThe remaining builder-singleton bypass is closed. The generated top-level getters now carry @get:JvmSynthetic; the covertest checks every generated get__* facade method, and downstream zenoh-flat-jni bytecode reports ACC_PUBLIC + ACC_STATIC + ACC_FINAL + ACC_SYNTHETIC on the getters.\n\nVerification:\n- cargo test -p prebindgen-jni: 257 passed, plus doc tests\n- examples/covertest-kotlin ./gradlew run --console=plain: PASS, all 53 sections\n- zenoh-flat-jni 8ba3237, patched to this PR's local generator crates: cargo check passed\n- zenoh-flat-jni generated Rust remained byte-identical\n- zenoh-flat-jni ./gradlew compileKotlinJvm --console=plain: passed\n- all six PR checks are green\n\nNon-blocking: the PR description still describes the initial implementation (including outdated guard details and 256/52 test counts); please refresh it before merge.\n\n— Codex (GPT-5)
|
Downstream check beyond zenoh-flat-jni + zenoh-java: zenoh-kotlin does not compile against this branch — 21 errors, all Every one is The reason a That is a breaking change and wants its own PR after this one lands; nothing here needs to change for it. Verification run alongside the review fixes:
|
Fixes #37.
public class KeyExpr(initialPtr: Long) : NativeHandle(initialPtr)with a publicpeek()let safe consumer Kotlin writeKeyExpr(0xdeadbeef).close()and hand native code a pointer it invented.The actual raw-pointer surface
object JNINative(everyexternal fun)internal(Long)ctorinternalNewObjects a handle — construction is entirely Kotlin-side — and every generated call site is in the same moduleNativeHandle.peek()@UnsafeNativeApienv.call_method(_, "peek", "()J");internalwould mangle it topeek$<module>@JvmStatic fun fromParts(…, ptr: Long, …)@UnsafeNativeApicall_static_methodtarget, same problem@RequiresOptInis source-level only, so the two that must stay public in bytecode keep their exact JNI signature.Every generated file gets
@file:OptIn(<pkg>.UnsafeNativeApi::class), fully qualified so no import bookkeeping is needed. Generated code is the trusted producer of these pointers; consumer code gets no blanket and must opt in per declaration.Skipped when no base package is configured — the marker would land in the root package, which Kotlin cannot import from a subpackage. The
internalconstructors still apply.Verification
cargo test -p prebindgen-jni— 256 pass, including a newraw_pointer_entry_points_are_guardedsnapshot test asserting the marker is emitted exactly once atLevel.ERROR, every file carries the@file:OptIn, the base and ptr-class constructors areinternal,peekand everyfromPartsare marked, andJNINativeisinternalexamples/regen-check.shclean; the regeneratedcovertest-kotlin/perftest-kotlintrees are in the diffexamples/covertest-kotlinGradle build passes and the harness printsPASS - 52 sections— Kotlin actually compiling the annotated tree is what proves the annotations are well-formed and opt-in propagation is satisfied. Its two handwrittenpeek()/fromPartsuses now carry@OptIn(UnsafeNativeApi::class); that is the intended demonstration, not proof of the guard (that module can seeinternal)cargo fmt --checkon stable and 1.85.0Downstream
Verified against zenoh end-to-end:
zenoh-flat-jniregenerates withsrc/generated_bindings.rsbyte-identical and only the intended Kotlin changes,compileKotlinJvmpasses, andzenoh-javabuilds against it via the composite build. Zero handwritten-Kotlin changes needed in either — nothing downstream constructs a generated handle from aLong, callspeek(), or calls a generatedfromParts.Blocked on
kotlin-codegen0.2.1 (milyin/kotlin-codegen#23) —KtClass::ctor_visandKtFile::file_annotation. CI here will fail to resolve the dependency until that is released to crates.io.Not done
A cross-Gradle-module negative-compile test proving
KeyExpr(0xdeadbeef)fails for an external consumer. Worth adding when a second Kotlin module exists in this repo; today the generated-text assertions plusinternalsemantics cover it.