Race on first use of a by-value Structure type: FFIType is shared per class with size 0 and libffi initialises it in place without a lock

未关闭
#1,752 0 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
43/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
c, java
领域
backend

调研方向

Start by reading Structure.java around FFIType, typeInfoMap, and cacheStructureLock, then trace getStructureType() into native/dispatch.c and the Native.initialize_ffi_type binding. Confirm the shared descriptor can be initialized safely before publication, and add a concurrent first-use regression test for a by-value structure. Done means concurrent calls preserve the correct structure size and values without crashes.

由索引模型根据 Issue 内容生成。

描述

Summary

The libffi ffi_type that JNA creates for a Structure class is shared per class and is created with size = 0 and alignment = 0. libffi fills it in lazily inside ffi_prep_cif, in place and without a lock. When two threads pass a by-value struct of a class that has not been used before, at the same time, they race on that initialisation. One thread can read a partial size, marshal the struct with the wrong size, and corrupt the native stack or pass wrong data to the callee. The shared descriptor can also be left with a wrong size for the rest of the process.

Observed on JNA 5.18.1, JDK 21, macOS arm64, and in production on Android arm32 (POS app) where it crashes with SIGSEGV on return from the native call.

Mechanism

  • Structure.FFIType(Structure ref) writes elements but leaves size and alignment at 0 (Structure.java, FFIType constructor and init). The instance is cached per class in typeInfoMap and shared across threads. Native.initialize_ffi_type exists but is not called.
  • dispatch() in native/dispatch.c builds a new ffi_cif on every call and passes the shared ffi_type* from getStructureType() to ffi_prep_cif, with no synchronisation.
  • libffi ffi_prep_cif_core runs initialize_aggregate when arg->size == 0. initialize_aggregate (src/prep_cif.c) does arg->size = 0; arg->alignment = 0; and then accumulates element sizes into arg->size on the shared object.
  • Two threads in initialize_aggregate at once interleave the reset and the accumulation. Each thread then computes cif->bytes from whatever size it reads, while ffi_prep_args later copies ty->size bytes. A bytes that is too small overflows the argument block into libffi's call frame; a size that is too small on aarch64 makes libffi pass a large struct in registers instead of by pointer.

Only the first use of each struct class is exposed. After one successful initialisation, size != 0 and the path is never taken again.

Reproduction

Two files, no dependencies beyond jna-5.18.1.jar and a C compiler.

sink.c:

#include <stdint.h>
typedef struct { uint64_t a; uint64_t b; void *p; } inner_t;          /* 24 bytes */
typedef struct { inner_t first; int8_t code; inner_t second; } outer_t; /* 56 bytes */
uint64_t sink(uint64_t handle, outer_t v) {
    return (handle << 48) | ((uint64_t)(uint8_t)v.code << 40) | (v.first.a << 20) | v.first.b;
}

FfiTypeRace.java:

import com.sun.jna.*;
import java.lang.reflect.Method;
import java.util.concurrent.atomic.*;

public class FfiTypeRace {
    @Structure.FieldOrder({"a", "b", "p"})
    public static class Inner extends Structure {
        public long a; public long b; public Pointer p;
        public static class ByValue extends Inner implements Structure.ByValue {}
    }
    @Structure.FieldOrder({"first", "code", "second"})
    public static class Outer extends Structure {
        public Inner.ByValue first = new Inner.ByValue(); public byte code; public Inner.ByValue second = new Inner.ByValue();
        public static class ByValue extends Outer implements Structure.ByValue {}
    }

    // Test hook only: JNA's native ffi_type for a Structure class (size_t size @0, short alignment @8).
    static Pointer ffiType(Structure s) throws Exception {
        Method m = Structure.class.getDeclaredMethod("getTypeInfo"); m.setAccessible(true);
        return (Pointer) m.invoke(s);
    }
    static long expected(long h, long a, long b) { return (h << 48) | (a << 20) | b; }

    public static void main(String[] args) throws Exception {
        int threads = Integer.parseInt(args[0]), rounds = Integer.parseInt(args[1]);
        boolean resetEachRound = args.length > 2 && args[2].equals("reset");
        Function sink = NativeLibrary.getInstance("sink").getFunction("sink");
        Outer.ByValue warm = new Outer.ByValue(); warm.first.a = 1; warm.first.b = 2;
        if (sink.invokeLong(new Object[]{9L, warm}) != expected(9, 1, 2)) throw new AssertionError("warm-up");
        Pointer outer = ffiType(warm), inner = ffiType(new Inner.ByValue());
        System.out.println("after single-threaded first use: outer.size=" + outer.getLong(0) + " inner.size=" + inner.getLong(0));

        AtomicLong gen = new AtomicLong(); AtomicInteger arrived = new AtomicInteger(), done = new AtomicInteger();
        AtomicInteger wrongSize = new AtomicInteger(), wrongChecksum = new AtomicInteger(); AtomicBoolean stop = new AtomicBoolean();
        for (int t = 0; t < threads; t++) {
            final long id = t;
            Thread th = new Thread(() -> {
                long seen = 0;
                while (!stop.get()) {
                    while (gen.get() == seen && !stop.get()) Thread.onSpinWait();
                    if (stop.get()) break;
                    seen = gen.get(); long a = (seen % 1000) + 1, b = id + 1;
                    Outer.ByValue v = new Outer.ByValue(); v.first.a = a; v.first.b = b;
                    arrived.incrementAndGet(); while (arrived.get() < threads) Thread.onSpinWait();
                    if (sink.invokeLong(new Object[]{id, v}) != expected(id, a, b)) wrongChecksum.incrementAndGet();
                    done.incrementAndGet();
                }
            });
            th.setDaemon(true); th.start();
        }
        for (int i = 0; i < 20000; i++) sink.invokeLong(new Object[]{1L, warm}); // JIT the call path
        for (int r = 0; r < rounds; r++) {
            // Re-create "first use of this class in the process" for every round.
            if (resetEachRound) { outer.setLong(0, 0); outer.setShort(8, (short) 0); inner.setLong(0, 0); inner.setShort(8, (short) 0); }
            arrived.set(0); done.set(0); gen.set(r + 1);
            while (done.get() < threads) Thread.onSpinWait();
            if (outer.getLong(0) != 56) wrongSize.incrementAndGet();
        }
        stop.set(true);
        System.out.println("threads=" + threads + " rounds=" + rounds + " reset_each_round=" + resetEachRound
            + " rounds_with_wrong_final_size=" + wrongSize + " wrong_struct_values_seen_by_native=" + wrongChecksum);
    }
}
cc -shared -o libsink.dylib sink.c        # or libsink.so
javac -cp jna-5.18.1.jar FfiTypeRace.java
java -Djna.library.path=. -cp .:jna-5.18.1.jar FfiTypeRace 4 20000          # control
java -Djna.library.path=. -cp .:jna-5.18.1.jar FfiTypeRace 4 20000 reset    # race

Results on JDK 21.0.10, macOS 15 arm64, JNA 5.18.1:

mode rounds rounds with wrong final size wrong struct values seen by native crash
control (descriptor initialised once, then 4 threads) 20000 0 0 no
race (descriptor reset before each round, 4 threads) 20000 247 13 no
race, second run stopped early - - SIGSEGV in sink+0x4, si_addr 0x18
race, third run stopped early - - SIGSEGV in sink+0x4

The reset through getTypeInfo() only re-creates the state a brand new Structure class has on its first use. Without it the race happens once per class per process, which is how it shows up in production: rare per device, but tied to the first concurrent use of a struct type.

Production impact

Shopify POS on Android passes a 56-byte struct by value from two threads at the same time, once per process, through UniFFI generated bindings. On 32-bit ARM devices the thread that computed a too-small cif->bytes overflows libffi's call frame and crashes with SIGSEGV on return, with PC in no code mapping and the callee having received correct data. The same race is a plausible cause for the foreign-future completion segfaults discussed in mozilla/uniffi-rs#2733.

Possible fixes

  • Compute size and alignment in Java when constructing FFIType(Structure ref) and write them with the elements, so libffi never sees size == 0. JNA already knows both values from the Structure layout.
  • Or call the existing Native.initialize_ffi_type on the new FFIType inside cacheStructureLock in FFIType.get, before the type is published.

Either removes the only unsynchronised write to the shared descriptor.

Workaround

Pass one instance of each by-value struct class through a native call, or create one JNA closure for a callback method that takes it, on a single thread before any concurrent use. We do this at application startup.

主要语言
Java
星标
8.9k
派生
1.7k
平均合并
7 天 18 小时
30 天内合并 PR
2

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

java-native-access/jna 的其他 Issue

查看 java-native-access/jna 的全部 Issue

相似的 Issue

更多 Java Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。