Skip to content

Commit 79563ec

Browse files
ljmf00-wekaioclaude
andcommitted
druntime: add regression test for the fiber-switch GC scan hole
Covers issue #23576 on both affected architectures. The test holds the sole reference to a canary object (with a 4 MB payload) in the first callee-saved FP register (d8 on AArch64, fs0 on LoongArch64) across a yield, XOR-obfuscating every other copy and scrubbing the stack and scratch registers, forces collections and heap churn from the main context, then resumes and asserts the canary survived with its payload intact. On a druntime without the fix the canary is collected while the fiber is suspended and the test fails its assertion. The register-holding helper is ABI-conformant assembly: like any function using a callee-saved register, it saves the caller's copy in its prologue and restores it in its epilogue, keeping the pointer in the register across the call to yield - exactly the code shape a compiler emits when it allocates a value to a callee-saved FP register across a call. It lives in a separate assembly file because no source-level construct can force a register allocator to pick a specific register (so relying on compiler codegen would make the test flaky across compiler versions and tuning flags) and inline asm would tie the test to one compiler's dialect; the file compiles to an empty object on other architectures and the test itself reduces to an empty main there. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fbd433a commit 79563ec

3 files changed

Lines changed: 280 additions & 1 deletion

File tree

druntime/test/thread/Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,18 @@ TESTS := tlsgc_sections test_import tlsstack filterthrownglobal filterthrownmeth
66
ifeq ($(OS),windows)
77
TESTS += winfiber
88
else
9-
TESTS += fiber_guard_page external_threads
9+
TESTS += fiber_guard_page external_threads fiber_fpreg_gc_scan
1010
endif
1111

1212
include ../common.mak
1313

14+
# no-op on other architectures; the asm helpers live in a separate .S file
15+
$(ROOT)/fiber_fpreg_gc_scan$(DOTEXE): private extra_sources = $(ROOT)/fiber_gc_scan_asm$(DOTOBJ)
16+
$(ROOT)/fiber_fpreg_gc_scan$(DOTEXE): $(ROOT)/fiber_gc_scan_asm$(DOTOBJ)
17+
18+
$(ROOT)/fiber_gc_scan_asm$(DOTOBJ): $(SRC)/fiber_gc_scan_asm.S | $(OBJDIR)
19+
$(COMPILE.c) $(OUTPUT_OPTION) $<
20+
1421
# segfault || bus error (OSX)
1522
$(ROOT)/fiber_guard_page.done: $(ROOT)/%.done : $(ROOT)/%$(DOTEXE)
1623
@echo Testing $*
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Fiber-switch GC scan regression test for callee-saved FP registers.
2+
//
3+
// On AArch64 (d8-d15) and LoongArch64 (fs0-fs7), the callee-saved FP
4+
// registers may hold a live GC pointer across a suspension point
5+
// (e.g. fmov d8, x0). fiber_switchContext used to report sp + 9*8 as
6+
// the stack top, hiding the saved return address and FP registers
7+
// from the GC scan - a pointer whose only copy sat in a saved FP
8+
// register could be collected while the fiber was suspended.
9+
//
10+
// The test holds the sole reference to a canary object in the first
11+
// callee-saved FP register across a yield (via an ABI-conformant
12+
// helper in fiber_gc_scan_asm.S that mirrors the code a compiler
13+
// emits when it allocates a value to such a register across a call;
14+
// all other copies are XOR-obfuscated), forces collections plus heap
15+
// churn from the main context, then resumes and checks the canary
16+
// survived.
17+
18+
version (AArch64) version = TestFiberFpRegGcScan;
19+
else version (LoongArch64) version = TestFiberFpRegGcScan;
20+
21+
version (TestFiberFpRegGcScan)
22+
{
23+
import core.memory : GC;
24+
import core.thread : Fiber;
25+
26+
alias YieldFn = extern (C) void function() nothrow @nogc;
27+
28+
extern (C) nothrow @nogc
29+
{
30+
size_t hold_in_fpreg_and_yield(size_t obf, size_t key, YieldFn yield);
31+
void scrub_regs();
32+
void compiler_barrier(void* p);
33+
}
34+
35+
extern (C) void doYield() nothrow @nogc
36+
{
37+
Fiber.yield();
38+
}
39+
40+
enum size_t MAGIC = 0xDEAD_BEEF_CAFE_F00D;
41+
enum size_t KEY = 0xA5A5_A5A5_A5A5_A5A5;
42+
enum PAYLOAD_WORDS = (4 * 1024 * 1024) / size_t.sizeof;
43+
44+
class Canary
45+
{
46+
__gshared bool collected;
47+
size_t[] payload;
48+
~this() { collected = true; }
49+
}
50+
51+
// Returns the canary address XOR-obfuscated so the raw pointer has
52+
// no GC-visible copy outside the fiber's saved FP register.
53+
size_t makeCanary()
54+
{
55+
pragma(inline, false);
56+
auto c = new Canary;
57+
c.payload = new size_t[PAYLOAD_WORDS];
58+
c.payload[] = MAGIC;
59+
return (cast(size_t) cast(void*) c) ^ KEY;
60+
}
61+
62+
// Zero the stack below the current frame to wipe stale spills of
63+
// the raw pointer left behind by makeCanary.
64+
void scrubStack()
65+
{
66+
pragma(inline, false);
67+
size_t[1024] z = 0;
68+
compiler_barrier(z.ptr);
69+
}
70+
71+
__gshared bool sawCollected;
72+
__gshared bool payloadIntact;
73+
74+
void fiberFunc()
75+
{
76+
size_t obf = makeCanary();
77+
scrubStack();
78+
79+
// Suspend with the pointer's only copy held in the callee-saved
80+
// FP register; the obfuscated value in this frame is invisible
81+
// to the GC scan.
82+
auto c = cast(Canary) cast(void*) hold_in_fpreg_and_yield(obf, KEY, &doYield);
83+
84+
sawCollected = Canary.collected;
85+
if (!sawCollected)
86+
payloadIntact = c.payload.length == PAYLOAD_WORDS
87+
&& c.payload[0] == MAGIC && c.payload[$ - 1] == MAGIC;
88+
}
89+
90+
void main()
91+
{
92+
auto fib = new Fiber(&fiberFunc);
93+
fib.call();
94+
95+
// The canary now lives only in the suspended fiber's saved FP
96+
// register.
97+
scrub_regs();
98+
GC.collect();
99+
foreach (i; 0 .. 8)
100+
{
101+
auto junk = new size_t[PAYLOAD_WORDS];
102+
junk[] = 0x0101_0101_0101_0101;
103+
compiler_barrier(junk.ptr);
104+
}
105+
GC.collect();
106+
107+
fib.call();
108+
assert(!sawCollected, "canary collected: saved FP register was hidden from the GC scan");
109+
assert(payloadIntact, "canary payload corrupted");
110+
}
111+
}
112+
else
113+
{
114+
void main() {} // only meaningful on AArch64 / LoongArch64
115+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/* Register-parking helpers for the fiber_fpreg_gc_scan test.
2+
*
3+
* Kept in a separate assembly file because no source-level construct
4+
* can force a compiler's register allocator to place a pointer in a
5+
* callee-saved FP register, and inline asm would tie the test to one
6+
* compiler's dialect. Only meaningful on architectures with
7+
* callee-saved FP registers covered by the test (AArch64,
8+
* LoongArch64); compiles to an empty object elsewhere.
9+
*
10+
* hold_in_fpreg_and_yield is ABI-conformant: like any function using
11+
* a callee-saved register, it saves the caller's copy in its prologue
12+
* and restores it in its epilogue, keeping its own value in the
13+
* register across the call to the callback - exactly the code shape a
14+
* compiler emits when it allocates a value to a callee-saved FP
15+
* register across a call.
16+
*/
17+
18+
#if (__linux__ || __FreeBSD__ || __NetBSD__ || __OpenBSD__ || __DragonFly__) && __ELF__
19+
.section .note.GNU-stack,"",%progbits
20+
#endif
21+
22+
#ifdef __USER_LABEL_PREFIX__
23+
#define GLUE2(a, b) a ## b
24+
#define GLUE(a, b) GLUE2(a, b)
25+
#define CSYM(name) GLUE(__USER_LABEL_PREFIX__, name)
26+
#else
27+
#define CSYM(name) name
28+
#endif
29+
30+
#if defined(__aarch64__)
31+
32+
/* size_t hold_in_fpreg_and_yield(size_t obf, size_t key, void (*yield)(void))
33+
*
34+
* Deobfuscates obf ^ key into the callee-saved d8, calls yield() with
35+
* the resulting pointer's only copy held there, and returns it after
36+
* yield comes back.
37+
*/
38+
.text
39+
.global CSYM(hold_in_fpreg_and_yield)
40+
.p2align 2
41+
#ifndef __APPLE__
42+
.type hold_in_fpreg_and_yield, %function
43+
#endif
44+
CSYM(hold_in_fpreg_and_yield):
45+
stp x29, x30, [sp, #-32]!
46+
mov x29, sp
47+
str d8, [sp, #16] // save the caller's d8 (callee saved)
48+
49+
eor x9, x0, x1
50+
fmov d8, x9 // the pointer's only copy lives in d8
51+
mov x9, xzr
52+
blr x2 // yield(): suspends the fiber
53+
54+
fmov x0, d8 // read it back after resume
55+
56+
ldr d8, [sp, #16] // restore the caller's d8
57+
ldp x29, x30, [sp], #32
58+
ret
59+
60+
/* void scrub_regs(void)
61+
*
62+
* Clear scratch registers that might still hold a stale copy of the
63+
* canary pointer.
64+
*/
65+
.text
66+
.global CSYM(scrub_regs)
67+
.p2align 2
68+
#ifndef __APPLE__
69+
.type scrub_regs, %function
70+
#endif
71+
CSYM(scrub_regs):
72+
mov x9, xzr
73+
mov x10, xzr
74+
mov x11, xzr
75+
mov x12, xzr
76+
mov x13, xzr
77+
mov x14, xzr
78+
mov x15, xzr
79+
ret
80+
81+
/* void compiler_barrier(void *p)
82+
*
83+
* Opaque sink: keeps the pointed-to memory alive and ordered as far as
84+
* the calling compiler can tell.
85+
*/
86+
.text
87+
.global CSYM(compiler_barrier)
88+
.p2align 2
89+
#ifndef __APPLE__
90+
.type compiler_barrier, %function
91+
#endif
92+
CSYM(compiler_barrier):
93+
ret
94+
95+
#elif defined(__loongarch64)
96+
97+
/* size_t hold_in_fpreg_and_yield(size_t obf, size_t key, void (*yield)(void))
98+
*
99+
* Deobfuscates obf ^ key into the callee-saved fs0, calls yield() with
100+
* the resulting pointer's only copy held there, and returns it after
101+
* yield comes back.
102+
*/
103+
.text
104+
.globl CSYM(hold_in_fpreg_and_yield)
105+
.p2align 2
106+
.type hold_in_fpreg_and_yield, %function
107+
CSYM(hold_in_fpreg_and_yield):
108+
addi.d $sp, $sp, -32
109+
st.d $ra, $sp, 24
110+
fst.d $fs0, $sp, 16 # save the caller's fs0 (callee saved)
111+
112+
xor $t0, $a0, $a1
113+
movgr2fr.d $fs0, $t0 # the pointer's only copy lives in fs0
114+
move $t0, $zero
115+
jirl $ra, $a2, 0 # yield(): suspends the fiber
116+
117+
movfr2gr.d $a0, $fs0 # read it back after resume
118+
119+
fld.d $fs0, $sp, 16 # restore the caller's fs0
120+
ld.d $ra, $sp, 24
121+
addi.d $sp, $sp, 32
122+
jr $ra
123+
124+
/* void scrub_regs(void)
125+
*
126+
* Clear scratch registers that might still hold a stale copy of the
127+
* canary pointer.
128+
*/
129+
.text
130+
.globl CSYM(scrub_regs)
131+
.p2align 2
132+
.type scrub_regs, %function
133+
CSYM(scrub_regs):
134+
move $t0, $zero
135+
move $t1, $zero
136+
move $t2, $zero
137+
move $t3, $zero
138+
move $t4, $zero
139+
move $t5, $zero
140+
move $t6, $zero
141+
move $t7, $zero
142+
move $t8, $zero
143+
jr $ra
144+
145+
/* void compiler_barrier(void *p)
146+
*
147+
* Opaque sink: keeps the pointed-to memory alive and ordered as far as
148+
* the calling compiler can tell.
149+
*/
150+
.text
151+
.globl CSYM(compiler_barrier)
152+
.p2align 2
153+
.type compiler_barrier, %function
154+
CSYM(compiler_barrier):
155+
jr $ra
156+
157+
#endif /* __aarch64__ / __loongarch64 */

0 commit comments

Comments
 (0)