A signal delivered in a syscall epilogue records the TLBI wire values as the guest's `X8` through `X11`
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 45/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- c, macos
- Domain
- operating-systems
Research direction
Start with tests/repro-epilogue-x8.c and run the listed make, cross-compile, and elfuse commands to reproduce the corruption. Read tlbi_request_emit_to_vcpu, syscall_return_epilogue, deliver_signal_locked, and the frame-publication logic around src/syscall/signal.c:2298 and 2434. Done means the epilogue delivery preserves the guest's X8-X11 state and the reproduction no longer reports corrupted registers or an unintended second syscall.
Written by the indexing model from the issue text.
Description
Symptom
A guest calls mprotect, a signal is delivered in the host's return epilogue for
that call, and the instruction the guest resumes on is another svc. That svc
runs as a syscall the guest never asked for:
WARN src/syscall/syscall.c:2841: unimplemented syscall 3 (x0=0x0, x1=0x1000, x2=0x3, x3=0x22, x4=0xffffffffffffffff, x5=0x0)
The guest held 226 (__NR_mprotect) in X8 at both instructions. The handler
sees the same corruption directly, without needing a second svc to expose it:
uc_mcontext.regs[8] reads 0 or 3, and on the deliveries where it reads 3,
regs[9], regs[10] and regs[11] read the operands of a TLB invalidation
rather than what the guest put there.
This is not #379 and is not closed by #383. #383 changes which wrong number
reaches EL0 on this path. It does not change whether one does, and the rate is
unmoved: in the runs below every delivery corrupts the register set on both
trees.
Mechanism
The host uses X8 through X11 as a wire protocol to ask the shim for a TLB
invalidation on the way out of a page-table syscall, and the signal-delivery
path snapshots those registers as though they were the guest's.
-
Dispatch finishes the syscall and calls
tlbi_request_emit_to_vcpu
(src/syscall/syscall.c:2911), which overwrites the liveX8throughX11
with the request (src/core/guest.h:674-708):X8is the kind, 0 for none,
1 for broadcast, 3 for a range, 4 for a large range;X9andX10carry the
range start and page count;X11is the I-cache hint. The comment at
src/syscall/proc.c:4089-4091states the invariant this relies on, that the
helper has already writtenX8by the time the epilogue runs. -
syscall_return_epiloguethen delivers any pending signal
(src/syscall/proc.c:4132), after that write and before the vCPU resumes. -
deliver_signal_lockedsnapshots the live GPRs (src/syscall/signal.c:2415)
and copies them into thert_sigframeit builds
(src/syscall/signal.c:2512). The registers it copies are the wire values,
so the frame recordsX8as 0/1/3/4 andX9throughX11as TLBI operands. -
The handler's
rt_sigreturnrestores all 31 GPRs from that frame
(src/syscall/signal.c:2894) and hands them back toEL0, so the guest
resumes with the wire values in place of its own registers.
The park that #383 adds does not cover this. It is read at
src/syscall/signal.c:2434 and is keyed on a record only signal_rt_sigreturn
writes (src/syscall/signal.c:2980-2982); on the ordinary syscall-return route
no rt_sigreturn has run in this epilogue, so sigreturn_x8.valid is false and
the snapshot takes the live register.
What #383 does change is the value that arrives. On afdcfce the delivery
writes the drop-frame marker into X8 and the tail restores no register, so
EL0 sees 2. On #383 the tail reloads X8 from the frame slot
(src/syscall/signal.c:2978-2979), and the frame slot holds what step 3
recorded, so EL0 sees the wire value. Both are wrong, and the second is
arguably harder to spot, since 2 is a constant and the wire value varies with
the mapping being changed.
The X9 through X11 half is present identically on both trees. #383 declines
it deliberately, because it is the same epilogue and not the register that
change moves.
Measurements
Apple M1, macOS 15.6.1. The machine was shared for every run below: two
logged-in users, load average 3.24 to 3.73 across the set, so the delivery counts
are a product of that scheduling and are not a rate to quote elsewhere. Rounds
are interleaved, one tree then the other, so both binaries met the same machine.
Population is 60000 mprotect rounds per run under a 200 us ITIMER_REAL, each
round two adjacent svcs with the first the mprotect.
| tree | round | deliveries | second svc ran as the wrong call |
X8 it ran as |
ucontext X8 not 226 |
X9/X10/X11 not the guest's |
|---|---|---|---|---|---|---|
| #383 at 03bf6e9 | 1 | 760 | 759 | 0 x462, 3 x297 | 761 of 761 | 299 |
| #383 at 03bf6e9 | 2 | 704 | 704 | 0 x410, 3 x294 | 704 of 704 | 294 |
| #383 at 03bf6e9 | 3 | 715 | 715 | 0 x440, 3 x275 | 715 of 715 | 275 |
afdcfce |
1 | 710 | 710 | 2 x710 | 710 of 710 | 286 |
afdcfce |
2 | 705 | 705 | 2 x705 | 705 of 705 | 275 |
afdcfce |
3 | 715 | 715 | 2 x715 | 715 of 715 | 297 |
Reading the table:
- The
ucontextX8column is 100 percent on both trees. Every signal delivered
in one of these epilogues reports a syscall number the guest did not set. - The second
svccolumn is the same 100 percent, one delivery aside in round 1,
where the last delivery landed between the two counters being printed. - The only column that moves between the trees is which wrong number arrives:
2 onafdcfce, 0 or 3 on #383. X9throughX11are corrupted on exactly the deliveries whereX8is 3,
which isTLBI_RANGE, the only kind that writes them. The observed values are
X9 = 0x200000000(the range start, a guest VA),X10 = 0x1(one page) and
X11 = 0x0(no I-cache flush), against sentinels of0xa9,0xaaand0xab.
The counts match theX8 = 3counts exactly in five of the six rounds, and are
off by two in round 1 for the same print-ordering reason.
Reproduction
/* repro-epilogue-x8.c -- does a signal delivered in the epilogue of a
* page-table syscall record the TLBI wire values as the guest's X8-X11, and
* does a guest returning from that handler onto an unexecuted SVC issue the
* shadowed number?
*/
#define _GNU_SOURCE
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/time.h>
#include <ucontext.h>
#include <unistd.h>
#define SENT8 226 /* __NR_mprotect, what the guest holds at the SVC */
#define SENT9 0xa9
#define SENT10 0xaa
#define SENT11 0xab
static volatile uint64_t hits, bad8, bad9, bad10, bad11;
static volatile uint64_t s8[8], s9[8], s10[8], s11[8];
static volatile uint64_t n8, n9, n10, n11;
static void note(volatile uint64_t *set, volatile uint64_t *n, uint64_t v)
{
for (uint64_t k = 0; k < *n; k++)
if (set[k] == v)
return;
if (*n < 8)
set[(*n)++] = v;
}
static void h(int s, siginfo_t *i, void *c)
{
(void) s;
(void) i;
ucontext_t *uc = c;
uint64_t x8 = uc->uc_mcontext.regs[8], x9 = uc->uc_mcontext.regs[9];
uint64_t x10 = uc->uc_mcontext.regs[10], x11 = uc->uc_mcontext.regs[11];
hits++;
if (x8 != SENT8) { bad8++; note(s8, &n8, x8); }
if (x9 != SENT9) { bad9++; note(s9, &n9, x9); }
if (x10 != SENT10) { bad10++; note(s10, &n10, x10); }
if (x11 != SENT11) { bad11++; note(s11, &n11, x11); }
}
/* x0 = page, x1 = iterations. Two adjacent SVCs: the first is mprotect, the
* second executes with whatever X8 holds when the guest resumes there.
*/
volatile uint64_t twin_bad, twin_hist[16];
void twin_svc(void *page, uint64_t iters);
__asm__(
".text\n.globl twin_svc\n.type twin_svc, %function\n"
"twin_svc:\n"
" stp x19, x20, [sp, #-32]!\n"
" str x21, [sp, #16]\n"
" mov x19, x0\n mov x20, x1\n mov x21, #1\n"
"1: mov x0, x19\n mov x1, #4096\n mov x2, x21\n"
" eor x21, x21, #2\n"
" mov x9, #0xa9\n mov x10, #0xaa\n mov x11, #0xab\n"
" mov x8, #226\n"
" svc #0\n"
" svc #0\n" /* unexecuted SVC at the resume PC */
" mov x12, x8\n cmp x12, #226\n b.eq 2f\n"
" adrp x13, twin_bad\n add x13, x13, :lo12:twin_bad\n"
" ldr x14, [x13]\n add x14, x14, #1\n str x14, [x13]\n"
" adrp x13, twin_hist\n add x13, x13, :lo12:twin_hist\n"
" and x15, x12, #15\n add x13, x13, x15, lsl #3\n"
" ldr x14, [x13]\n add x14, x14, #1\n str x14, [x13]\n"
"2: subs x20, x20, #1\n b.ne 1b\n"
" ldr x21, [sp, #16]\n ldp x19, x20, [sp], #32\n ret\n"
".size twin_svc, .-twin_svc\n");
static void dump(const char *name, uint64_t bad, volatile uint64_t *set,
uint64_t n, uint64_t want)
{
printf(" %s: %llu of %llu deliveries not 0x%llx; saw", name,
(unsigned long long) bad, (unsigned long long) hits,
(unsigned long long) want);
for (uint64_t k = 0; k < n; k++)
printf(" 0x%llx", (unsigned long long) set[k]);
printf("\n");
}
int main(void)
{
struct sigaction sa;
struct itimerval it;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = h;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigaction(SIGALRM, &sa, NULL);
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
it.it_interval.tv_sec = 0;
it.it_interval.tv_usec = 200;
it.it_value = it.it_interval;
setitimer(ITIMER_REAL, &it, NULL);
twin_svc(p, 60000);
printf("deliveries=%llu second-SVC ran as the wrong call %llu of 60000\n",
(unsigned long long) hits, (unsigned long long) twin_bad);
printf(" X8 seen at the second SVC:");
for (int k = 0; k < 16; k++)
if (twin_hist[k])
printf(" %d=%llu", k, (unsigned long long) twin_hist[k]);
printf("\n");
dump("ucontext X8 ", bad8, s8, n8, SENT8);
dump("ucontext X9 ", bad9, s9, n9, SENT9);
dump("ucontext X10", bad10, s10, n10, SENT10);
dump("ucontext X11", bad11, s11, n11, SENT11);
it.it_value.tv_sec = 0;
it.it_value.tv_usec = 0;
it.it_interval = it.it_value;
setitimer(ITIMER_REAL, &it, NULL);
return 0;
}
Save as tests/repro-epilogue-x8.c, then:
make elfuse
aarch64-linux-gnu-gcc -static -O2 -o build/repro tests/repro-epilogue-x8.c
./build/elfuse build/repro
One run takes about 10 seconds. It needs no loop and no load to reproduce: a
single run on #383 at 03bf6e9 printed
deliveries=708 second-SVC ran as the wrong call 708 of 60000
X8 seen at the second SVC: 0=421 3=287
ucontext X8 : 709 of 709 deliveries not 0xe2; saw 0x3 0x0
ucontext X9 : 287 of 709 deliveries not 0xa9; saw 0x200000000
ucontext X10: 287 of 709 deliveries not 0xaa; saw 0x1
ucontext X11: 287 of 709 deliveries not 0xab; saw 0x0
The X8 seen at the second SVC line is the whole difference between the trees:
2= on afdcfce, 0= and 3= on #383.
Suggested direction
@jserv's observation on the #383 review is the one to start from, and is quoted
rather than adopted here, since nothing below has been implemented or measured.
The shim's exception frame is live at SP_EL1 for this delivery too, exactly as
it is for the rt_sigreturn delivery that #383 already publishes into. So the
same frame slot that #383 reads could supply X8 for this route as well, and
X9 through X11 beside it, with the parked value winning when it is set.
Two things that would have to be settled and are not settled here. The frame slot
holds the registers the exception was taken with, which is the guest's state for
this route, so a plain read is plausible; whether it is correct for every entry
into the epilogue is not something these runs show. And #383 is careful that
shim_publish_frame_x8 may only be called where the frame is provably live
(src/syscall/signal.c:2298 and the comment above it), so extending the reader
to a second route means extending that proof, not just the call.
What this does not show
- Which kinds other than
TLBI_RANGEandTLBI_NONEare affected. Only
X8 = 0andX8 = 3were observed.TLBI_BROADCAST(1) and
TLBI_RANGE_LARGE(4) are written by the same helper and are presumably
identical, but they were not produced by this workload and were not measured. - Any syscall other than
mprotect. The reproduction uses one call on one
page. Every page-table syscall reachingtlbi_request_emit_to_vcpushould
behave the same way; none was tried. - The
HVC #11EL0-fault route.tlbi_request_emit_to_vcpuserves that path
too (src/core/guest.h:667-672names both call sites). Not tested. - A consequence in a real program. The second
svcin the reproduction is
there to make the corrupted register visible, and 3 happens to beio_cancel
onaarch64, which is unimplemented and answers-ENOSYSloudly. A guest
whose shadowedX8names an implemented syscall would run it silently, and
whether that occurs in any real workload was not investigated. - A rate. The machine was shared throughout. The delivery counts track the
200 us itimer against a loaded 8-core host and mean nothing on their own; the
proportions within a run are what the table is for. - Any interaction with #378. Not examined.
- Dominant language
- C
- Stars
- 268
- Forks
- 26
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 48
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from sysprog21/elfuse
-
Difficulty 3/5 1-2 days Newbie friendliness 76/100
-
`rt_sigreturn` overwrites the restored X8, so a signal taken on an `svc` re-enters it as syscall 2 Open
Difficulty 4/5 3-5 days Newbie friendliness 64/100
-
Difficulty 5/5 Over a week Newbie friendliness 35/100
All issues in sysprog21/elfuse
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
zephyrproject-rtos/zephyr#119726 ·
-
[Bounty proposal] fix(web): memory insights count an evening memory on the next day ($25 proposed) Open
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
BasedHardware/omi#15320 ·
-
[adam] AdamNet network read doesn't cap to MAX_ADAM_PACKET_LEN, overflows client receive buffers Open
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
FujiNetWIFI/fujinet-firmware#1649 · 2 comments ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
HarbourMasters/Shipwright#7229 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
riscv-software-src/riscv-isa-sim#2435 · 1 comment ·