Skip to content

Commit f896601

Browse files
committed
feat: /etc/passwd-based exploit
1 parent b9f8a74 commit f896601

5 files changed

Lines changed: 364 additions & 50 deletions

File tree

.github/workflows/build.yml

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,22 @@ jobs:
4545
fi
4646
- name: verify
4747
run: |
48-
file payload exploit
48+
file payload exploit exploit-passwd
4949
echo "payload: $(stat -c%s payload) bytes"
5050
echo "exploit: $(stat -c%s exploit) bytes"
51-
- name: upload binary
51+
echo "exploit-passwd: $(stat -c%s exploit-passwd) bytes"
52+
- name: upload binaries
5253
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
5354
with:
5455
name: copy-fail-c-${{ matrix.target.arch }}-glibc
55-
path: exploit
56+
path: |
57+
exploit
58+
exploit-passwd
5659
retention-days: 90
5760

58-
# zig cc + musl-static. Produces deploy-anywhere binaries (no glibc
59-
# version dependency, no dynamic loader). zig ships musl libc for every
60-
# target; we still need GNU binutils-<arch>-cross for the
61+
# zig cc + musl-static. Produces deploy-anywhere binaries with a fully
62+
# static link and no runtime libc version dependency. zig ships musl libc
63+
# for every target; we still need GNU binutils-<arch>-cross for the
6164
# `ld -r -b binary` step that wraps the payload as a relocatable .o,
6265
# plus kernel UAPI headers for nolibc and linux/if_alg.h.
6366
build-musl:
@@ -98,32 +101,35 @@ jobs:
98101
UAPI_LINUX_DIR=${{ matrix.target.uapi_base }}/linux \
99102
UAPI_ASM_GENERIC_DIR=${{ matrix.target.uapi_base }}/asm-generic \
100103
UAPI_ASM_DIR=${{ matrix.target.uapi_asm }}
101-
- name: strip binary
104+
- name: strip binaries
102105
env:
103106
LD_PREFIX: ${{ matrix.target.ld_prefix }}
104107
run: |
105108
STRIP=strip
106109
if [ -n "$LD_PREFIX" ]; then
107110
STRIP="${LD_PREFIX}-strip"
108111
fi
109-
BEFORE=$(stat -c%s exploit)
110-
"$STRIP" --strip-all exploit
111-
AFTER=$(stat -c%s exploit)
112-
echo "stripped: $BEFORE -> $AFTER bytes"
112+
for bin in exploit exploit-passwd; do
113+
BEFORE=$(stat -c%s "$bin")
114+
"$STRIP" --strip-all "$bin"
115+
AFTER=$(stat -c%s "$bin")
116+
echo "$bin stripped: $BEFORE -> $AFTER bytes"
117+
done
113118
- name: verify
114119
run: |
115-
file payload exploit
120+
file payload exploit exploit-passwd
116121
echo "payload: $(stat -c%s payload) bytes"
117122
echo "exploit: $(stat -c%s exploit) bytes"
118-
- name: upload binary
123+
echo "exploit-passwd: $(stat -c%s exploit-passwd) bytes"
124+
- name: upload binaries
119125
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
120126
with:
121127
name: copy-fail-c-${{ matrix.target.arch }}-musl
122-
path: exploit
128+
path: |
129+
exploit
130+
exploit-passwd
123131
retention-days: 90
124132

125-
# Tagged releases attach all twelve binaries to a GitHub Release.
126-
# Triggered only on `v*` tag pushes; ordinary main pushes skip this job.
127133
release:
128134
if: startsWith(github.ref, 'refs/tags/v')
129135
needs: [build-glibc, build-musl]
@@ -143,8 +149,10 @@ jobs:
143149
mkdir -p release
144150
for d in artifacts/copy-fail-c-*; do
145151
base=$(basename "$d")
152+
passwd_name="${base/copy-fail-c-/copy-fail-c-passwd-}"
146153
cp "$d/exploit" "release/$base"
147-
chmod +x "release/$base"
154+
cp "$d/exploit-passwd" "release/$passwd_name"
155+
chmod +x "release/$base" "release/$passwd_name"
148156
done
149157
ls -la release/
150158
- name: create release
@@ -162,8 +170,19 @@ jobs:
162170
163171
## Binaries
164172
165-
Each binary is statically linked. Download the one matching the target's
166-
architecture and either libc variant.
173+
Each binary is statically linked. Download the one matching the
174+
target's architecture, libc, and exploit variant.
175+
176+
Exploit variants:
177+
178+
* \`copy-fail-c-<arch>-<libc>\`: binary-mutation variant. Mutates
179+
a setuid binary's page cache, then execs it.
180+
* \`copy-fail-c-passwd-<arch>-<libc>\`: /etc/passwd UID-flip variant.
181+
Mutates four bytes of /etc/passwd's page cache, then execs `su`.
182+
Works where the binary-mutation route is blocked but has a
183+
narrower cashout surface; see README for details.
184+
185+
Build modes:
167186
168187
* \`-glibc\` builds: GNU cross-toolchain, glibc-static. Larger (~800 KB)
169188
but functionally identical to source-built binaries on a glibc system.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# build artifacts
22
exploit
3+
exploit-passwd
34
payload
45
payload.o
56
*.o

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ PAYLOAD_CFLAGS ?= $(PAYLOAD_BASE_CFLAGS) $(PAYLOAD_PACK_LDFLAGS)
6060

6161
.PHONY: all clean info musl-shim musl-static zig-musl-static FORCE
6262

63-
all: exploit
63+
all: exploit exploit-passwd
6464

6565
# musl-static: tiny (~55 KB exploit + ~1.5 KB payload), no glibc dependency.
6666
# Requires musl-tools and linux-libc-dev. musl-gcc isolates its include path
@@ -119,6 +119,11 @@ payload-abi.o: FORCE
119119
exploit: exploit.c payload.o
120120
$(CC) $(CFLAGS) $(LDFLAGS) -static -o $@ $^
121121

122+
# /etc/passwd UID-flip variant. No payload needed; mutates four ASCII bytes
123+
# of /etc/passwd's page cache to flip the user's UID to "0000", then execs su.
124+
exploit-passwd: exploit-passwd.c
125+
$(CC) $(CFLAGS) $(LDFLAGS) -static -o $@ $<
126+
122127
info: payload payload.o
123128
@echo "=== payload size ==="
124129
@stat -c '%n: %s bytes' payload
@@ -131,4 +136,4 @@ info: payload payload.o
131136
@readelf -S payload | grep -E 'Name|\.text|\.rodata|\.data|\.bss' | head -10
132137

133138
clean:
134-
rm -rf exploit payload payload.o payload-abi.o .musl-shim
139+
rm -rf exploit exploit-passwd payload payload.o payload-abi.o .musl-shim

README.md

Lines changed: 62 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ Discovery and original disclosure: Theori / Xint.
1717

1818
```
1919
copy-fail-c/
20-
├── exploit.c the dropper (AF_ALG + splice page-cache mutation)
20+
├── exploit.c the dropper (binary-mutation variant)
21+
├── exploit-passwd.c the dropper (/etc/passwd UID-flip variant)
2122
├── payload.c the body that gets dropped (setgid+setuid+execve sh)
2223
├── Makefile build orchestration
2324
├── nolibc/ vendored from torvalds/linux tools/include/nolibc
@@ -29,7 +30,8 @@ After `make`:
2930
```
3031
├── payload tiny static ELF, embedded into the dropper as bytes
3132
├── payload.o payload wrapped as a relocatable .o by `ld -r -b binary`
32-
└── exploit final dropper binary
33+
├── exploit dropper, binary-mutation variant
34+
└── exploit-passwd dropper, /etc/passwd UID-flip variant
3335
```
3436

3537
`exploit.c` opens the target binary read-only, then for each 4-byte window of
@@ -47,6 +49,11 @@ setuid root, so the kernel grants root credentials and runs the payload.
4749
...)`. nolibc supplies the `_start`, the syscall machinery, and the per-arch
4850
register-juggling.
4951

52+
A second variant, `exploit-passwd.c`, mutates four bytes of /etc/passwd's page
53+
cache instead of a setuid binary's image. It needs no embedded payload and
54+
works on systems where the binary-mutation route is blocked, but its cashout
55+
surface is much narrower.
56+
5057

5158
## Build
5259

@@ -112,20 +119,66 @@ _binary_payload_size absolute symbol whose value is the size in bytes
112119
```
113120

114121
`exploit.c` declares the first two as `extern const unsigned char[]` and
115-
computes the size as `_binary_payload_end - _binary_payload_start`. There is
116-
no `xxd -i`-generated header file, no embedded array literal, no source
117-
regeneration step. The payload bytes pass through the build as a real
118-
linker artifact.
122+
computes the size as `_binary_payload_end - _binary_payload_start`.
119123

120124
### `-Wl,-N` plus tight `max-page-size`
121125

122126
The payload is statically linked with `-Wl,-N -Wl,-z,max-page-size=0x10`,
123127
which collapses `.text`/`.rodata`/`.data` into a single LOAD segment with
124128
16-byte file-alignment instead of the kernel-page-aligned 4 KB-per-segment
125129
default. This produces an "RWX permissions" warning from `ld`, which is
126-
informational only - the payload's runtime memory protection doesn't matter
127-
to its single-purpose program. Without this flag, the same code links to ~13
128-
KB on x86_64 (mostly inter-segment zero padding); with it, ~1.7 KB.
130+
informational only - the payload's runtime memory protection doesn't matter to
131+
its single-purpose program. Without this flag, the same code links to ~13 KB
132+
on x86_64 (mostly inter-segment zero padding); with it, ~1.7 KB.
133+
134+
135+
## Variants and cashout viability
136+
137+
This repository ships two exploit variants that share the AF_ALG/splice
138+
page-cache mutation primitive but cash out into root execution differently.
139+
Their reliability profiles are not equivalent, and the difference matters
140+
when reasoning about real-world threat models.
141+
142+
### Binary-mutation variant (`exploit`)
143+
144+
Mutates the page cache of a target setuid binary with the embedded payload
145+
bytes, then execs the binary. The kernel grants root credentials from the
146+
binary's untouched on-disk setuid bit, loads the corrupted in-memory image,
147+
and runs the payload.
148+
149+
Works wherever the attacker can `open(target, O_RDONLY)` for any root-setuid
150+
binary on the system. More or less defeated by environments that gate setuid
151+
binaries behind restricted-read directories and by setuid-free system designs.
152+
153+
### /etc/passwd UID-flip variant (`exploit-passwd`)
154+
155+
Mutates four bytes of /etc/passwd's page cache to set the running user's UID
156+
field to "0000". /etc/passwd is world-readable on every standard Linux system,
157+
so the *mutation* is universal. Translating it into root execution depends on
158+
some root-side process resolving the user via getpwnam/getpwuid and acting on
159+
the resolved uid without cross-validation. Many such consumers exist; many of
160+
them defensively cross-check against the kernel's view of the calling uid or
161+
against on-disk file ownership, breaking the cashout.
162+
163+
#### Cashout viability matrix
164+
165+
| Cashout | Pre-root setup needed | Notes |
166+
|---|---|---|
167+
| WSL2 session spawn | No | WSL's per-session `setuid(getpwnam(default_user)->pw_uid)` does no validation. Works cleanly. |
168+
| util-linux `su` | No | Permissive caller-identity handling. |
169+
| shadow-utils `su` | Yes | `getpwuid(getuid())` caller-identity check fails because the mutation unmaps the real uid. |
170+
| sshd (default `StrictModes yes`) | Yes (disable StrictModes) | StrictModes requires the home dir to be owned by root or `pw->pw_uid`. Mutation makes pw_uid=0; on-disk owner stays at original uid; mismatch refuses auth. |
171+
| MTA local delivery (postfix, exim, etc.) | Variable | Depends on the MDA's home-perm validation. Test per MTA. |
172+
173+
#### Pivoting after `su` fails
174+
175+
`exploit-passwd` execs `su <user>` after mutating, as the simplest possible
176+
cashout. That works against util-linux `su` but fails against shadow-utils `su`
177+
with "Cannot determine your user name." The page cache mutation is still in
178+
place at that point, and pivoting to any other cashout (e.g. using a daemon
179+
resolving users via getpwnam without cross-checking) is possible at that point.
180+
Run `echo 3 > /proc/sys/vm/drop_caches` as root to clear the corrupted page
181+
cache when done testing.
129182

130183

131184
## Affected kernels
@@ -154,26 +207,6 @@ disclosure. To verify whether a target kernel is in-window, check whether
154207
git log or the distro's changelog.
155208

156209

157-
## Verification
158-
159-
Local cross-arch sanity check via qemu-user-static:
160-
161-
```sh
162-
sudo apt install qemu-user-static gcc-aarch64-linux-gnu binfmt-support
163-
make clean
164-
make CC=aarch64-linux-gnu-gcc LD=aarch64-linux-gnu-ld
165-
file payload # ELF 64-bit LSB executable, ARM aarch64
166-
echo 'id; exit' | ./payload # runs via binfmt_misc -> qemu-aarch64-static
167-
```
168-
169-
This confirms the per-arch syscall asm in `nolibc/arch-arm64.h` dispatches
170-
correctly and that the build pipeline cross-compiles cleanly. It does not
171-
exercise the kernel-side AF_ALG/splice primitive on an aarch64 kernel,
172-
because qemu-user-static forwards syscalls to the host kernel. For full
173-
kernel-level verification on a foreign arch, use `qemu-system-aarch64` with
174-
a vulnerable cloud image, or a real aarch64 host.
175-
176-
177210
## License and credits
178211

179212
Discovery and original disclosure of CVE-2026-31431: Theori / Xint.

0 commit comments

Comments
 (0)