linux: an attack guest that tries to leave its address space #137

Open
nonos-sync wants to merge 13 commits from nonos-sync/gh-586 into linux/go-guests AGit
Member

A guest that turns the isolation proofs around: instead of showing a
mapping behaves, it attacks its own confinement through the syscalls it
has. It maps at the kernel half, wraps a span, asks for a write-and-
execute page, adds execute to a file it never proved, calls a number the
personality does not serve, and steps into an unmapped hole. Each part
passes when the machine refuses.

It is a memproof part, so it shares the one guest binary and its store
trailer. On NONOS all seven are refused. On native Linux two are allowed,
a write-and-execute mapping and adding execute to an unproven file, which
are the two the personality enforces and stock Linux does not.

A guest that turns the isolation proofs around: instead of showing a mapping behaves, it attacks its own confinement through the syscalls it has. It maps at the kernel half, wraps a span, asks for a write-and- execute page, adds execute to a file it never proved, calls a number the personality does not serve, and steps into an unmapped hole. Each part passes when the machine refuses. It is a memproof part, so it shares the one guest binary and its store trailer. On NONOS all seven are refused. On native Linux two are allowed, a write-and-execute mapping and adding execute to an unproven file, which are the two the personality enforces and stock Linux does not.
The kernel demand-filled any user page a thread touched first, foreign
guests included. A Linux guest's PROT_NONE reservation read as zeros, and
a pthread's guard page, which musl leaves as the unopened bottom of a
PROT_NONE stack reservation, took a fresh page and guarded nothing: a
recursion ran straight through it.

Every page a Linux guest is meant to have is already mapped by its
supervisor with MkPeerMap: brk, the initial stack, the ELF segments, file
maps, anonymous maps and mprotect commits. Demand fill only ever gave a
guest pages nobody had mapped for it. Now a not-present fault in a
foreign guest is refused. The fault path ends the thread with -11, the
kernel posts the death to the supervisor as before, and the personality
ends the process with status 139. The page tables stay the one record
of what a guest holds; nothing new is kept in the kernel.

guardpage, a musl pthread recursing into its guard page, proves it: the
process ends on SIGSEGV with status 139 and never prints the line it
prints when it runs 64 KiB below the stack.
The peer protection bits had write and exec and nothing for no access:
MkPeerMap and MkPeerProtect with neither bit gave a present, readable
user page. So mprotect(PROT_NONE) on a mapped span, and a PROT_NONE file
mapping, left the pages readable, and the region list had no way to say
a backed span was closed.

MkPeerMap and MkPeerProtect now take PEER_PROT_NONE. The page stays
present with the user bit clear: every guest access faults, the kernel
still copies it at fork and frees it at teardown, and the bytes are
there again when a later mprotect opens it, as Linux keeps them. A
region records whether it allows access at all, and one function turns
a region's protection into peer bits for map, commit, mprotect and the
fork copy.

protnone proves it part by part in forked children.
mprotect changed the page protection of a backed span in the kernel but
left the region list with the protection the span was mapped with. Fork
maps the child from that list, so after mprotect RW to R, or RW to
PROT_NONE, the child got the span read-write: a write the parent could
not make succeeded in the child.

Every protection change on a backed span goes through protect_span, and
protect_span now records the new write, exec and access on exactly the
part it changed, splitting the region around it and keeping whether the
bytes were proved. Fork maps the child with what the parent has now.

protfork proves it: RW to R, RW to PROT_NONE, RW to R to RW, and one
page closed in the middle of three, each checked in a forked child.
A MAP_FIXED mmap over pages the guest already had kept them: MkPeerMap
skips a page that is present, so the old bytes and the old protection
stayed, and the region list held the old span and the new one on top of
each other. A program that wrote a page, mapped PROT_NONE over it with
MAP_FIXED and opened it again read its old bytes, where Linux gives
zeroes, and fork copied the doubled span twice.

MAP_FIXED now unmaps the target span just before the new pages go in,
as Linux replaces a mapping. It is done after every refusal the mapping
can meet, so a refused mapping leaves the old one in place.

touchfork proves it with the reservation cases around it: bytes written
into an opened part of a reservation reach a forked child, still do
after the part is closed and opened again in the child, a page never
opened faults in the child, and MAP_FIXED PROT_NONE over a written page
reads zero once opened.
mmap took any nonzero address as fixed, hint or not, and the mapping
cursor took the next address above the last mapping it chose without
looking at what the guest had mapped there itself. Either way a new
mapping could land on an old one: MkPeerMap skips a present page, so the
guest got the old bytes back as its new, zeroed mapping, and the region
list held both. MAP_FIXED_NOREPLACE was taken as a hint and mapped over
whatever was there.

Placement now follows Linux. MAP_FIXED is the exact address;
MAP_FIXED_NOREPLACE is the exact address or EEXIST when anything is
there; any other address is a hint, rounded up to a page and used only
when nothing is there. Otherwise the cursor chooses and skips every span
the guest holds, and moves past a mapping only when it chose it.
brk mapped from the old break itself, which is rarely page aligned, so
every call that grew the break pushed a second region for the page the
break already sat in, and fork copied that page once per call. A lower
break only moved the number: the pages above it stayed mapped, and
growing again handed back the old bytes where Linux gives zeroes. And a
break that grew into a mapping the guest had placed in the heap area
with MAP_FIXED mapped over it.

The break now grows from the page above the old one, is refused, as
Linux refuses it, when that span meets a mapping, and a lower break
unmaps the whole pages above it.
mremap gave the part it grew read-write whatever the mapping was, so a
PROT_NONE or read-only mapping grew a writable tail. A move read the old
bytes and failed with EFAULT on a reservation, which has none; restored
only read-only or read-write on the copy, so a PROT_NONE mapping came
back readable; left the new span mapped when the copy failed; and
dropped the mark that the bytes came from a file nothing proved, so the
moved copy could then be made executable with mprotect, past the check
that refuses it where the file was mapped. It also accepted an old span
running across mappings with different protections.

The grown part and the moved copy are now held like the mapping they
came from: its protection, its backing and its provenance. A reservation
grows or moves as a reservation with no bytes to copy. A move that fails
unmaps what it made. An old span that is not one mapping is refused with
EFAULT, as Linux refuses it. The move goes to a free span found the same
way mmap finds one.
munmap and mprotect rounded an address that was not on a page boundary
down to one, and so acted on bytes below the address the guest named.
mmap did the same for a MAP_FIXED address, and took a file offset that
was not on a page boundary. Linux answers all four with EINVAL.

They now answer EINVAL. Only a length is rounded, and a hint address
without MAP_FIXED, as on Linux.
A guest calling mlock, mlock2, munlock, mlockall, munlockall, msync or
mincore got an unserved line and ENOSYS, where Linux answers each.

In this memory model every page a guest holds is resident from the
commit that maps it until it is unmapped, and nothing is paged out. So
the lock calls have nothing left to do but Linux's argument checks: the
span must be held (ENOMEM), the flags known (EINVAL), and RLIMIT_MEMLOCK
is already reported unlimited. Every file mapping is private, since
MAP_SHARED of a file is refused, so msync has nothing to write back and
answers its checks alone, ENOMEM for a span with a hole included.
mincore answers from the region list, one byte per page: 1 for a backed
page, whatever its protection, and 0 for a reservation page, which has
no frame.

memcalls proves these and the placement, brk, alignment and mremap
changes before it, one line per part.
With the gopoll, gopreempt and cwait guests enrolled beside the five
memory proof guests, the guest-test store no longer loaded:
nonos-store-pack counted 18039927 payload bytes against the vfs budget
of 16777216 and refused it, so no guest could boot. Each guest in the
store is its binary plus its certificate, manifest and a 327 KiB proof
trailer, and the five memory guests came to 1907576 bytes of it, 1683520
of them proofs.

The five proofs are now one program, memproof, whose first argument names
the proof: guardpage, protnone, protfork, touchfork or memcalls. Each
proof keeps its own file and is built with its main renamed, so it runs
exactly as it did as a program of its own. memcalls maps its own file for
the provenance part, which is now /bin/memproof. The ids 4982 to 4989 are
free again.
Nine files this branch touches ran past 75 lines, from 76 for map.rs to
224 for memcalls.c, and the comments added in the kernel, personality
and guest files were written with // where the rule is /* */.

Each long file is split along a line it already had: the three refusals
of a demand fill go to faults/demand_refuse.rs; perms_of moves into
peer_protect.rs; reserve and map_span leave mem_map.rs; the mprotect
walk, the mremap one-mapping check, the mmap kind and the cursor search
each get a file. The memory proofs share one file for printing, running
a part in a child and counting, and memcalls is split by call family.
call/mod.rs is back to its own export line and makes mem public, so
the lock, msync and mincore calls are named call::mem::... in the
table. No behaviour changes.
Fork mapped each span into the child with one MkPeerMap over the whole
span. The kernel refuses a peer call longer than 1 MiB, so forking a
guest that held a larger span, as a Go heap is, failed. Another branch
fixes that by mapping in pieces, but maps each piece with write and exec
only, so a span the parent closed with PROT_NONE comes back open in the
child.

Fork now maps and copies each span a megabyte at a time, and gives every
piece the protection the span has now, PROT_NONE included. The proof
helper also counts a fork that fails as a failed part; it used to wait
on nothing and report a pass. protfork gains two parts on a 2 MiB
mapping: read-write, with the child reading the last page, and closed
with PROT_NONE, where the child's read must fault.
A guest that turns the isolation proofs around: instead of showing a
mapping behaves, it attacks its own confinement through the syscalls it
has. It maps at the kernel half, wraps a span, asks for a write-and-
execute page, adds execute to a file it never proved, calls a number the
personality does not serve, and steps into an unmapped hole. Each part
passes when the machine refuses.

It is a memproof part, so it shares the one guest binary and its store
trailer. On NONOS all seven are refused. On native Linux two are allowed,
a write-and-execute mapping and adding execute to an unproven file, which
are the two the personality enforces and stock Linux does not.
This pull request has changes conflicting with the target branch.
  • userland/linux_guests/Guests.mk
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin +refs/pull/137/head:nonos-sync/gh-586
git switch nonos-sync/gh-586

Merge

Merge the changes and update on Forgejo.
git switch linux/go-guests
git merge --no-ff nonos-sync/gh-586
git switch nonos-sync/gh-586
git rebase linux/go-guests
git switch linux/go-guests
git merge --ff-only nonos-sync/gh-586
git switch nonos-sync/gh-586
git rebase linux/go-guests
git switch linux/go-guests
git merge --no-ff nonos-sync/gh-586
git switch linux/go-guests
git merge --squash nonos-sync/gh-586
git switch linux/go-guests
git merge --ff-only nonos-sync/gh-586
git switch linux/go-guests
git merge nonos-sync/gh-586
git push origin linux/go-guests
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
NON-OS/nonos-micro-kernel!137
No description provided.