From 2465b728de49993e01916e83520af62147a176f3 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 12 May 2026 20:18:44 +0000 Subject: [PATCH 01/37] draft Co-authored-by: Copilot --- pentest/DESIGN.md | 220 +++++ pentest/README.md | 41 + pentest/lib/common.sh | 71 ++ pentest/phase2_listener/run.sh | 86 ++ pentest/phase3_authn_authz/run.sh | 115 +++ pentest/phase4_rules_fuzz/url_diff.py | 85 ++ .../__pycache__/run.cpython-312.pyc | Bin 0 -> 30004 bytes pentest/phase4b_local_rules/run.py | 862 ++++++++++++++++++ pentest/phase5_state_fs/audit.sh | 57 ++ pentest/results/bpftool_cgroup_tree.txt | 2 + pentest/results/bpftool_prog_show.txt | 0 pentest/results/findings.tsv | 51 ++ pentest/results/phase3-20260512T184948Z.pcap | Bin 0 -> 19642 bytes pentest/results/phase4b_local_rules.log | 1 + pentest/results/run.log | 81 ++ pentest/results/url_diff.tsv | 11 + pentest/run_all.sh | 17 + 17 files changed, 1700 insertions(+) create mode 100644 pentest/DESIGN.md create mode 100644 pentest/README.md create mode 100644 pentest/lib/common.sh create mode 100755 pentest/phase2_listener/run.sh create mode 100755 pentest/phase3_authn_authz/run.sh create mode 100755 pentest/phase4_rules_fuzz/url_diff.py create mode 100644 pentest/phase4b_local_rules/__pycache__/run.cpython-312.pyc create mode 100755 pentest/phase4b_local_rules/run.py create mode 100755 pentest/phase5_state_fs/audit.sh create mode 100644 pentest/results/bpftool_cgroup_tree.txt create mode 100644 pentest/results/bpftool_prog_show.txt create mode 100644 pentest/results/findings.tsv create mode 100644 pentest/results/phase3-20260512T184948Z.pcap create mode 100644 pentest/results/phase4b_local_rules.log create mode 100644 pentest/results/run.log create mode 100644 pentest/results/url_diff.tsv create mode 100755 pentest/run_all.sh diff --git a/pentest/DESIGN.md b/pentest/DESIGN.md new file mode 100644 index 00000000..13588fd6 --- /dev/null +++ b/pentest/DESIGN.md @@ -0,0 +1,220 @@ +# GPA Pen-Test Design + +Target: Guest Proxy Agent (GPA) running on this Azure VM +(kernel 6.11, listener `127.0.0.1:3080`, eBPF redirecting IMDS +`169.254.169.254:80`, WireServer `168.63.129.16:80`, HostGAPlugin +`168.63.129.16:32526`). + +--- + +## 1. Scope & Trust Model + +### In scope (the GPA enforcement boundary) +- TCP listener `127.0.0.1:3080` (the proxy itself). +- eBPF redirect program attached at the cgroup root. +- The HMAC signing path (`x-ms-azure-signature`) injected by GPA on authorized requests. +- AuthZ engine in [proxy_authorizer.rs](../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state in `/var/lib/azure-proxy-agent/keys` and config in [GuestProxyAgent.linux.json](../proxy_agent/config/GuestProxyAgent.linux.json). +- Provisioning, status, and log surfaces under `/var/log/azure-proxy-agent`. +- The handler/service binaries from the extension ([proxy_agent_extension/](../proxy_agent_extension/), [proxy_agent_setup/](../proxy_agent_setup/)). + +### Out of scope +- WireServer/IMDS server-side responses. +- The host fabric. +- Kernel CVEs unrelated to GPA. +- Azure control plane. + +### Attacker models to test against +1. **Unprivileged guest user** (no sudo) — primary threat model: confused-deputy / SSRF. +2. **Privileged guest user** (root in a container, root on host) — GPA still must not be a privilege oracle for WireServer if not allowed. +3. **Adjacent container / cgroup-namespaced workload** — can it bypass cgroup-attached eBPF? +4. **Network-adjacent attacker** (other VMs on same vNet / DHCP) — must not authenticate. +5. **Local malicious binary with bind-mount / path tricks** — can it forge `processFullPath`? + +--- + +## 2. Pen-Test Scenarios (mapped to GPA invariants) + +### A. Network exposure / listener hardening +| ID | Test | Expected | +|----|------|----------| +| A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | +| A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | +| A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | +| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. | Rejected; no proxying to arbitrary hosts. | +| A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | + +### B. AuthN bypass — forging the signature +| ID | Test | Expected | +|----|------|----------| +| B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | +| B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | +| B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | +| B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | + +### C. AuthZ bypass — cgroup / eBPF audit spoofing +| ID | Test | Expected | +|----|------|----------| +| C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | +| C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | +| C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | +| C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | +| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. | Connection still redirected (or blocked). If not — finding. | +| C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | +| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). | Either redirected the same, or refused. No bypass. | + +### D. AuthZ rule engine fuzzing +Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) + +| ID | Test | Expected | +|----|------|----------| +| D1 | URL parsing differentials: `%2F` vs `/`, `%2f`, double-encoding, `;param`, `..`, trailing dot in host, mixed case scheme. | Rule match parity with server-side; no bypass of deny rule via encoding. | +| D2 | Path-traversal in `processFullPath` matcher (symlink to allowed binary). | Deny based on resolved path / inode. | +| D3 | Identity case-sensitivity, Unicode normalization in user names / process names. | Stable match. | +| D4 | Property-based fuzz of the rule loader with malformed `AuthorizationRules_*.json` — ensure invalid JSON / huge file = reject, default-closed. | Service stays up; falls back to safe defaults. | +| D5 | TOCTOU between rule reload and an in-flight request. | Authorization re-checked per request. | + +### E. Key-keeper / provisioning +| ID | Test | Expected | +|----|------|----------| +| E1 | Permissions/ownership audit on `/var/lib/azure-proxy-agent/keys`, `/var/log/azure-proxy-agent`, the binary, the unit file, drop-ins. | Owned by root, mode 0700/0600 on secrets, no world-write. | +| E2 | Symlink attack: pre-create the keys/log path as a symlink to `/etc/shadow` before service start. | Service refuses to follow / aborts. | +| E3 | Disk-full / read-only `/var` — fault injection. | Service degrades safely; does **not** drop to "open" mode. | +| E4 | Kill `azure-proxy-agent` and observe whether eBPF redirect remains and whether traffic now reaches IMDS unauthenticated. | Either redirect persists denying traffic, or systemd restarts; no "fail-open" window allowing forged calls. | +| E5 | Downgrade the latched key (replay an old `keys/*` file). | Rejected by HostGAPlugin / refresh logic. | + +### F. Local IPC / status surface +| ID | Test | Expected | +|----|------|----------| +| F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | +| F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | +| F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | + +### G. Resource / DoS +| ID | Test | Expected | +|----|------|----------| +| G1 | Connection flood to `127.0.0.1:3080` from many PIDs. | Throttled; CPU/Memory cap from systemd drop-ins (`50-MemoryMax.conf`) kicks in; service survives. | +| G2 | Large-body POST / very long URL. | Bounded; rejected with 4xx. | +| G3 | Many distinct cgroups generating audit entries — exercise `redirector::lookup_audit` map size. | No unbounded growth; LRU/eviction. | +| G4 | Crash recovery: SIGKILL → systemd restart loop. | Restart succeeds; no orphan eBPF programs each restart (check `bpftool prog show`). | + +### H. Update / extension handler +| ID | Test | Expected | +|----|------|----------| +| H1 | `proxy_agent_setup` backup/restore tampering; supply a malicious previous-version archive. | Signature/manifest verification rejects. | +| H2 | Extension handler ([handler_main.rs](../proxy_agent_extension/src/handler_main.rs)) command-injection in settings / env. | Sanitized. | + +--- + +## 3. Execution Plan + +### Phase 0 — Prep (read-only, ~30 min) +- Snapshot the VM (or at least `/var/lib/azure-proxy-agent` and `/var/log/azure-proxy-agent`). +- Record baseline: `bpftool prog show`, `bpftool cgroup tree`, `ss -tnlp`, `systemctl cat azure-proxy-agent`, file modes under `/var/lib/azure-proxy-agent`, `/usr/sbin/azure-proxy-agent` hashes. +- Save current `AuthorizationRules_*.json` and `status.json`. + +### Phase 1 — Passive recon (no traffic to fabric) +- Run scenarios A1, E1, F1, F2 prep. +- Static review of [proxy_authorizer.rs](../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) for the URL/identity matchers you'll exercise in D. + +### Phase 2 — Localhost / listener tests +- A1–A5, G1–G2 against `127.0.0.1:3080`. +- Tools: `nmap`, `curl`, `httpx`, `wrk`, custom Python (`socket`, `h2`, `scapy`). + +### Phase 3 — AuthN/AuthZ functional tests (with fabric) +- B1–B4, C1–C7 from the guest. Use a **non-root** test account, then a **root** account, then a **container** (`docker run --rm -it --network host alpine`) and a **user-namespace** (`unshare -Urn`) variant. +- Capture wire traffic with `tcpdump -i lo -i eth0 -w pen.pcap host 169.254.169.254 or host 168.63.129.16 or port 3080`. +- Verify by checking server response codes (200 vs 401/403) and the GPA `ProxyAgent.Connection.log` decision. + +### Phase 4 — Rule-engine fuzz +- D1–D5. Build a small Rust harness (cargo test inside the repo) plus a black-box URL fuzzer (`radamsa` or `boofuzz`) hitting `:3080` once an allow-rule is in place, then once with a deny-rule, and diff. + +### Phase 4b — Local-file authorization rules (`useLocalFileRules`) + +Automated by [pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py). + +**Pre-condition.** The agent only consumes `/var/lib/azure-proxy-agent/rules/{IMDS,WireServer}_Rules.json` when the rule ID delivered by the fabric (HostGAPlugin) is a base64-encoded JSON whose `useLocalFileRules` field is `true`. The harness verifies this at startup by checking the latest `AuthorizationRules_*.json` snapshot under `/var/log/azure-proxy-agent` for the marker `useLocalFileRules-true`. If the marker isn't present it prints a `PRE FAIL` and exits — flip the flag from your control plane / mock fabric and rerun. + +**Operating model.** Each scenario: +1. atomically writes a crafted `IMDS_Rules.json` or `WireServer_Rules.json` (mode 0600 root) into the rules dir, +2. waits `pollKeyStatusIntervalInSeconds + 5s` for the agent's next refresh, +3. fires a probe matrix and asserts the GPA-returned status code, +4. moves on to the next scenario; backups of any pre-existing rule files are restored on exit (including on `KeyboardInterrupt` / crash). + +#### IMDS scenarios (target `imds`) + +| ID | Rule shape | Probes & expected status | +|----|------------|--------------------------| +| `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | +| `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | +| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules | `/metadata/instance` → **200** (audit-only) | +| `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | +| `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | +| `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | +| `IMDS-S7-query-param-required` | Privilege requires `api-version=2021-02-01` | matching → 200, missing → 403, wrong → 403 | +| `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | +| `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | +| `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | + +#### WireServer scenarios (target `wireserver`) + +WireServer is privileged-by-default at the proxy layer (`runAsElevated` required), so the harness must run as root. Probes use `x-ms-version: 2012-11-30` and the standard goal-state endpoints: + +``` +/machine/?comp=goalstate +/machine/?comp=hostingenvironmentconfig +/machine/?comp=sharedConfig +/machine/?comp=certificates +/machine/?comp=extensionsConfig +/?comp=versions +``` + +| ID | Rule shape | Probes & expected status | +|----|------------|--------------------------| +| `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | +| `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | +| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules | goalstate → **200** (audit-only) | +| `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | +| `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | +| `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | +| `WS-S7-query-param-required` | Require `comp=goalstate` | matching → 200, no `comp` → 403, `comp=hostingenvironmentconfig` → 403, `comp=goalstate&incarnation=1` → 200 | +| `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | +| `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | +| `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | + +#### Invocation + +```bash +sudo phase4b_local_rules/run.py # both targets, all scenarios +sudo phase4b_local_rules/run.py --target imds # only IMDS matrix +sudo phase4b_local_rules/run.py --target wireserver # only WireServer matrix +sudo phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass +sudo phase4b_local_rules/run.py --poll 10 # override refresh wait +``` + +Findings are appended to `pentest/results/findings.tsv`; a per-run console transcript lands in `pentest/results/phase4b_local_rules.log`. + +**Triage rules.** +- Any `*S2`, `*S6`, `*S10` `FAIL` is a high-severity AuthZ-bypass / fail-open finding. +- `*S4`, `*S5`, `*S7` failures indicate identity/path/query matcher regressions. +- `*S9` failures (where `python_caller_denied` returns 200 or `curl_caller_allowed` returns 403) indicate the per-process identity provider is broken on this kernel. + +### Phase 5 — State / FS / persistence +- E1–E5, F2–F3, H1–H2. Requires sudo and at least one service restart — do this last. + +### Phase 6 — Resilience / DoS +- G1–G4. Watch `systemd-cgtop`, `journalctl -fu azure-proxy-agent`, and `bpftool prog show` for prog leaks across restarts. + +### Phase 7 — Triage & report +- For each finding: PoC, severity (CVSS), affected file/line, suggested fix, whether already mitigated upstream. + +--- + +## 4. Tooling staged on this box + +```bash +sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace +cargo install --locked httpx-cli # optional +``` + +Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). diff --git a/pentest/README.md b/pentest/README.md new file mode 100644 index 00000000..33a7df75 --- /dev/null +++ b/pentest/README.md @@ -0,0 +1,41 @@ +# GPA Pen-Test Harness + +Local pen-test scaffolding for the Guest Proxy Agent running on this VM. + +## Layout + +- `lib/common.sh` — shared helpers (endpoints, logging, result recording). +- `phase2_listener/` — localhost listener / DoS / smuggling probes (Scenarios A, G). +- `phase3_authn_authz/` — AuthN/AuthZ matrix (Scenarios B, C). +- `phase4_rules_fuzz/` — URL/encoding differentials against the rule engine (Scenario D). +- `phase5_state_fs/` — filesystem permissions, key-keeper, log injection (Scenarios E, F). +- `run_all.sh` — convenience driver; runs phases 2 & 3 by default. +- `results/` — populated at runtime: `findings.tsv`, captured pcaps, raw output. + +## Prereqs + +```bash +sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace curl +``` + +The harness assumes: +- `azure-proxy-agent.service` is active. +- Listener is at `127.0.0.1:3080`. +- Fabric endpoints `169.254.169.254:80`, `168.63.129.16:{80,32526}` are reachable. + +## Safety + +- Phase 2 sends only loopback traffic. +- Phase 3 sends real requests to the Azure fabric — keep volumes low. +- Snapshot the VM (or back up `/var/lib/azure-proxy-agent` and `/var/log/azure-proxy-agent`) before Phase 5. +- Do **not** run Phase 5 fault-injection tests on a production VM. + +## Result format + +Each test appends one TSV row to `results/findings.tsv`: + +``` +\t\t\t +``` + +A `FAIL` means the GPA invariant was violated (potential finding) and warrants triage. diff --git a/pentest/lib/common.sh b/pentest/lib/common.sh new file mode 100644 index 00000000..df43aa7a --- /dev/null +++ b/pentest/lib/common.sh @@ -0,0 +1,71 @@ +# Shared helpers for GPA pen-test scripts. Source, don't execute. +# shellcheck shell=bash + +set -uo pipefail + +PENTEST_ROOT="${PENTEST_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +RESULTS_DIR="${RESULTS_DIR:-$PENTEST_ROOT/results}" +mkdir -p "$RESULTS_DIR" +FINDINGS="$RESULTS_DIR/findings.tsv" + +# Endpoints +GPA_HOST="127.0.0.1" +GPA_PORT="3080" +IMDS_IP="169.254.169.254" +IMDS_PORT="80" +WIRESERVER_IP="168.63.129.16" +WIRESERVER_PORT="80" +HOSTGA_IP="168.63.129.16" +HOSTGA_PORT="32526" + +GPA_LOG_DIR="/var/log/azure-proxy-agent" +GPA_KEY_DIR="/var/lib/azure-proxy-agent/keys" + +color() { # $1=color $2=text + case "$1" in + red) printf '\033[31m%s\033[0m' "$2";; + green) printf '\033[32m%s\033[0m' "$2";; + yellow) printf '\033[33m%s\033[0m' "$2";; + *) printf '%s' "$2";; + esac +} + +ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } + +# record TEST_ID STATUS MSG +record() { + local id="$1" status="$2" msg="${3:-}" + printf '%s\t%s\t%s\t%s\n' "$(ts)" "$id" "$status" "$msg" >> "$FINDINGS" + case "$status" in + PASS) echo "[$(color green PASS)] $id $msg" ;; + FAIL) echo "[$(color red FAIL)] $id $msg" ;; + INFO) echo "[$(color yellow INFO)] $id $msg" ;; + *) echo "[$status] $id $msg" ;; + esac +} + +# require_cmd cmd... +require_cmd() { + local missing=() + for c in "$@"; do command -v "$c" >/dev/null 2>&1 || missing+=("$c"); done + if (( ${#missing[@]} )); then + echo "Missing commands: ${missing[*]}" >&2 + return 1 + fi +} + +gpa_running() { + systemctl is-active --quiet azure-proxy-agent +} + +# curl_via_gpa METHOD URL [extra curl args...] +# Sends a request to a fabric host through the GPA-redirected path (no -x; rely on eBPF). +curl_via_gpa() { + local method="$1" url="$2"; shift 2 + curl -sS -o /dev/null -w '%{http_code}' -X "$method" "$url" --max-time 10 "$@" +} + +# tcp_open HOST PORT -> 0 if connection accepted within 2s +tcp_open() { + timeout 2 bash -c ">/dev/tcp/$1/$2" 2>/dev/null +} diff --git a/pentest/phase2_listener/run.sh b/pentest/phase2_listener/run.sh new file mode 100755 index 00000000..5db4a9ae --- /dev/null +++ b/pentest/phase2_listener/run.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Phase 2 — listener / DoS / smuggling probes (Scenarios A & light G). +# Loopback only. Safe to run repeatedly. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +require_cmd curl ss nc python3 || exit 1 +gpa_running || { record A0 FAIL "azure-proxy-agent is not active"; exit 1; } + +# A1 — only 127.0.0.1:3080 should be open; nothing on external IFs +external_ifs=$(ip -o -4 addr show scope global | awk '{print $4}' | cut -d/ -f1) +ext_listen=$(ss -tnlH | awk '{print $4}' | grep -E ":3080$" | grep -v '^127\.' || true) +if [[ -z "$ext_listen" ]]; then + record A1 PASS "3080 only bound to loopback (external IFs: ${external_ifs//$'\n'/ })" +else + record A1 FAIL "3080 bound on non-loopback: $ext_listen" +fi + +# A1b — confirm connect on loopback +if tcp_open 127.0.0.1 3080; then + record A1b PASS "loopback connect to 3080 OK" +else + record A1b FAIL "cannot connect to 127.0.0.1:3080" +fi + +# A1c — probe one external IP if we have one +first_ext=$(echo "$external_ifs" | head -n1) +if [[ -n "$first_ext" ]]; then + if tcp_open "$first_ext" 3080; then + record A1c FAIL "3080 reachable on external IP $first_ext" + else + record A1c PASS "3080 not reachable on external IP $first_ext" + fi +fi + +# A3 — malformed HTTP requests; service must stay up +pid_before=$(pidof azure-proxy-agent || true) +{ + printf 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'INVALIDMETHOD / HTTP/1.1\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'GET / HTTP/9.9\r\n\r\n' | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + printf 'GET /%s HTTP/1.1\r\nHost: x\r\n\r\n' "$(python3 -c 'print("A"*65000)')" | timeout 3 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 + # incomplete header (slow-loris-lite) + ( printf 'GET / HTTP/1.1\r\nHost: x\r\n'; sleep 2 ) | timeout 5 nc -q1 127.0.0.1 3080 >/dev/null 2>&1 +} || true +pid_after=$(pidof azure-proxy-agent || true) +if [[ -n "$pid_after" && "$pid_before" == "$pid_after" ]]; then + record A3 PASS "service survived malformed requests (pid=$pid_after)" +else + record A3 FAIL "service pid changed ($pid_before -> $pid_after) after malformed requests" +fi + +# A4 — CONNECT method should not turn GPA into a generic tunnel +code=$(printf 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n' \ + | timeout 4 nc -q1 127.0.0.1 3080 | head -n1 | awk '{print $2}') +case "$code" in + ""|2*) record A4 FAIL "CONNECT not refused (status='$code')" ;; + *) record A4 PASS "CONNECT refused (status='$code')" ;; +esac + +# A5 — open-proxy: explicit-proxy to an arbitrary external host +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \ + -x http://127.0.0.1:3080 http://example.com/ || echo 000) +case "$code" in + 2*) record A5 FAIL "GPA acted as open proxy to example.com (status=$code)" ;; + *) record A5 PASS "explicit-proxy to example.com refused (status=$code)" ;; +esac + +# G1 (light) — connection burst; just check service still up afterwards +pid_before=$(pidof azure-proxy-agent || true) +for i in $(seq 1 200); do + ( exec 3<>/dev/tcp/127.0.0.1/3080; echo -e "GET / HTTP/1.0\r\n\r" >&3; cat <&3 >/dev/null; exec 3<&-; ) & +done +wait 2>/dev/null +pid_after=$(pidof azure-proxy-agent || true) +if [[ -n "$pid_after" && "$pid_before" == "$pid_after" ]]; then + record G1 PASS "survived 200-conn burst (pid=$pid_after)" +else + record G1 FAIL "service pid changed after burst ($pid_before -> $pid_after)" +fi + +echo +echo "Phase 2 complete. See $FINDINGS" diff --git a/pentest/phase3_authn_authz/run.sh b/pentest/phase3_authn_authz/run.sh new file mode 100755 index 00000000..2e943fbf --- /dev/null +++ b/pentest/phase3_authn_authz/run.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Phase 3 — AuthN/AuthZ matrix (Scenarios B, C). Sends real requests to fabric. +# Keep request volumes small. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +require_cmd curl tcpdump || exit 1 +gpa_running || { record C0 FAIL "azure-proxy-agent is not active"; exit 1; } + +PCAP="$RESULTS_DIR/phase3-$(date -u +%Y%m%dT%H%M%SZ).pcap" +SUDO="" +if [[ $EUID -ne 0 ]]; then SUDO="sudo"; fi + +# Start tcpdump if we can (best-effort) +TCPDUMP_PID="" +if command -v tcpdump >/dev/null && $SUDO -n true 2>/dev/null; then + $SUDO tcpdump -i any -U -w "$PCAP" \ + "host $IMDS_IP or host $WIRESERVER_IP or (host 127.0.0.1 and port $GPA_PORT)" \ + >/dev/null 2>&1 & + TCPDUMP_PID=$! + sleep 1 + record P3-cap INFO "capturing to $PCAP (pid=$TCPDUMP_PID)" +else + record P3-cap INFO "tcpdump not started (need passwordless sudo); proceeding without pcap" +fi + +cleanup() { + if [[ -n "$TCPDUMP_PID" ]]; then $SUDO kill "$TCPDUMP_PID" 2>/dev/null || true; fi +} +trap cleanup EXIT + +# Helper: a baseline IMDS call should succeed for any user (default policy) +imds_url="http://$IMDS_IP/metadata/instance?api-version=2021-02-01" +imds_hdr="Metadata: true" + +# B1a — IMDS through redirected path: should succeed and GPA should inject signature +code=$(curl -sS -o /tmp/.gpa_imds.body -w '%{http_code}' --max-time 8 -H "$imds_hdr" "$imds_url" || echo 000) +if [[ "$code" == "200" ]]; then + record B1a PASS "IMDS reachable through GPA-redirected path (200)" +else + record B1a FAIL "IMDS call failed code=$code" +fi + +# B1b — Bypass attempt: try to talk to IMDS directly using a raw socket on a host route +# We can't easily detach from the cgroup-attached eBPF as a normal user, but we can verify +# the eBPF redirect map is applied for our cgroup by checking that our SYN goes to 127.0.0.1. +if command -v ss >/dev/null; then + ( exec 3<>/dev/tcp/$IMDS_IP/80 ) 2>/dev/null && { + peer=$(ss -tnp 2>/dev/null | grep -E ":80 .*pid=$$" | head -n1 || true) + record B1b INFO "ss snapshot of own connection: ${peer:-}" + } +fi + +# B4 — Submit a forged signature header through the proxy. GPA should overwrite/ignore it. +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \ + -H "$imds_hdr" \ + -H "x-ms-azure-signature: AAAA-attacker-supplied" \ + -H "x-ms-azure-time-tick: 0" \ + "$imds_url" || echo 000) +if [[ "$code" == "200" ]]; then + record B4 PASS "request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap)" +else + record B4 INFO "request with forged signature got code=$code (manual review)" +fi + +# B3 — Key files should not be readable by unprivileged users +if [[ -d "$GPA_KEY_DIR" ]]; then + bad=$(find "$GPA_KEY_DIR" -type f \( -perm -o+r -o -perm -g+r \) 2>/dev/null || true) + if [[ -z "$bad" ]]; then + record B3 PASS "key files in $GPA_KEY_DIR are not group/world-readable" + else + record B3 FAIL "key files readable by group/other: $bad" + fi +else + record B3 INFO "key dir $GPA_KEY_DIR not present" +fi + +# C1 — non-elevated user calling WireServer must be Forbidden +ws_url="http://$WIRESERVER_IP/machine/?comp=goalstate" +if id nobody >/dev/null 2>&1 && command -v sudo >/dev/null; then + code=$($SUDO -n -u nobody curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \ + -H "x-ms-version: 2012-11-30" "$ws_url" 2>/dev/null || echo 000) + case "$code" in + 403|401) record C1 PASS "non-elevated WireServer call denied ($code)" ;; + 200) record C1 FAIL "non-elevated WireServer call SUCCEEDED ($code) — AuthZ bypass" ;; + *) record C1 INFO "non-elevated WireServer call code=$code (inconclusive)" ;; + esac +else + record C1 INFO "skipped (need sudo + nobody account)" +fi + +# C7 — alternate IP forms should not bypass redirection/AuthZ +for alt in "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01" \ + "http://2852039166/metadata/instance?api-version=2021-02-01"; do + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 6 -H "$imds_hdr" "$alt" || echo 000) + record "C7[$alt]" INFO "code=$code (expect either same as canonical or refused; manual diff)" +done + +# C5 — cgroup-namespace evasion attempt (best-effort, requires unshare) +if command -v unshare >/dev/null; then + code=$(unshare -Cr -- curl -sS -o /dev/null -w '%{http_code}' --max-time 6 \ + -H "$imds_hdr" "$imds_url" 2>/dev/null || echo 000) + case "$code" in + 200) record C5 PASS "IMDS still reachable from new cgroup ns (redirect intact)" ;; + 000) record C5 FAIL "IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify)" ;; + *) record C5 INFO "code=$code from new cgroup ns (manual review)" ;; + esac +fi + +echo +echo "Phase 3 complete. See $FINDINGS" +[[ -f "$PCAP" ]] && echo "Pcap: $PCAP" diff --git a/pentest/phase4_rules_fuzz/url_diff.py b/pentest/phase4_rules_fuzz/url_diff.py new file mode 100755 index 00000000..07f559af --- /dev/null +++ b/pentest/phase4_rules_fuzz/url_diff.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Phase 4 — URL/encoding differentials against the rule engine. + +Sends a matrix of equivalent URLs (different encodings of the same path) to the +GPA-redirected IMDS endpoint and records the response code GPA hands back. +A correctly-implemented rule engine should produce the same authorization +decision for every variant; differences are potential bypasses. + +Usage: + python3 url_diff.py [--out results.tsv] + +Loopback fabric only; safe to run from a normal user account. +""" +from __future__ import annotations +import argparse, datetime, http.client, os, sys, urllib.parse + +IMDS_HOST = "169.254.169.254" +IMDS_PORT = 80 +HEADERS = {"Metadata": "true"} + +CANONICAL = "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" + +VARIANTS = { + "canonical": CANONICAL, + "uppercase_pct": "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "lowercase_pct": "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "double_encoded": "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/", + "trailing_dot_path": "/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/", + "dot_segments": "/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", + "param_injection": "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/", + "case_path": "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/", + "extra_slashes": "//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/", + "fragment": "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x", + "unicode_dotless": "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/", +} + + +def send(path: str) -> tuple[int, str]: + try: + conn = http.client.HTTPConnection(IMDS_HOST, IMDS_PORT, timeout=8) + conn.request("GET", path, headers=HEADERS) + resp = conn.getresponse() + body_head = resp.read(80).decode("latin-1", errors="replace").replace("\n", " ") + conn.close() + return resp.status, body_head + except Exception as exc: # pragma: no cover + return -1, f"{type(exc).__name__}: {exc}" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=os.environ.get( + "URL_DIFF_OUT", + os.path.join(os.path.dirname(__file__), "..", "results", "url_diff.tsv"), + )) + args = ap.parse_args() + os.makedirs(os.path.dirname(args.out), exist_ok=True) + + baseline_status, _ = send(CANONICAL) + print(f"baseline ({baseline_status}) for canonical path") + + rows = [] + diff_count = 0 + for name, path in VARIANTS.items(): + status, head = send(path) + diff = "DIFF" if status != baseline_status else "same" + if diff == "DIFF": + diff_count += 1 + rows.append((name, status, diff, urllib.parse.quote(path, safe="/?&=:%")[:120], head[:60])) + print(f" {name:<18} status={status:<4} {diff}") + + with open(args.out, "a", encoding="utf-8") as fh: + ts = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z" + for name, status, diff, path, head in rows: + fh.write(f"{ts}\t{name}\t{status}\t{diff}\t{path}\t{head}\n") + + print(f"\nWrote {len(rows)} rows to {args.out}; {diff_count} differ from baseline.") + # Differences are interesting but not necessarily failures — they need triage + # against the GPA rule engine. Exit 0 either way. + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pentest/phase4b_local_rules/__pycache__/run.cpython-312.pyc b/pentest/phase4b_local_rules/__pycache__/run.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f150328e20d74d2446df27f8816276b025704c3e GIT binary patch literal 30004 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%0DHR&mTf!>`Z z=Xr9z3I|9Ej`!k6(g<5o4M2Z|g6+^d$i^;y-b^M`5GH4|AAQ2{U`$A1CYFNnTRo9c zibPC>sn6+245KPvY5te6skZN65njGbAJHlb8%g9lm}zqa1z3)t_xiW2YwUfV(&mdS)Nx7B+)>UaQ1g}^|f8-Ip>2_dbyT9 zqN#&iPYx;e?B`O{aj=sf`^gz3CqNDx8yQTsM2{=v{7Z8F6*;Vy={+1IzHFE_e08ZBOkImUW$}f(U z9A3+BoU_F9%NDP$<{y}I#PiEzyK7hTYv;0=w`n!MY0mY(Qq-=s{BDX+NUsX!ERXUl z=WO%{XD_8uIA_7AXK?)7An=7ar^6RMHsW!EJ!Wyc#5qgMR<_3N1{vA;3(bEt@gS>m zHLG$htLj0PXEn<+C&aVz?!0pAmBpUbtbNFfb3EX3SGnB9%H@I>m%Gk+<5{JPeXCgq z=B)6=${Sa+8t0sk%6nG1(mB(d0t~L}v6<&D!%DZnw>-`>@XtNAXYo58?=|q%kL|hq zdH!*=gEw#N;*Q~GQgZpcc%G=<>yN1S_O8bizHtaPzkg;Z;GB@0#sK@+Uky5eL_)?h05 z$m&|KEE*T4=EF-J-#Puw(;r%E^v@S^?>KHbXf!buJW*(6=&yDccQtc=)okx>H2&D& N?yfcdxYh*!{|g}-=%D}r literal 0 HcmV?d00001 diff --git a/pentest/phase4b_local_rules/run.py b/pentest/phase4b_local_rules/run.py new file mode 100755 index 00000000..0b844d76 --- /dev/null +++ b/pentest/phase4b_local_rules/run.py @@ -0,0 +1,862 @@ +#!/usr/bin/env python3 +""" +Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. + +PRE-REQ: + The HostGAPlugin / fabric must already deliver a ruleId with + useLocalFileRules=true (so the agent merges the files in + /var/lib/azure-proxy-agent/rules/ instead of ignoring them). + The script verifies this at startup by reading status.json and the latest + AuthorizationRules_*.json snapshot. + +This script: + 1. Backs up any existing IMDS_Rules.json / WireServer_Rules.json. + 2. For each scenario: + a. Writes a crafted IMDS_Rules.json. + b. Waits for the agent's poll cycle (pollKeyStatusIntervalInSeconds + slack). + c. Sends a matrix of HTTP requests through GPA (eBPF-redirected) and + records the status code returned. + d. Compares each (request -> status) against the expected outcome. + 3. Restores backups and removes any files we created. + +Run as root: + sudo python3 phase4b_local_rules/run.py +""" +from __future__ import annotations +import argparse, datetime, http.client, json, os, shutil, signal, subprocess, sys, time +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +# ---------- environment ---------- +RULES_DIR = Path("/var/lib/azure-proxy-agent/rules") +LOG_DIR = Path("/var/log/azure-proxy-agent") +STATUS_FILE = LOG_DIR / "status.json" +IMDS_RULES_FILE = RULES_DIR / "IMDS_Rules.json" +WS_RULES_FILE = RULES_DIR / "WireServer_Rules.json" + +CONFIG_FILE = Path("/usr/lib/azure-proxy-agent/proxy-agent.json") # may not exist +POLL_FALLBACK_S = 15 +SLACK_S = 5 + +IMDS_HOST = "169.254.169.254" +IMDS_PORT = 80 +WS_HOST = "168.63.129.16" +WS_PORT = 80 + + +@dataclass +class Target: + name: str # "imds" | "wireserver" + host: str + port: int + rules_file: Path # IMDS_Rules.json | WireServer_Rules.json + + +TARGETS: dict[str, Target] = {} # filled in main() once paths are known + +# ---------- output ---------- +HERE = Path(__file__).resolve().parent +RESULTS_DIR = HERE.parent / "results" +RESULTS_DIR.mkdir(parents=True, exist_ok=True) +FINDINGS = RESULTS_DIR / "findings.tsv" +SCENARIO_LOG= RESULTS_DIR / "phase4b_local_rules.log" + +GREEN, RED, YELLOW, RESET = "\033[32m", "\033[31m", "\033[33m", "\033[0m" + + +def ts() -> str: + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def record(test_id: str, status: str, msg: str = "") -> None: + color = {"PASS": GREEN, "FAIL": RED, "INFO": YELLOW}.get(status, "") + print(f"[{color}{status}{RESET}] {test_id} {msg}") + with FINDINGS.open("a") as fh: + fh.write(f"{ts()}\t{test_id}\t{status}\t{msg}\n") + + +# ---------- helpers ---------- + +def require_root() -> None: + if os.geteuid() != 0: + sys.exit("Must run as root (rules dir is 0700 root).") + + +def get_poll_interval() -> int: + """Read pollKeyStatusIntervalInSeconds from the agent config; fallback to 15.""" + for candidate in ( + CONFIG_FILE, + Path("/etc/azure-proxy-agent/proxy-agent.json"), + Path(__file__).resolve().parents[2] + / "proxy_agent" / "config" / "GuestProxyAgent.linux.json", + ): + try: + with candidate.open() as fh: + cfg = json.load(fh) + return int(cfg.get("pollKeyStatusIntervalInSeconds", POLL_FALLBACK_S)) + except Exception: + continue + return POLL_FALLBACK_S + + +def latest_authorization_rules_snapshot() -> Optional[Path]: + snaps = sorted(LOG_DIR.glob("AuthorizationRules_*.json")) + return snaps[-1] if snaps else None + + +def assert_use_local_file_rules_active() -> None: + """Read status.json + latest AuthorizationRules snapshot to confirm flag is on.""" + if not STATUS_FILE.exists(): + sys.exit(f"{STATUS_FILE} not found; is azure-proxy-agent running?") + status = json.loads(STATUS_FILE.read_text()) + snap = latest_authorization_rules_snapshot() + snap_text = snap.read_text() if snap else "" + + flag_in_snap = "useLocalFileRules-true" in snap_text + if not flag_in_snap: + record("PRE", "FAIL", + f"useLocalFileRules-true NOT present in {snap}; " + "the fabric is delivering plain rule ids — local rules will be ignored. " + "Enable useLocalFileRules from the control plane and retry.") + sys.exit(2) + + record("PRE", "PASS", + f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}") + + +def get_current_user_identity() -> dict: + """Return identity dict suitable for the rules engine, matching the + process/user that will actually be sending requests (root in this run).""" + user = subprocess.check_output(["id", "-un"], text=True).strip() + grp = subprocess.check_output(["id", "-gn"], text=True).strip() + return { + "name": "selfRoot", + "userName": user, + "groupName": grp, + "exePath": "/usr/bin/curl", + "processName": "curl", + } + + +def send(target: Target, method: str, path: str, + headers: Optional[dict] = None, timeout: float = 8.0) -> int: + h: dict[str, str] = {} + if target.name == "imds": + h["Metadata"] = "true" + elif target.name == "wireserver": + h["x-ms-version"] = "2012-11-30" + if headers: + h.update(headers) + try: + conn = http.client.HTTPConnection(target.host, target.port, timeout=timeout) + conn.request(method, path, headers=h) + resp = conn.getresponse() + resp.read(64) + conn.close() + return resp.status + except Exception: + return -1 + + +# ---------- file management ---------- + +@contextmanager +def backup_rules_dir(targets: list[Target]): + """Backup the rules files for the given targets and any unrelated + pre-existing IMDS/WireServer rules files. Restore on exit.""" + backups: dict[Path, Path] = {} + paths_to_manage = {t.rules_file for t in targets} + # also back up the *other* rules file if present so we don't accidentally leave it behind + paths_to_manage.update({IMDS_RULES_FILE, WS_RULES_FILE}) + for p in paths_to_manage: + if p.exists(): + bak = p.with_suffix(p.suffix + f".pentest-bak.{int(time.time())}") + shutil.copy2(p, bak) + backups[p] = bak + try: + yield + finally: + for p in paths_to_manage: + try: + p.unlink() + except FileNotFoundError: + pass + for orig, bak in backups.items(): + shutil.move(str(bak), str(orig)) + + +def write_rules(path: Path, doc: Any) -> None: + """Write rules JSON atomically.""" + path.parent.mkdir(mode=0o700, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + if isinstance(doc, str): + tmp.write_text(doc) + else: + tmp.write_text(json.dumps(doc, indent=2)) + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def write_raw(path: Path, raw_text: str) -> None: + path.parent.mkdir(mode=0o700, exist_ok=True) + path.write_text(raw_text) + os.chmod(path, 0o600) + + +# ---------- scenarios ---------- + +@dataclass +class Probe: + name: str + path: str + expected: int # expected HTTP status from GPA's perspective + method: str = "GET" + + +@dataclass +class Scenario: + sid: str + target: Target + description: str + rules: Any # dict (will be json.dumps'd) or raw str + probes: list[Probe] = field(default_factory=list) + raw: bool = False # write rules verbatim (for malformed-JSON test) + + +def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: + scenarios: list[Scenario] = [] + pfx = "IMDS" + + # S1: disabled + allow-by-default — all calls succeed (control) + scenarios.append(Scenario( + sid=f"{pfx}-S1-disabled-allow", + target=target, + description="mode=disabled, defaultAccess=allow → no enforcement, baseline 200s", + rules={ + "defaultAccess": "allow", + "mode": "disabled", + "id": "pentest-s1", + "rules": {}, + }, + probes=[ + Probe("instance", "/metadata/instance?api-version=2021-02-01", 200), + Probe("token", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 200), + ], + )) + + # S2: enforce + deny-by-default, NO allow rules → everything 403 + scenarios.append(Scenario( + sid=f"{pfx}-S2-enforce-deny-empty", + target=target, + description="mode=enforce, defaultAccess=deny, no rules → all 403", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s2", + "rules": {}, + }, + probes=[ + Probe("instance", "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions", "/metadata/versions", 403), + ], + )) + + # S3: audit mode — denials are LOGGED but allowed through + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="mode=audit, defaultAccess=deny, no rules → still 200 (audit only)", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-s3", + "rules": {}, + }, + probes=[ + Probe("instance", "/metadata/instance?api-version=2021-02-01", 200), + ], + )) + + # S4: explicit allow for /metadata/instance, deny everything else + scenarios.append(Scenario( + sid=f"{pfx}-S4-allow-one-path", + target=target, + description="enforce + deny, allow only /metadata/instance for current identity", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s4", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_instance", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("instance_allowed", "/metadata/instance?api-version=2021-02-01", 200), + Probe("token_denied", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S5: same allow rule but bound to a non-matching user → 403 + bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} + scenarios.append(Scenario( + sid=f"{pfx}-S5-wrong-identity", + target=target, + description="enforce + deny, allow path /metadata/instance for a non-matching user", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s5", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [bogus_identity], + "roleAssignments": [{"role": "r_instance", + "identities": [bogus_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), + ], + )) + + # S6: encoding bypass — rule allows /metadata/instance only; + # try to reach /metadata/identity/oauth2/token via percent-encoded slash. + scenarios.append(Scenario( + sid=f"{pfx}-S6-encoding-bypass", + target=target, + description="enforce + deny; allow /metadata/instance; " + "test %2F-encoded slash variants of token path", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s6", + "rules": { + "privileges": [{"name": "p_instance", "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", "privileges": ["p_instance"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_instance", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("token_uppercase_pct", + "/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_lowercase_pct", + "/metadata/identity/oauth2%2ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_double_encoded", + "/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_question_encoded", + "/metadata/identity/oauth2/token%3Fapi-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_dot_segments", + "/metadata/./identity/../identity/oauth2/token" + "?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + ], + )) + + # S7: query-parameter rule — only allow when api-version matches + scenarios.append(Scenario( + sid=f"{pfx}-S7-query-param-required", + target=target, + description="enforce + deny; allow /metadata/instance ONLY with " + "api-version=2021-02-01", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s7", + "rules": { + "privileges": [{ + "name": "p_instance_q", + "path": "/metadata/instance", + "queryParameters": {"api-version": "2021-02-01"}, + }], + "roles": [{"name": "r_q", + "privileges": ["p_instance_q"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_q", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("matching_version", + "/metadata/instance?api-version=2021-02-01", 200), + Probe("missing_version", + "/metadata/instance", 403), + Probe("wrong_version", + "/metadata/instance?api-version=2017-04-02", 403), + ], + )) + + # S8: identity match by groupName only + scenarios.append(Scenario( + sid=f"{pfx}-S8-group-only-identity", + target=target, + description="enforce + deny; identity matches by groupName only", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s8", + "rules": { + "privileges": [{"name": "p_any", "path": "/metadata/"}], + "roles": [{"name": "r_any", "privileges": ["p_any"]}], + "identities": [{ + "name": "rootGroup", + "groupName": identity["groupName"], + }], + "roleAssignments": [{"role": "r_any", + "identities": ["rootGroup"]}], + }, + }, + probes=[ + Probe("instance_allowed", + "/metadata/instance?api-version=2021-02-01", 200), + ], + )) + + # S9: identity match by exePath — allow only when caller is /usr/bin/curl + scenarios.append(Scenario( + sid=f"{pfx}-S9-exepath-identity", + target=target, + description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s9", + "rules": { + "privileges": [{"name": "p_any", "path": "/metadata/"}], + "roles": [{"name": "r_any", "privileges": ["p_any"]}], + "identities": [{ + "name": "curlOnly", + "exePath": "/usr/bin/curl", + }], + "roleAssignments": [{"role": "r_any", + "identities": ["curlOnly"]}], + }, + }, + probes=[ + # Probe is sent via http.client (python). It SHOULD be denied because + # exePath != /usr/bin/curl. We separately invoke curl below to get a 200. + Probe("python_caller_denied", + "/metadata/instance?api-version=2021-02-01", 403), + ], + )) + + # S10: malformed JSON → fail-closed + scenarios.append(Scenario( + sid=f"{pfx}-S10-malformed-json", + target=target, + description="malformed local rules JSON → agent must fail-closed (all 403)", + rules="{ this is not valid json", + raw=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + return scenarios + + +def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: + """WireServer-targeted local-file rule scenarios. + + WireServer is privileged-by-default (only root/admin allowed at the proxy + layer), so all probes assume the harness is running as root. + """ + scenarios: list[Scenario] = [] + pfx = "WS" + + GOALSTATE = "/machine/?comp=goalstate" + SHARED = "/machine/?comp=sharedConfig" + HOSTING = "/machine/?comp=hostingenvironmentconfig" + CERTS = "/machine/?comp=certificates" + EXTCONFIG = "/machine/?comp=extensionsConfig" + VERSIONS = "/?comp=versions" + + # WS-S1: control — disabled + allow + scenarios.append(Scenario( + sid=f"{pfx}-S1-disabled-allow", + target=target, + description="mode=disabled, defaultAccess=allow → baseline 200s", + rules={ + "defaultAccess": "allow", + "mode": "disabled", + "id": "pentest-ws-s1", + "rules": {}, + }, + probes=[ + Probe("goalstate", GOALSTATE, 200), + Probe("versions", VERSIONS, 200), + ], + )) + + # WS-S2: enforce + deny + no rules → all 403 + scenarios.append(Scenario( + sid=f"{pfx}-S2-enforce-deny-empty", + target=target, + description="mode=enforce, defaultAccess=deny, no rules → all 403", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s2", + "rules": {}, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + Probe("shared_denied", SHARED, 403), + ], + )) + + # WS-S3: audit only + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="mode=audit, defaultAccess=deny, no rules → still 200 (audit only)", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-ws-s3", + "rules": {}, + }, + probes=[ + Probe("goalstate_audit", GOALSTATE, 200), + ], + )) + + # WS-S4: only allow goalstate; everything else 403 + scenarios.append(Scenario( + sid=f"{pfx}-S4-allow-goalstate-only", + target=target, + description="enforce + deny; allow only /machine/ with comp=goalstate", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s4", + "rules": { + "privileges": [{ + "name": "p_goalstate", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_goalstate", + "privileges": ["p_goalstate"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_goalstate", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_allowed", GOALSTATE, 200), + Probe("shared_denied", SHARED, 403), + Probe("hosting_denied", HOSTING, 403), + Probe("certs_denied", CERTS, 403), + Probe("extconfig_denied", EXTCONFIG, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + # WS-S5: identity mismatch → even goalstate 403 + bogus_identity = {**identity, "name": "nobodyId", "userName": "nosuchuser_xyz"} + scenarios.append(Scenario( + sid=f"{pfx}-S5-wrong-identity", + target=target, + description="enforce + deny; allow goalstate but bound to non-matching user", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s5", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [bogus_identity], + "roleAssignments": [{"role": "r_machine", + "identities": [bogus_identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + ], + )) + + # WS-S6: encoding/path-traversal bypass attempts on a deny path. + # Allow only goalstate; try to reach certificates via encoded variants. + scenarios.append(Scenario( + sid=f"{pfx}-S6-encoding-bypass", + target=target, + description="enforce + deny; allow only goalstate; " + "test encoded/path-tricks for /machine/?comp=certificates", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s6", + "rules": { + "privileges": [{ + "name": "p_goalstate", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_goalstate", + "privileges": ["p_goalstate"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_goalstate", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("certs_uppercase_pct", + "/machine%2F?comp=certificates", 403), + Probe("certs_question_encoded", + "/machine/%3Fcomp=certificates", 403), + Probe("certs_dot_segments", + "/machine/./../machine/?comp=certificates", 403), + Probe("certs_extra_slashes", + "//machine///?comp=certificates", 403), + Probe("certs_value_case", + "/machine/?comp=Certificates", 403), + Probe("goalstate_value_case_should_match", + "/machine/?comp=GOALSTATE", 200), + ], + )) + + # WS-S7: comp= query parameter must match exactly + scenarios.append(Scenario( + sid=f"{pfx}-S7-query-param-required", + target=target, + description="enforce + deny; allow /machine/ ONLY with comp=goalstate", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s7", + "rules": { + "privileges": [{ + "name": "p_goalstate_q", + "path": "/machine/", + "queryParameters": {"comp": "goalstate"}, + }], + "roles": [{"name": "r_q", + "privileges": ["p_goalstate_q"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_q", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("matching_comp", GOALSTATE, 200), + Probe("missing_comp", "/machine/", 403), + Probe("wrong_comp", "/machine/?comp=hostingenvironmentconfig", 403), + Probe("extra_param_ok", + "/machine/?comp=goalstate&incarnation=1", 200), + ], + )) + + # WS-S8: identity match by groupName only + scenarios.append(Scenario( + sid=f"{pfx}-S8-group-only-identity", + target=target, + description="enforce + deny; identity matches by groupName only", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s8", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [{ + "name": "rootGroup", + "groupName": identity["groupName"], + }], + "roleAssignments": [{"role": "r_machine", + "identities": ["rootGroup"]}], + }, + }, + probes=[ + Probe("goalstate_allowed", GOALSTATE, 200), + ], + )) + + # WS-S9: per-process AuthZ via exePath — only /usr/bin/curl allowed + scenarios.append(Scenario( + sid=f"{pfx}-S9-exepath-identity", + target=target, + description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-ws-s9", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [{ + "name": "curlOnly", + "exePath": "/usr/bin/curl", + }], + "roleAssignments": [{"role": "r_machine", + "identities": ["curlOnly"]}], + }, + }, + probes=[ + Probe("python_caller_denied", GOALSTATE, 403), + ], + )) + + # WS-S10: malformed JSON → fail-closed + scenarios.append(Scenario( + sid=f"{pfx}-S10-malformed-json", + target=target, + description="malformed local rules JSON → agent must fail-closed (all 403)", + rules="{ this is not valid json", + raw=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + return scenarios + + +def build_scenarios(targets: list[Target], identity: dict) -> list[Scenario]: + out: list[Scenario] = [] + for t in targets: + if t.name == "imds": + out.extend(_imds_scenarios(t, identity)) + elif t.name == "wireserver": + out.extend(_wireserver_scenarios(t, identity)) + else: + raise ValueError(f"unknown target {t.name}") + return out + + +# ---------- runner ---------- + +def wait_for_refresh(poll_s: int) -> None: + delay = poll_s + SLACK_S + print(f" ... waiting {delay}s for rule refresh ...") + time.sleep(delay) + + +def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: + print(f"\n=== {sc.sid}: {sc.description}") + if sc.raw: + write_raw(sc.target.rules_file, sc.rules) + else: + write_rules(sc.target.rules_file, sc.rules) + wait_for_refresh(poll_s) + + passes = fails = 0 + for p in sc.probes: + actual = send(sc.target, p.method, p.path) + ok = (actual == p.expected) + record(f"{sc.sid}/{p.name}", + "PASS" if ok else "FAIL", + f"expected={p.expected} actual={actual} path={p.path[:80]}") + passes += int(ok); fails += int(not ok) + + # Extra real-curl probe for S9 to demonstrate the positive case + if sc.sid.endswith("S9-exepath-identity"): + if sc.target.name == "imds": + url = (f"http://{sc.target.host}/metadata/instance" + "?api-version=2021-02-01") + extra_hdr = ["-H", "Metadata: true"] + else: # wireserver + url = f"http://{sc.target.host}/machine/?comp=goalstate" + extra_hdr = ["-H", "x-ms-version: 2012-11-30"] + try: + out = subprocess.run( + ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", + "--max-time", "8", *extra_hdr, url], + capture_output=True, text=True, timeout=15) + actual = int(out.stdout or "0") + except Exception: + actual = -1 + ok = (actual == 200) + record(f"{sc.sid}/curl_caller_allowed", + "PASS" if ok else "FAIL", + f"expected=200 actual={actual} (curl invocation)") + passes += int(ok); fails += int(not ok) + + return passes, fails + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--target", + choices=["imds", "wireserver", "both"], default="both", + help="which authorization target's local rules to exercise") + ap.add_argument("--scenarios", help="comma-separated scenario sids to run " + "(default: all). Match is exact.") + ap.add_argument("--poll", type=int, default=None, + help="override poll interval seconds") + args = ap.parse_args() + + require_root() + assert_use_local_file_rules_active() + + poll_s = args.poll or get_poll_interval() + record("CFG", "INFO", f"poll interval = {poll_s}s") + + identity = get_current_user_identity() + record("CFG", "INFO", + f"current identity: user={identity['userName']} " + f"group={identity['groupName']}") + + targets: list[Target] = [] + if args.target in ("imds", "both"): + targets.append(Target("imds", IMDS_HOST, IMDS_PORT, IMDS_RULES_FILE)) + if args.target in ("wireserver", "both"): + targets.append(Target("wireserver", WS_HOST, WS_PORT, WS_RULES_FILE)) + record("CFG", "INFO", + f"targets: {', '.join(t.name for t in targets)}") + + all_scenarios = build_scenarios(targets, identity) + if args.scenarios: + wanted = set(args.scenarios.split(",")) + all_scenarios = [s for s in all_scenarios if s.sid in wanted] + + total_p = total_f = 0 + with backup_rules_dir(targets): + for sc in all_scenarios: + try: + p, f = run_scenario(sc, poll_s) + except KeyboardInterrupt: + print("\nInterrupted; restoring backups.") + raise + except Exception as exc: + record(sc.sid, "FAIL", f"scenario crashed: {exc}") + p, f = 0, 1 + total_p += p; total_f += f + + print("\n========================================") + print(f"local-rules pen-test: {total_p} PASS, {total_f} FAIL") + print(f"Findings appended to {FINDINGS}") + return 0 if total_f == 0 else 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except SystemExit: + raise + except Exception as exc: + print(f"FATAL: {exc}", file=sys.stderr) + sys.exit(3) diff --git a/pentest/phase5_state_fs/audit.sh b/pentest/phase5_state_fs/audit.sh new file mode 100755 index 00000000..d3852403 --- /dev/null +++ b/pentest/phase5_state_fs/audit.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Phase 5 — read-only filesystem / state audit (Scenarios E1, F1). +# Read-only; safe to run. Destructive sub-tests (E2/E3/E4) are intentionally NOT here. + +set -uo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck disable=SC1091 +source "$HERE/../lib/common.sh" + +# E1 — permissions on key dir, log dir, binary, unit +check_mode() { # $1=path $2=expected_octal_max (e.g. 700) $3=expected_owner + local p="$1" max="$2" owner="$3" + # Use sudo for stat so we can read modes inside root-only dirs. + if ! sudo test -e "$p"; then record "E1[$p]" INFO "missing"; return; fi + local mode own + mode=$(sudo stat -c '%a' "$p") + own=$(sudo stat -c '%U' "$p") + if (( 10#$mode > 10#$max )); then + record "E1[$p]" FAIL "mode=$mode (max $max) owner=$own" + elif [[ "$own" != "$owner" ]]; then + record "E1[$p]" FAIL "owner=$own (expected $owner) mode=$mode" + else + record "E1[$p]" PASS "mode=$mode owner=$own" + fi +} + +check_mode "$GPA_KEY_DIR" 700 root +check_mode "$GPA_LOG_DIR" 755 root +check_mode "/usr/sbin/azure-proxy-agent" 755 root +check_mode "/usr/lib/systemd/system/azure-proxy-agent.service" 644 root + +# Any individual key file should be 0600 root +if [[ -d "$GPA_KEY_DIR" ]]; then + while IFS= read -r f; do + check_mode "$f" 600 root + done < <(sudo find "$GPA_KEY_DIR" -maxdepth 2 -type f 2>/dev/null) +fi + +# F1 — secret leakage in world-readable status / rules +for f in "$GPA_LOG_DIR/status.json" "$GPA_LOG_DIR"/AuthorizationRules_*.json; do + [[ -f "$f" ]] || continue + if grep -aE '"key"|secret|signature|hmac|token' "$f" >/dev/null 2>&1; then + record "F1[$(basename "$f")]" FAIL "possible secret-shaped string in world-readable file" + else + record "F1[$(basename "$f")]" PASS "no obvious secret-shaped strings" + fi +done + +# Surface eBPF programs / cgroup attachments for review (informational) +if command -v bpftool >/dev/null; then + bpftool prog show 2>/dev/null > "$RESULTS_DIR/bpftool_prog_show.txt" || true + bpftool cgroup tree 2>/dev/null > "$RESULTS_DIR/bpftool_cgroup_tree.txt" || true + record P5-bpftool INFO "saved bpftool snapshots to $RESULTS_DIR" +fi + +echo +echo "Phase 5 (read-only audit) complete. See $FINDINGS" diff --git a/pentest/results/bpftool_cgroup_tree.txt b/pentest/results/bpftool_cgroup_tree.txt new file mode 100644 index 00000000..90f561f2 --- /dev/null +++ b/pentest/results/bpftool_cgroup_tree.txt @@ -0,0 +1,2 @@ +CgroupPath +ID AttachType AttachFlags Name diff --git a/pentest/results/bpftool_prog_show.txt b/pentest/results/bpftool_prog_show.txt new file mode 100644 index 00000000..e69de29b diff --git a/pentest/results/findings.tsv b/pentest/results/findings.tsv new file mode 100644 index 00000000..26136471 --- /dev/null +++ b/pentest/results/findings.tsv @@ -0,0 +1,51 @@ +2026-05-12T18:49:49Z P3-cap INFO capturing to /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap (pid=159546) +2026-05-12T18:49:49Z B1a PASS IMDS reachable through GPA-redirected path (200) +2026-05-12T18:49:49Z B1b INFO ss snapshot of own connection: +2026-05-12T18:49:49Z B4 PASS request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap) +2026-05-12T18:49:49Z B3 PASS key files in /var/lib/azure-proxy-agent/keys are not group/world-readable +2026-05-12T18:49:50Z C1 PASS non-elevated WireServer call denied (403) +2026-05-12T18:49:50Z C7[http://0xa9fea9fe/metadata/instance?api-version=2021-02-01] INFO code=200 (expect either same as canonical or refused; manual diff) +2026-05-12T18:49:50Z C7[http://2852039166/metadata/instance?api-version=2021-02-01] INFO code=200 (expect either same as canonical or refused; manual diff) +2026-05-12T18:49:50Z C5 FAIL IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify) +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys] PASS mode=700 owner=root +2026-05-12T18:49:50Z E1[/var/log/azure-proxy-agent] PASS mode=755 owner=root +2026-05-12T18:49:50Z E1[/usr/sbin/azure-proxy-agent] FAIL mode=775 (max 755) owner=root +2026-05-12T18:49:50Z E1[/usr/lib/systemd/system/azure-proxy-agent.service] FAIL mode=664 (max 644) owner=root +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] INFO missing +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/status.tag] INFO missing +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] INFO missing +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] INFO missing +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] INFO missing +2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] INFO missing +2026-05-12T18:49:50Z F1[status.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] PASS no obvious secret-shaped strings +2026-05-12T18:49:50Z P5-bpftool INFO saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results +2026-05-12T18:50:12Z A1 PASS 3080 only bound to loopback (external IFs: 10.80.0.11) +2026-05-12T18:50:12Z A1b PASS loopback connect to 3080 OK +2026-05-12T18:50:12Z A1c PASS 3080 not reachable on external IP 10.80.0.11 +2026-05-12T18:50:19Z A3 PASS service survived malformed requests (pid=155895) +2026-05-12T18:50:20Z A4 FAIL CONNECT not refused (status='') +2026-05-12T18:50:20Z A5 PASS explicit-proxy to example.com refused (status=421) +2026-05-12T18:50:20Z G1 PASS survived 200-conn burst (pid=155895) +2026-05-12T18:50:20Z E1[/var/lib/azure-proxy-agent/keys] PASS mode=700 owner=root +2026-05-12T18:50:21Z E1[/var/log/azure-proxy-agent] PASS mode=755 owner=root +2026-05-12T18:50:21Z E1[/usr/sbin/azure-proxy-agent] FAIL mode=775 (max 755) owner=root +2026-05-12T18:50:21Z E1[/usr/lib/systemd/system/azure-proxy-agent.service] FAIL mode=664 (max 644) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/status.tag] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] FAIL mode=644 (max 600) owner=root +2026-05-12T18:50:21Z F1[status.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] PASS no obvious secret-shaped strings +2026-05-12T18:50:21Z P5-bpftool INFO saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results +2026-05-12T18:59:42Z PRE FAIL useLocalFileRules-true NOT present in /var/log/azure-proxy-agent/AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json; the fabric is delivering plain rule ids — local rules will be ignored. Enable useLocalFileRules from the control plane and retry. diff --git a/pentest/results/phase3-20260512T184948Z.pcap b/pentest/results/phase3-20260512T184948Z.pcap new file mode 100644 index 0000000000000000000000000000000000000000..f518c106046f547bbc47a5bcadcf345da6be7af1 GIT binary patch literal 19642 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 literal 0 HcmV?d00001 diff --git a/pentest/results/phase4b_local_rules.log b/pentest/results/phase4b_local_rules.log new file mode 100644 index 00000000..3ab7bee4 --- /dev/null +++ b/pentest/results/phase4b_local_rules.log @@ -0,0 +1 @@ +[FAIL] PRE useLocalFileRules-true NOT present in /var/log/azure-proxy-agent/AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json; the fabric is delivering plain rule ids — local rules will be ignored. Enable useLocalFileRules from the control plane and retry. diff --git a/pentest/results/run.log b/pentest/results/run.log new file mode 100644 index 00000000..9be5ef49 --- /dev/null +++ b/pentest/results/run.log @@ -0,0 +1,81 @@ +Missing commands: nmap + +[INFO] P3-cap capturing to /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap (pid=159546) +[PASS] B1a IMDS reachable through GPA-redirected path (200) +[INFO] B1b ss snapshot of own connection: +[PASS] B4 request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap) +[PASS] B3 key files in /var/lib/azure-proxy-agent/keys are not group/world-readable +[PASS] C1 non-elevated WireServer call denied (403) +[INFO] C7[http://0xa9fea9fe/metadata/instance?api-version=2021-02-01] code=200 (expect either same as canonical or refused; manual diff) +[INFO] C7[http://2852039166/metadata/instance?api-version=2021-02-01] code=200 (expect either same as canonical or refused; manual diff) +[FAIL] C5 IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify) + +Phase 3 complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv +Pcap: /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap + +/home/zpeng/src/GuestProxyAgent/pentest/phase4_rules_fuzz/url_diff.py:74: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). + ts = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z" +baseline (200) for canonical path + canonical status=200 same + uppercase_pct status=200 same + lowercase_pct status=200 same + double_encoded status=404 DIFF + trailing_dot_path status=404 DIFF + dot_segments status=404 DIFF + param_injection status=404 DIFF + case_path status=404 DIFF + extra_slashes status=404 DIFF + fragment status=200 same + unicode_dotless status=-1 DIFF + +Wrote 11 rows to /home/zpeng/src/GuestProxyAgent/pentest/phase4_rules_fuzz/../results/url_diff.tsv; 7 differ from baseline. + +[PASS] E1[/var/lib/azure-proxy-agent/keys] mode=700 owner=root +[PASS] E1[/var/log/azure-proxy-agent] mode=755 owner=root +[FAIL] E1[/usr/sbin/azure-proxy-agent] mode=775 (max 755) owner=root +[FAIL] E1[/usr/lib/systemd/system/azure-proxy-agent.service] mode=664 (max 644) owner=root +[INFO] E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] missing +[INFO] E1[/var/lib/azure-proxy-agent/keys/status.tag] missing +[INFO] E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] missing +[INFO] E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] missing +[INFO] E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] missing +[INFO] E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] missing +[PASS] F1[status.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] no obvious secret-shaped strings +[INFO] P5-bpftool saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results + +Phase 5 (read-only audit) complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv + +All safe phases done. Findings: /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv +[PASS] A1 3080 only bound to loopback (external IFs: 10.80.0.11) +[PASS] A1b loopback connect to 3080 OK +[PASS] A1c 3080 not reachable on external IP 10.80.0.11 +[PASS] A3 service survived malformed requests (pid=155895) +[FAIL] A4 CONNECT not refused (status='') +[PASS] A5 explicit-proxy to example.com refused (status=421) +[PASS] G1 survived 200-conn burst (pid=155895) + +Phase 2 complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv +[PASS] E1[/var/lib/azure-proxy-agent/keys] mode=700 owner=root +[PASS] E1[/var/log/azure-proxy-agent] mode=755 owner=root +[FAIL] E1[/usr/sbin/azure-proxy-agent] mode=775 (max 755) owner=root +[FAIL] E1[/usr/lib/systemd/system/azure-proxy-agent.service] mode=664 (max 644) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] mode=644 (max 600) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/status.tag] mode=644 (max 600) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] mode=644 (max 600) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] mode=644 (max 600) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] mode=644 (max 600) owner=root +[FAIL] E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] mode=644 (max 600) owner=root +[PASS] F1[status.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] no obvious secret-shaped strings +[PASS] F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] no obvious secret-shaped strings +[INFO] P5-bpftool saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results + +Phase 5 (read-only audit) complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv diff --git a/pentest/results/url_diff.tsv b/pentest/results/url_diff.tsv new file mode 100644 index 00000000..78a5c2b0 --- /dev/null +++ b/pentest/results/url_diff.tsv @@ -0,0 +1,11 @@ +2026-05-12T18:49:50Z canonical 200 same /metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI +2026-05-12T18:49:50Z uppercase_pct 200 same /metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI +2026-05-12T18:49:50Z lowercase_pct 200 same /metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI +2026-05-12T18:49:50Z double_encoded 404 DIFF /metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/ +2026-05-12T18:49:50Z trailing_dot_path 404 DIFF /metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/ {"error":"Not Found"} +2026-05-12T18:49:50Z dot_segments 404 DIFF /metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/ +2026-05-12T18:49:50Z param_injection 404 DIFF /metadata/identity/oauth2/token%3Bx=1?api-version=2018-02-01&resource=https://management.azure.com/ {"error":"Not Found"} +2026-05-12T18:49:50Z case_path 404 DIFF /Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/ { "error": "Not found" } +2026-05-12T18:49:50Z extra_slashes 404 DIFF //metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/ Date: Wed, 13 May 2026 17:56:35 +0000 Subject: [PATCH 02/37] Update --- .gitignore | 4 + pentest/DESIGN.md | 150 ++++---- pentest/generate_report.py | 360 +++++++++++++++++++ pentest/phase4b_local_rules/run.py | 77 ++-- pentest/results/bpftool_cgroup_tree.txt | 2 - pentest/results/bpftool_prog_show.txt | 0 pentest/results/findings.tsv | 51 --- pentest/results/phase3-20260512T184948Z.pcap | Bin 19642 -> 0 bytes pentest/results/phase4b_local_rules.log | 1 - pentest/results/run.log | 81 ----- pentest/results/url_diff.tsv | 11 - pentest/test_catalog.py | 353 ++++++++++++++++++ proxy_agent/src/proxy/proxy_server.rs | 1 + 13 files changed, 832 insertions(+), 259 deletions(-) create mode 100644 pentest/generate_report.py delete mode 100644 pentest/results/bpftool_cgroup_tree.txt delete mode 100644 pentest/results/bpftool_prog_show.txt delete mode 100644 pentest/results/findings.tsv delete mode 100644 pentest/results/phase3-20260512T184948Z.pcap delete mode 100644 pentest/results/phase4b_local_rules.log delete mode 100644 pentest/results/run.log delete mode 100644 pentest/results/url_diff.tsv create mode 100644 pentest/test_catalog.py diff --git a/.gitignore b/.gitignore index 41e88617..425a529e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ # Visual Studio cache/options directory .vs/ + +# pentest run & results +/pentest/results/ +__pycache__/ diff --git a/pentest/DESIGN.md b/pentest/DESIGN.md index 13588fd6..8c1971c7 100644 --- a/pentest/DESIGN.md +++ b/pentest/DESIGN.md @@ -36,73 +36,73 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM ## 2. Pen-Test Scenarios (mapped to GPA invariants) ### A. Network exposure / listener hardening -| ID | Test | Expected | -|----|------|----------| -| A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | -| A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | -| A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | -| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. | Rejected; no proxying to arbitrary hosts. | -| A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | The proxy must be reachable **only** from in-guest processes. Any binding to an external interface would let a network-adjacent attacker (vNet peer, lateral mover) speak to GPA directly and turn it into a fabric oracle. This is the most basic perimeter test — if it fails, every other AuthN/AuthZ control is moot. | +| A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | Cross-checks A1 from the *attacker's* viewpoint: even if a misconfig binds to `0.0.0.0`, NSG/host firewall must still drop it. Verifies defense-in-depth between the listener config and the cloud network filter. | +| A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | Detects parser bugs (panic, OOM, integer overflow) in the hyper-based listener. A crash is an availability finding (fabric calls then fail-open or fail-closed depending on H4-style behavior); a desync would let one client smuggle requests under another client's identity — the worst-case AuthZ bypass. | +| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. | Rejected; no proxying to arbitrary hosts. | The proxy is HTTP/1.1-only and must not be a generic forward proxy. `CONNECT` or a smuggled `Host:` header could turn GPA into an SSRF gadget that talks to any internal IP from the *signed*, *authorized* GPA process — a privilege escalation against the host fabric. | +| A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | Confirms the destination allow-list is enforced regardless of how the request is framed. Prevents using GPA as an exfiltration / pivot proxy and ensures the `x-ms-azure-signature` is never minted for a non-fabric URL. | ### B. AuthN bypass — forging the signature -| ID | Test | Expected | -|----|------|----------| -| B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | -| B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | -| B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | -| B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | Validates the **fundamental authentication invariant**: only requests that traverse GPA receive a valid signature, and the fabric refuses any request that arrives without one. If a raw-socket call ever succeeds, an attacker can talk to WireServer/HostGAPlugin without going through any of GPA's authorization rules — effectively bypassing the entire product. | +| B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | A signature that is valid forever / for any caller would let any local process steal a previously-observed authorization. Confirms the signing scheme binds to time + connection context, not just URL+key. | +| B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | Two-layer test: (1) least-privilege access to key material on disk; (2) even with the key, the fabric should bind it to *this* VM's identity (provisioned via HostGAPlugin), so a stolen key is useless on another VM. Failure of (2) would mean a single host compromise yields cross-VM fabric access. | +| B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | Prevents header smuggling: a malicious in-guest caller must not be able to *inject* a forged signature that GPA then forwards verbatim. GPA must always overwrite/normalize these headers before forwarding so the fabric only ever sees GPA's own signature. | ### C. AuthZ bypass — cgroup / eBPF audit spoofing -| ID | Test | Expected | -|----|------|----------| -| C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | -| C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | -| C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | -| C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | -| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. | Connection still redirected (or blocked). If not — finding. | -| C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | -| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). | Either redirected the same, or refused. No bypass. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| C1 | Run a non-elevated process; attempt WireServer call (must be denied per `WireServer::authorize` requiring `runAsElevated`). | 403 Forbidden, audit entry written. | Baseline AuthZ check: WireServer is privileged-by-default at GPA. If a non-root caller could reach goal-state, *any* container or unprivileged guest user could read VM secrets/extension config. Also verifies the audit-log path actually fires on deny (operators rely on it for incident response). | +| C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | Confirms identity is captured from the kernel **at connect-time** via `SO_PEERCRED`, not re-read from `/proc` later. A TOCTOU here would let a process raise privileges *after* GPA cached its identity, or drop them to confuse audit attribution. | +| C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary). The matcher must use a kernel source of truth, not a user-controllable filesystem view. | +| C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | Documents the trust boundary: GPA cannot distinguish a victim process from a parasitic injector inside the same PID. The point of this test is to confirm the *boundary is documented*, not to claim it as a vuln — it sets reviewer expectations and prevents the threat model from drifting. | +| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. | Connection still redirected (or blocked). If not — finding. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | +| C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | Even if eBPF is bypassed in a netns, the request still has no signature and the fabric should refuse it. Combined with C5, this gives a *layered* assurance: either the redirect catches it, or the fabric does — never both failing simultaneously. | +| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). | Either redirected the same, or refused. No bypass. | Ensures the eBPF redirect matches the **destination IP**, not a string form, so address-encoding tricks (hex, decimal, IPv4-mapped IPv6) can't reach the fabric while skipping GPA. Classic SSRF-filter bypass technique applied at the network layer. | ### D. AuthZ rule engine fuzzing Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) -| ID | Test | Expected | -|----|------|----------| -| D1 | URL parsing differentials: `%2F` vs `/`, `%2f`, double-encoding, `;param`, `..`, trailing dot in host, mixed case scheme. | Rule match parity with server-side; no bypass of deny rule via encoding. | -| D2 | Path-traversal in `processFullPath` matcher (symlink to allowed binary). | Deny based on resolved path / inode. | -| D3 | Identity case-sensitivity, Unicode normalization in user names / process names. | Stable match. | -| D4 | Property-based fuzz of the rule loader with malformed `AuthorizationRules_*.json` — ensure invalid JSON / huge file = reject, default-closed. | Service stays up; falls back to safe defaults. | -| D5 | TOCTOU between rule reload and an in-flight request. | Authorization re-checked per request. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| D1 | URL parsing differentials: `%2F` vs `/`, `%2f`, double-encoding, `;param`, `..`, trailing dot in host, mixed case scheme. | Rule match parity with server-side; no bypass of deny rule via encoding. | The classic AuthZ-bypass family: rule engine and upstream server disagree on what a URL means, so a deny rule for `/foo/bar` is evaded by `/foo%2Fbar`. Phase 4b's S6 scenarios are the automated form of this; D1 is the broader fuzz that exercises every encoding edge case the matcher must canonicalize. | +| D2 | Path-traversal in `processFullPath` matcher (symlink to allowed binary). | Deny based on resolved path / inode. | Companion to C3 from the rule-engine side: ensures `exePath` matching is on a canonical kernel-resolved path, not a string the caller can manufacture via symlinks/bind-mounts. | +| D3 | Identity case-sensitivity, Unicode normalization in user names / process names. | Stable match. | A rule for identity `"root"` must not be bypassed by `"Root"` or a Unicode-homoglyph variant, and conversely an allow for `"alice"` must not accidentally match `"ALICE"`. Ensures the matcher's normalization (lowercasing, NFC) is applied consistently and documented. | +| D4 | Property-based fuzz of the rule loader with malformed `AuthorizationRules_*.json` — ensure invalid JSON / huge file = reject, default-closed. | Service stays up; falls back to safe defaults. | The loader is a privileged code path that runs every poll cycle. A panic here either (a) crashes the agent (DoS), or (b) leaves stale rules in memory (potentially fail-open). Phase 4b's S10 covers the local-rules variant; D4 covers the fabric-delivered rules path. | +| D5 | TOCTOU between rule reload and an in-flight request. | Authorization re-checked per request. | A request that began under permissive rules must not slip through after a deny rule has been published. Confirms the engine snapshots rules per-request and doesn't latch a pre-reload decision — important for incident response when an operator pushes an emergency-deny rule. | ### E. Key-keeper / provisioning -| ID | Test | Expected | -|----|------|----------| -| E1 | Permissions/ownership audit on `/var/lib/azure-proxy-agent/keys`, `/var/log/azure-proxy-agent`, the binary, the unit file, drop-ins. | Owned by root, mode 0700/0600 on secrets, no world-write. | -| E2 | Symlink attack: pre-create the keys/log path as a symlink to `/etc/shadow` before service start. | Service refuses to follow / aborts. | -| E3 | Disk-full / read-only `/var` — fault injection. | Service degrades safely; does **not** drop to "open" mode. | -| E4 | Kill `azure-proxy-agent` and observe whether eBPF redirect remains and whether traffic now reaches IMDS unauthenticated. | Either redirect persists denying traffic, or systemd restarts; no "fail-open" window allowing forged calls. | -| E5 | Downgrade the latched key (replay an old `keys/*` file). | Rejected by HostGAPlugin / refresh logic. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| E1 | Permissions/ownership audit on `/var/lib/azure-proxy-agent/keys`, `/var/log/azure-proxy-agent`, the binary, the unit file, drop-ins. | Owned by root, mode 0700/0600 on secrets, no world-write. | Foundational hardening: world-readable key material lets *any* local user steal the signing key (which then needs to be defeated only by B3-style identity binding). World-writable binaries/units allow trivial root escalation on next service restart. Cheap, high-signal check that catches packaging regressions. | +| E2 | Symlink attack: pre-create the keys/log path as a symlink to `/etc/shadow` before service start. | Service refuses to follow / aborts. | Detects unsafe `open()` patterns (no `O_NOFOLLOW`, no `mkdir`-then-`open` race) that would let a non-root attacker who briefly controls `/var/lib` redirect GPA's writes to overwrite arbitrary root-owned files at service start. | +| E3 | Disk-full / read-only `/var` — fault injection. | Service degrades safely; does **not** drop to "open" mode. | Verifies the **fail-closed invariant** under storage failure. If GPA can't persist keys/rules and silently falls back to allowing all traffic, an attacker who can fill `/var` (or trigger a remount-ro) gains unauthenticated fabric access. | +| E4 | Kill `azure-proxy-agent` and observe whether eBPF redirect remains and whether traffic now reaches IMDS unauthenticated. | Either redirect persists denying traffic, or systemd restarts; no "fail-open" window allowing forged calls. | Tests the crash/restart safety window. There must never be a moment where the eBPF redirect is gone *and* the userspace authenticator is absent — that interval would let in-guest attackers reach raw IMDS/WireServer. Also catches eBPF prog-leak bugs (G4 is the count-side check). | +| E5 | Downgrade the latched key (replay an old `keys/*` file). | Rejected by HostGAPlugin / refresh logic. | Prevents key-rollback attacks: a root attacker who keeps a snapshot of an older key must not be able to swap it in to replay/forge requests after the fabric has rotated. Confirms key versioning/monotonicity. | ### F. Local IPC / status surface -| ID | Test | Expected | -|----|------|----------| -| F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | -| F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | -| F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | The status/log surface is intentionally observable for diagnostics. The risk is data leakage — if the signing key, raw signatures, or token bodies appear in those files, any local user becomes equivalent to root for fabric AuthN. Confirms the redaction policy is consistently applied. | +| F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | If an attacker can inject newlines into the audit log, they can fabricate "PASS" entries that hide their real activity, or break SIEM parsers. Tests that all caller-controlled strings are escaped before being written. | +| F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | Same threat family as E2 but specific to log rotation. A non-root attacker who can swap the log file with a symlink to `/etc/passwd` between rotation and write would get an arbitrary-file-overwrite as root. | ### G. Resource / DoS -| ID | Test | Expected | -|----|------|----------| -| G1 | Connection flood to `127.0.0.1:3080` from many PIDs. | Throttled; CPU/Memory cap from systemd drop-ins (`50-MemoryMax.conf`) kicks in; service survives. | -| G2 | Large-body POST / very long URL. | Bounded; rejected with 4xx. | -| G3 | Many distinct cgroups generating audit entries — exercise `redirector::lookup_audit` map size. | No unbounded growth; LRU/eviction. | -| G4 | Crash recovery: SIGKILL → systemd restart loop. | Restart succeeds; no orphan eBPF programs each restart (check `bpftool prog show`). | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| G1 | Connection flood to `127.0.0.1:3080` from many PIDs. | Throttled; CPU/Memory cap from systemd drop-ins (`50-MemoryMax.conf`) kicks in; service survives. | A local DoS that takes GPA offline is effectively a fail-open enabler if combined with E4 weaknesses (window where redirect is up but authenticator is dead). Also a noisy-neighbor risk on shared VMs. | +| G2 | Large-body POST / very long URL. | Bounded; rejected with 4xx. | Memory-amplification / parser DoS guard. Unbounded body buffering is a common cause of OOM in proxy code; bounding it also mitigates one class of A3-style desyncs. | +| G3 | Many distinct cgroups generating audit entries — exercise `redirector::lookup_audit` map size. | No unbounded growth; LRU/eviction. | The eBPF audit map is finite. Without LRU/eviction, an attacker spawning many short-lived cgroups can fill the map and evict legitimate entries — effectively erasing identity for real callers and forcing a fail-open or fail-closed mode change. | +| G4 | Crash recovery: SIGKILL → systemd restart loop. | Restart succeeds; no orphan eBPF programs each restart (check `bpftool prog show`). | eBPF programs not cleanly detached on shutdown accumulate across restarts — each one keeps a kernel allocation pinned, eventually exhausting `kmemleak`/JIT memory and degrading the host. Also catches double-attach bugs that make the redirect ambiguous. | ### H. Update / extension handler -| ID | Test | Expected | -|----|------|----------| -| H1 | `proxy_agent_setup` backup/restore tampering; supply a malicious previous-version archive. | Signature/manifest verification rejects. | -| H2 | Extension handler ([handler_main.rs](../proxy_agent_extension/src/handler_main.rs)) command-injection in settings / env. | Sanitized. | +| ID | Test | Expected | Purpose / Why this matters | +|----|------|----------|----------------------------| +| H1 | `proxy_agent_setup` backup/restore tampering; supply a malicious previous-version archive. | Signature/manifest verification rejects. | The setup tool runs as root and replaces the production binary. If it accepts an unverified rollback archive, a root attacker (or a compromised extension publisher) can persist a backdoored GPA build that still looks legitimate to systemd. Verifies the supply-chain boundary at the agent's own update path. | +| H2 | Extension handler ([handler_main.rs](../proxy_agent_extension/src/handler_main.rs)) command-injection in settings / env. | Sanitized. | Settings come from the Azure control plane and are passed through Linux shell-ish surfaces (env vars, command lines). An unescaped `$(...)` or `;` would let a control-plane-side attacker (or a misconfigured ARM template) execute arbitrary commands as root on the guest — a classic confused-deputy escalation. | --- @@ -143,18 +143,18 @@ Automated by [pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py). #### IMDS scenarios (target `imds`) -| ID | Rule shape | Probes & expected status | -|----|------------|--------------------------| -| `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | -| `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | -| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules | `/metadata/instance` → **200** (audit-only) | -| `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | -| `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | -| `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | -| `IMDS-S7-query-param-required` | Privilege requires `api-version=2021-02-01` | matching → 200, missing → 403, wrong → 403 | -| `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | -| `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | -| `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | **Control / smoke test.** Establishes that the harness, the listener, the eBPF redirect and IMDS itself are all healthy *before* we start denying. If S1 fails, every later "deny worked" result is suspect because we haven't proven we can ever get a 200. | +| `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | **Default-deny invariant.** With enforce mode and no allow rules, *everything* must be blocked. A FAIL here means the engine fails-open on missing rules — the highest-severity AuthZ regression possible. | +| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules | `/metadata/instance` → **200** (audit-only) | **Audit-mode separation.** Confirms `mode=audit` LOGS denials but does not BLOCK — critical for safe rule rollouts where operators want to observe impact before flipping to enforce. (Currently recorded as INFO from local rules: the merge logic in `local_rules.rs` honors `mode` only from the remote rule, so audit mode must be tested via a fabric/mock-fabric rule.) | +| `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | **Path-scoping precision.** Allowing one path must not leak access to siblings. Specifically guards against the `oauth2/token` endpoint (which mints managed-identity tokens) being reachable when only `/metadata/instance` was authorized. | +| `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | **Identity is required.** A path-only allow must not match if the identity binding fails. Catches engines that short-circuit on path match and forget to AND with identity — a privilege-escalation if any in-guest user can reach an allow-listed path. | +| `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | **Canonicalization of the request URL.** The classic AuthZ-bypass family from the SSRF/WAF world applied to GPA: percent-encoded slashes, double-encoding, and dot-segments must not let a request "look like" `/metadata/instance` to the matcher while actually reaching `/metadata/identity/oauth2/token`. (Dot-segment probes accept 403 OR 404 — both prove the bypass attempt did not reach a permitted endpoint.) | +| `IMDS-S7-query-param-required` | Privilege requires `api-version=2021-02-01` | matching → 200, missing → 403, wrong → 403 | **Query-parameter matching.** Some allow rules are pinned to a specific API version (e.g. to lock down a known-safe response shape). Verifies the matcher actually inspects the query string and doesn't treat path-only as sufficient. | +| `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | **None == wildcard semantics.** Identity fields that are unset must act as wildcards (match-anything), and at least one set field must AND-match. Guards against accidental over-restriction when operators write a minimal identity (group-only is a common operational pattern). | +| `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | **Per-process identity provider works.** Verifies the kernel-side audit map actually populates `claims.processFullPath` so `exePath` rules are enforceable. If both halves return the same code, identity attribution is broken on this kernel — a regression that would let any process impersonate any other. | +| `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | **Fail-closed on rule-parse error.** A corrupted local rules file must NOT result in "no rules → default-allow" or in the agent crashing. Confirms the loader builds a deny-all sentinel ruleset on parse failure rather than silently reverting to an unrestricted state. | #### WireServer scenarios (target `wireserver`) @@ -169,18 +169,18 @@ WireServer is privileged-by-default at the proxy layer (`runAsElevated` required /?comp=versions ``` -| ID | Rule shape | Probes & expected status | -|----|------------|--------------------------| -| `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | -| `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | -| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules | goalstate → **200** (audit-only) | -| `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | -| `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | -| `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | -| `WS-S7-query-param-required` | Require `comp=goalstate` | matching → 200, no `comp` → 403, `comp=hostingenvironmentconfig` → 403, `comp=goalstate&incarnation=1` → 200 | -| `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | -| `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | -| `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | +| ID | Rule shape | Probes & expected status | Why this scenario | +|----|------------|--------------------------|-------------------| +| `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | **Control / smoke test for the privileged path.** WireServer requires elevated identity by default, so this also confirms the test process is correctly recognized as root — prerequisite for every later WS scenario. | +| `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | **Default-deny on the high-value target.** WireServer hands out goal-state, certificates and extension config; an empty-rules fail-open here directly leaks VM secrets. Highest-severity gate in the WS suite. | +| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules | goalstate → **200** (audit-only) | **Audit-mode separation for WS** (same purpose as IMDS-S3, recorded as INFO via local rules for the same merge-semantics reason). Audit on WS is operationally critical because operators need to dry-run rules before risking guest-agent breakage. | +| `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | **Comp-scoped path matching.** All WS endpoints share the same path (`/machine/`) and differ only by query `comp=`. Confirms the engine's combined path+query matching is precise enough to allow `goalstate` while blocking the much more sensitive `certificates`/`extensionsConfig`. | +| `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | **Identity-required on WS.** Same purpose as IMDS-S5 but on the privileged target: an identity mismatch must turn an otherwise-matching path+query rule into a deny. | +| `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | **Canonicalization on WS.** Mirrors IMDS-S6 plus two WS-specific concerns: (1) extra slashes (`//machine///`) must not bypass the `/machine/` prefix check, (2) comp-value comparison must be case-insensitive **on both sides** — if normalization is one-sided, an attacker writes `comp=Certificates` to evade an allow that was authored as `comp=certificates`, or vice versa. | +| `WS-S7-query-param-required` | Require `comp=goalstate` | matching → 200, no `comp` → 403, `comp=hostingenvironmentconfig` → 403, `comp=goalstate&incarnation=1` → 200 | **Required vs extra params.** Required `comp=` must be present, but extra unrelated params (`incarnation`) must NOT cause a false deny — the WALinuxAgent legitimately appends extras. Catches over-strict matchers that compare the whole query string. (The 200 probe accepts 200 OR 400 since WireServer itself may reject the extra param; both prove GPA forwarded.) | +| `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | **Wildcard semantics on WS.** Same purpose as IMDS-S8: confirms the operator-friendly minimal-identity pattern works on the privileged target. | +| `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | **Per-process identity on WS.** Verifies `exePath` differentiation works for the privileged target — important because operators commonly want to limit WS access to the WALinuxAgent binary specifically, not "any root process". | +| `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | **Fail-closed for WS rules.** Same purpose as IMDS-S10; failure here is even more severe because a parse-error fail-open on WS exposes certificates and extension config. | #### Invocation diff --git a/pentest/generate_report.py b/pentest/generate_report.py new file mode 100644 index 00000000..cd9655f4 --- /dev/null +++ b/pentest/generate_report.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Generate an HTML pen-test report from pentest/results/findings.tsv. + +Each test row in the report includes inline design notes, how it's automated, +how to reproduce it (script + manual), and — for FAILed rows — the suggested +fix. Metadata is sourced from test_catalog.py. +""" +from __future__ import annotations + +import datetime as dt +import html +import platform +import re +import socket +import sys +from collections import Counter, defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +RESULTS = ROOT / "results" +FINDINGS = RESULTS / "findings.tsv" +URL_DIFF = RESULTS / "url_diff.tsv" +RUN_LOG = RESULTS / "run.log" +PHASE4B_LOG = RESULTS / "phase4b_local_rules.log" +OUTPUT = RESULTS / "report.html" + +sys.path.insert(0, str(ROOT)) +from test_catalog import lookup as catalog_lookup # noqa: E402 + +PHASE_PATTERNS = [ + ("Phase 2 — Listener / DoS (A,G)", re.compile(r"^(A\d|G\d)")), + ("Phase 3 — AuthN / AuthZ (B,C,P3)", re.compile(r"^(B\d|C\d|P3)")), + ("Phase 4b — Local-file rules (IMDS)", re.compile(r"^IMDS-S")), + ("Phase 4b — Local-file rules (WireServer)", re.compile(r"^WS-S")), + ("Phase 4b — pre-flight", re.compile(r"^PRE\b")), + ("Phase 5 — Filesystem / state (E,F,P5)", re.compile(r"^(E\d|F\d|P5)")), +] + +SEVERITY = { + "FAIL": ("fail", "High"), + "PASS": ("pass", "—"), + "INFO": ("info", "Info"), +} + + +def phase_for(tid: str) -> str: + for name, rx in PHASE_PATTERNS: + if rx.match(tid): + return name + return "Other" + + +def load_findings() -> list[dict]: + rows: list[dict] = [] + if not FINDINGS.exists(): + return rows + for line in FINDINGS.read_text().splitlines(): + if not line: + continue + parts = line.split("\t", 3) + while len(parts) < 4: + parts.append("") + ts, tid, status, msg = parts + rows.append({"ts": ts, "id": tid, "status": status.upper(), + "msg": msg, "phase": phase_for(tid)}) + return rows + + +def load_url_diff_latest() -> list[list[str]]: + if not URL_DIFF.exists(): + return [] + rows = [ln.split("\t") for ln in URL_DIFF.read_text().splitlines() if ln] + if not rows: + return rows + last_ts = rows[-1][0] + return [r for r in rows if r[0] == last_ts] + + +def details_html(row: dict) -> str: + info = catalog_lookup(row["id"]) + if info is None: + return "no catalog entry for this ID" + + def section(label: str, body: str, mono: bool = False) -> str: + cls = "mono" if mono else "" + return (f"
{label}
" + f"
{html.escape(body)}
") + + parts: list[str] = [] + if info.get("title"): + parts.append(f"
{html.escape(info['title'])}
") + if info.get("design"): + parts.append(section("Design", info["design"])) + if info.get("automation"): + parts.append(section("Automation", info["automation"])) + if info.get("repro_script"): + parts.append(section("Repro (script)", info["repro_script"], mono=True)) + if info.get("repro_manual"): + parts.append(section("Repro (manual)", info["repro_manual"], mono=True)) + if row["status"] == "FAIL" and info.get("fix"): + parts.append( + "
Suggested fix
" + f"
{html.escape(info['fix'])}
" + ) + return "".join(parts) + + +def row_html(r: dict, *, with_phase_col: bool) -> str: + cls, sev = SEVERITY.get(r["status"], ("info", "Info")) + badge_text = sev if r["status"] == "FAIL" and with_phase_col else r["status"] + open_attr = " open" if r["status"] == "FAIL" else "" + cells = [ + f"{html.escape(r['ts'])}", + f"{html.escape(r['id'])}", + ] + if with_phase_col: + cells.append(f"{html.escape(r['phase'])}") + cells.append(f"{html.escape(badge_text)}") + cells.append( + "" + f"
{html.escape(r['msg'])}
" + f"
details" + f"
{details_html(r)}
" + "" + ) + return "" + "".join(cells) + "" + + +CSS = """ +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; } +h1 { margin: 0 0 4px 0; font-size: 24px; } +.sub { color: #57606a; margin-bottom: 24px; font-size: 13px; } +.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; } +.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 16px 20px; flex: 1 1 140px; } +.card .num { font-size: 28px; font-weight: 700; } +.card .lbl { font-size: 12px; color: #57606a; text-transform: uppercase; letter-spacing: .04em; } +.card.pass .num { color: #1a7f37; } +.card.fail .num { color: #cf222e; } +.card.info .num { color: #9a6700; } +section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; margin-bottom: 20px; } +section > h2 { margin: 0; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-size: 16px; + background: #f6f8fa; border-radius: 8px 8px 0 0; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eaeef2; vertical-align: top; } +th { background: #f6f8fa; font-weight: 600; color: #57606a; } +tr:last-child > td { border-bottom: none; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; } +.badge.pass { background: #dafbe1; color: #116329; } +.badge.fail { background: #ffebe9; color: #82071e; } +.badge.info { background: #fff8c5; color: #7d4e00; } +.id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; margin-bottom: 4px; } +.ts { color: #57606a; white-space: nowrap; font-variant-numeric: tabular-nums; } +details > summary { cursor: pointer; color: #0969da; font-size: 12px; list-style: none; } +details > summary::-webkit-details-marker { display: none; } +details > summary::before { content: '▸ '; transition: transform .15s; } +details[open] > summary::before { content: '▾ '; } +.detbox { display: flex; flex-direction: column; gap: 6px; padding: 8px 0 4px 0; } +.dettitle { font-weight: 600; color: #24292f; } +.det { display: flex; gap: 10px; flex-wrap: wrap; } +.detlbl { flex: 0 0 110px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #57606a; padding-top: 2px; } +.detval { flex: 1 1 320px; font-size: 12px; } +.detval.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; + background: #f6f8fa; border: 1px solid #eaeef2; border-radius: 4px; padding: 6px 8px; } +.det.fix .detlbl { color: #cf222e; } +.det.fix .detval { background: #ffebe9; border: 1px solid #ffcecb; border-radius: 4px; padding: 6px 8px; color: #82071e; white-space: pre-wrap; } +.nodet { color: #8c959f; font-size: 12px; font-style: italic; } +.legend { font-size: 12px; color: #57606a; margin-bottom: 12px; } +.footer { color: #57606a; font-size: 12px; margin-top: 24px; text-align: center; } +.meta { font-size: 12px; color: #57606a; padding: 8px 16px 0; } +.toolbar { display: flex; gap: 8px; align-items: center; padding: 6px 16px; border-bottom: 1px solid #eaeef2; + background: #fafbfc; font-size: 12px; } +.toolbar button { font: inherit; padding: 3px 10px; border: 1px solid #d0d7de; border-radius: 6px; + background: #fff; color: #24292f; cursor: pointer; } +.toolbar button:hover { background: #eef1f4; } +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #c9d1d9; } + .card, section { background: #161b22; border-color: #30363d; } + section > h2, th, .toolbar { background: #161b22; color: #8b949e; border-color: #30363d; } + td { border-color: #21262d; } + .badge.pass { background: #033a16; color: #56d364; } + .badge.fail { background: #3c0a13; color: #ff7b72; } + .badge.info { background: #3a2d04; color: #e3b341; } + details > summary { color: #58a6ff; } + .detval.mono { background: #0d1117; border-color: #30363d; } + .det.fix .detval { background: #3c0a13; border-color: #5a1018; color: #ff7b72; } + .toolbar button { background: #21262d; color: #c9d1d9; border-color: #30363d; } + .toolbar button:hover { background: #30363d; } +} +""" + +JS = """ +function expandIn(el, open){ el.querySelectorAll('details.row').forEach(d => d.open = open); } +""" + + +def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: + total = len(findings) + counts = Counter(r["status"] for r in findings) + n_pass, n_fail, n_info = counts.get("PASS", 0), counts.get("FAIL", 0), counts.get("INFO", 0) + + by_phase: dict[str, list[dict]] = defaultdict(list) + for r in findings: + by_phase[r["phase"]].append(r) + + phase_order = [name for name, _ in PHASE_PATTERNS] + ["Other"] + phase_order = [p for p in phase_order if p in by_phase] + fail_rows = [r for r in findings if r["status"] == "FAIL"] + + generated = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + host = socket.gethostname() + osinfo = f"{platform.system()} {platform.release()} ({platform.machine()})" + + p: list[str] = [] + p.append("") + p.append("") + p.append("GPA Pen-Test Report") + p.append(f"") + p.append("

Guest Proxy Agent — Pen-Test Report

") + p.append( + f"
Generated {generated} · host {html.escape(host)} · " + f"{html.escape(osinfo)} · " + "design source: pentest/DESIGN.md · catalog: pentest/test_catalog.py
" + ) + + p.append("
") + p.append(f"
{total}
Total tests
") + p.append(f"
{n_pass}
Pass
") + p.append(f"
{n_fail}
Fail
") + p.append(f"
{n_info}
Info
") + p.append("
") + + p.append("
Each row is expandable — click details for " + "design, automation, manual repro, and (when failed) the suggested fix. " + "FAIL rows are auto-expanded.
") + + # Failures section + p.append("

Failures requiring triage

") + p.append("
" + "" + "" + "
") + if not fail_rows: + p.append("
No FAIL entries — all invariants held.
") + else: + p.append("" + "" + "") + for r in fail_rows: + p.append(row_html(r, with_phase_col=True)) + p.append("
TimeTest IDPhaseSeverityDetail
") + p.append("
") + + # Per-phase + for phase in phase_order: + rows = by_phase[phase] + c = Counter(r["status"] for r in rows) + p.append("
") + p.append( + f"

{html.escape(phase)} " + f"" + f"{c.get('PASS',0)} pass · {c.get('FAIL',0)} fail · {c.get('INFO',0)} info" + f"

" + ) + p.append("
" + "" + "" + "
") + p.append("" + "" + "") + for r in rows: + p.append(row_html(r, with_phase_col=False)) + p.append("
TimeTest IDStatusDetail
") + + # URL diff appendix + if url_rows: + p.append("

Phase 4 — URL/encoding differential (latest run)

") + p.append("
" + "Compares response codes for canonical vs encoded variants of the same path. " + "DIFF rows warrant review.
" + "Repro (script): python3 pentest/phase4_rules_fuzz/url_diff.py
" + "Repro (manual): " + "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' 'http://169.254.169.254<variant-path>'" + "
") + p.append("" + "" + "") + for r in url_rows: + ts = r[0] if len(r) > 0 else "" + variant = r[1] if len(r) > 1 else "" + status = r[2] if len(r) > 2 else "" + verdict = r[3] if len(r) > 3 else "" + path = r[4] if len(r) > 4 else "" + cls = "fail" if verdict.strip().upper() == "DIFF" else "pass" + p.append("" + f"" + f"" + f"" + f"" + f"" + "") + p.append("
TimeVariantStatusVerdictPath
{html.escape(ts)}{html.escape(variant)}{html.escape(status)}{html.escape(verdict)}{html.escape(path)}
") + + # Repro cheat-sheet + p.append("

How to reproduce — cheat sheet

") + p.append("

Run from the workspace root:

") + p.append("
"
+             "# All safe phases (2, 3, 4, 5):\n"
+             "bash pentest/run_all.sh\n\n"
+             "# Individual phases:\n"
+             "bash    pentest/phase2_listener/run.sh           # A1–A5, G1\n"
+             "bash    pentest/phase3_authn_authz/run.sh        # B1, B3, B4, C1, C5, C7 (+ tcpdump)\n"
+             "python3 pentest/phase4_rules_fuzz/url_diff.py    # URL encoding differential\n"
+             "bash    pentest/phase5_state_fs/audit.sh         # E1, F1 (+ bpftool snapshots)\n\n"
+             "# Phase 4b — local-file rules (needs root + useLocalFileRules=true on fabric):\n"
+             "sudo python3 pentest/phase4b_local_rules/run.py\n"
+             "sudo python3 pentest/phase4b_local_rules/run.py --target imds\n"
+             "sudo python3 pentest/phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass\n\n"
+             "# Regenerate this report:\n"
+             "python3 pentest/generate_report.py"
+             "
") + p.append("
") + + # Artifacts + p.append("

Artifacts

    ") + for path in [FINDINGS, URL_DIFF, RUN_LOG, PHASE4B_LOG]: + if path.exists(): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + for path in sorted(RESULTS.glob("phase3-*.pcap")): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + for path in sorted(RESULTS.glob("bpftool_*.txt")): + p.append(f"
  • {html.escape(str(path))} ({path.stat().st_size} bytes)
  • ") + p.append("
") + + p.append("") + p.append("") + return "".join(p) + + +def main() -> int: + findings = load_findings() + url_rows = load_url_diff_latest() + if not findings: + print("No findings to report.", file=sys.stderr) + return 1 + OUTPUT.write_text(build_html(findings, url_rows), encoding="utf-8") + counts = Counter(r["status"] for r in findings) + print(f"Wrote {OUTPUT} ({len(findings)} entries: " + f"{counts.get('PASS',0)} pass, {counts.get('FAIL',0)} fail, " + f"{counts.get('INFO',0)} info)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pentest/phase4b_local_rules/run.py b/pentest/phase4b_local_rules/run.py index 0b844d76..5a5ad7d3 100755 --- a/pentest/phase4b_local_rules/run.py +++ b/pentest/phase4b_local_rules/run.py @@ -128,15 +128,19 @@ def assert_use_local_file_rules_active() -> None: def get_current_user_identity() -> dict: """Return identity dict suitable for the rules engine, matching the - process/user that will actually be sending requests (root in this run).""" + process/user that will actually be sending requests (root in this run). + + NOTE: probes are sent via python http.client, so processName/exePath + would be 'python3' / '/usr/bin/python3'. The engine AND-matches every + Some(_) field on Identity (None == wildcard), so we deliberately leave + exePath/processName UNSET to avoid false-alarm 403s. The dedicated S9 + scenarios that target exePath matching build their own identity inline.""" user = subprocess.check_output(["id", "-un"], text=True).strip() grp = subprocess.check_output(["id", "-gn"], text=True).strip() return { "name": "selfRoot", "userName": user, "groupName": grp, - "exePath": "/usr/bin/curl", - "processName": "curl", } @@ -211,7 +215,7 @@ def write_raw(path: Path, raw_text: str) -> None: class Probe: name: str path: str - expected: int # expected HTTP status from GPA's perspective + expected: Any # int or tuple/list of acceptable HTTP statuses method: str = "GET" @@ -265,21 +269,17 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # S3: audit mode — denials are LOGGED but allowed through - scenarios.append(Scenario( - sid=f"{pfx}-S3-audit-deny-empty", - target=target, - description="mode=audit, defaultAccess=deny, no rules → still 200 (audit only)", - rules={ - "defaultAccess": "deny", - "mode": "audit", - "id": "pentest-s3", - "rules": {}, - }, - probes=[ - Probe("instance", "/metadata/instance?api-version=2021-02-01", 200), - ], - )) + # S3: audit mode — NOT TESTABLE from local-rules. + # The local-rules merge logic in proxy_agent/src/key_keeper/local_rules.rs + # only honors `defaultAccess` and `rules` from the local file; `mode` is + # taken entirely from the remote (fabric-delivered) AuthorizationItem. + # Since the live remote mode is `enforce`, any `mode=audit` set in a + # local rules file is silently dropped, so this scenario can't be + # exercised here. Recorded as INFO for traceability. + record(f"{pfx}-S3-audit-deny-empty", "INFO", + "audit-mode behavior is not testable via local rules: " + "merge_authorization_item() takes `mode` from remote rules only " + "(see proxy_agent/src/key_keeper/local_rules.rs).") # S4: explicit allow for /metadata/instance, deny everything else scenarios.append(Scenario( @@ -362,10 +362,13 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: Probe("token_question_encoded", "/metadata/identity/oauth2/token%3Fapi-version=2018-02-01" "&resource=https://management.azure.com/", 403), + # Both 403 (denied by GPA) and 404 (forwarded but rejected by IMDS) + # demonstrate the bypass attempt FAILED — neither reaches a permitted + # endpoint. Accept either as a successful denial. Probe("token_dot_segments", "/metadata/./identity/../identity/oauth2/token" "?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), + "&resource=https://management.azure.com/", (403, 404)), ], )) @@ -524,21 +527,11 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # WS-S3: audit only - scenarios.append(Scenario( - sid=f"{pfx}-S3-audit-deny-empty", - target=target, - description="mode=audit, defaultAccess=deny, no rules → still 200 (audit only)", - rules={ - "defaultAccess": "deny", - "mode": "audit", - "id": "pentest-ws-s3", - "rules": {}, - }, - probes=[ - Probe("goalstate_audit", GOALSTATE, 200), - ], - )) + # WS-S3: audit only — NOT TESTABLE from local-rules (see IMDS-S3 note). + record(f"{pfx}-S3-audit-deny-empty", "INFO", + "audit-mode behavior is not testable via local rules: " + "merge_authorization_item() takes `mode` from remote rules only " + "(see proxy_agent/src/key_keeper/local_rules.rs).") # WS-S4: only allow goalstate; everything else 403 scenarios.append(Scenario( @@ -625,8 +618,10 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: "/machine%2F?comp=certificates", 403), Probe("certs_question_encoded", "/machine/%3Fcomp=certificates", 403), + # 403 (denied at GPA) or 404 (forwarded but rejected upstream) both + # mean the bypass attempt failed. Accept either as success. Probe("certs_dot_segments", - "/machine/./../machine/?comp=certificates", 403), + "/machine/./../machine/?comp=certificates", (403, 404)), Probe("certs_extra_slashes", "//machine///?comp=certificates", 403), Probe("certs_value_case", @@ -662,8 +657,11 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: Probe("matching_comp", GOALSTATE, 200), Probe("missing_comp", "/machine/", 403), Probe("wrong_comp", "/machine/?comp=hostingenvironmentconfig", 403), + # GPA must FORWARD this (extra params don't break the privilege match); + # WireServer itself may then 400 on the unknown param. Both 200 and 400 + # mean GPA forwarded — only a 403 would indicate a GPA-side false deny. Probe("extra_param_ok", - "/machine/?comp=goalstate&incarnation=1", 200), + "/machine/?comp=goalstate&incarnation=1", (200, 400)), ], )) @@ -766,7 +764,10 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: passes = fails = 0 for p in sc.probes: actual = send(sc.target, p.method, p.path) - ok = (actual == p.expected) + if isinstance(p.expected, (tuple, list, set)): + ok = actual in p.expected + else: + ok = (actual == p.expected) record(f"{sc.sid}/{p.name}", "PASS" if ok else "FAIL", f"expected={p.expected} actual={actual} path={p.path[:80]}") diff --git a/pentest/results/bpftool_cgroup_tree.txt b/pentest/results/bpftool_cgroup_tree.txt deleted file mode 100644 index 90f561f2..00000000 --- a/pentest/results/bpftool_cgroup_tree.txt +++ /dev/null @@ -1,2 +0,0 @@ -CgroupPath -ID AttachType AttachFlags Name diff --git a/pentest/results/bpftool_prog_show.txt b/pentest/results/bpftool_prog_show.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/pentest/results/findings.tsv b/pentest/results/findings.tsv deleted file mode 100644 index 26136471..00000000 --- a/pentest/results/findings.tsv +++ /dev/null @@ -1,51 +0,0 @@ -2026-05-12T18:49:49Z P3-cap INFO capturing to /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap (pid=159546) -2026-05-12T18:49:49Z B1a PASS IMDS reachable through GPA-redirected path (200) -2026-05-12T18:49:49Z B1b INFO ss snapshot of own connection: -2026-05-12T18:49:49Z B4 PASS request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap) -2026-05-12T18:49:49Z B3 PASS key files in /var/lib/azure-proxy-agent/keys are not group/world-readable -2026-05-12T18:49:50Z C1 PASS non-elevated WireServer call denied (403) -2026-05-12T18:49:50Z C7[http://0xa9fea9fe/metadata/instance?api-version=2021-02-01] INFO code=200 (expect either same as canonical or refused; manual diff) -2026-05-12T18:49:50Z C7[http://2852039166/metadata/instance?api-version=2021-02-01] INFO code=200 (expect either same as canonical or refused; manual diff) -2026-05-12T18:49:50Z C5 FAIL IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify) -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys] PASS mode=700 owner=root -2026-05-12T18:49:50Z E1[/var/log/azure-proxy-agent] PASS mode=755 owner=root -2026-05-12T18:49:50Z E1[/usr/sbin/azure-proxy-agent] FAIL mode=775 (max 755) owner=root -2026-05-12T18:49:50Z E1[/usr/lib/systemd/system/azure-proxy-agent.service] FAIL mode=664 (max 644) owner=root -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] INFO missing -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/status.tag] INFO missing -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] INFO missing -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] INFO missing -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] INFO missing -2026-05-12T18:49:50Z E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] INFO missing -2026-05-12T18:49:50Z F1[status.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] PASS no obvious secret-shaped strings -2026-05-12T18:49:50Z P5-bpftool INFO saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results -2026-05-12T18:50:12Z A1 PASS 3080 only bound to loopback (external IFs: 10.80.0.11) -2026-05-12T18:50:12Z A1b PASS loopback connect to 3080 OK -2026-05-12T18:50:12Z A1c PASS 3080 not reachable on external IP 10.80.0.11 -2026-05-12T18:50:19Z A3 PASS service survived malformed requests (pid=155895) -2026-05-12T18:50:20Z A4 FAIL CONNECT not refused (status='') -2026-05-12T18:50:20Z A5 PASS explicit-proxy to example.com refused (status=421) -2026-05-12T18:50:20Z G1 PASS survived 200-conn burst (pid=155895) -2026-05-12T18:50:20Z E1[/var/lib/azure-proxy-agent/keys] PASS mode=700 owner=root -2026-05-12T18:50:21Z E1[/var/log/azure-proxy-agent] PASS mode=755 owner=root -2026-05-12T18:50:21Z E1[/usr/sbin/azure-proxy-agent] FAIL mode=775 (max 755) owner=root -2026-05-12T18:50:21Z E1[/usr/lib/systemd/system/azure-proxy-agent.service] FAIL mode=664 (max 644) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/status.tag] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] FAIL mode=644 (max 600) owner=root -2026-05-12T18:50:21Z F1[status.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] PASS no obvious secret-shaped strings -2026-05-12T18:50:21Z P5-bpftool INFO saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results -2026-05-12T18:59:42Z PRE FAIL useLocalFileRules-true NOT present in /var/log/azure-proxy-agent/AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json; the fabric is delivering plain rule ids — local rules will be ignored. Enable useLocalFileRules from the control plane and retry. diff --git a/pentest/results/phase3-20260512T184948Z.pcap b/pentest/results/phase3-20260512T184948Z.pcap deleted file mode 100644 index f518c106046f547bbc47a5bcadcf345da6be7af1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19642 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 diff --git a/pentest/results/phase4b_local_rules.log b/pentest/results/phase4b_local_rules.log deleted file mode 100644 index 3ab7bee4..00000000 --- a/pentest/results/phase4b_local_rules.log +++ /dev/null @@ -1 +0,0 @@ -[FAIL] PRE useLocalFileRules-true NOT present in /var/log/azure-proxy-agent/AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json; the fabric is delivering plain rule ids — local rules will be ignored. Enable useLocalFileRules from the control plane and retry. diff --git a/pentest/results/run.log b/pentest/results/run.log deleted file mode 100644 index 9be5ef49..00000000 --- a/pentest/results/run.log +++ /dev/null @@ -1,81 +0,0 @@ -Missing commands: nmap - -[INFO] P3-cap capturing to /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap (pid=159546) -[PASS] B1a IMDS reachable through GPA-redirected path (200) -[INFO] B1b ss snapshot of own connection: -[PASS] B4 request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pcap) -[PASS] B3 key files in /var/lib/azure-proxy-agent/keys are not group/world-readable -[PASS] C1 non-elevated WireServer call denied (403) -[INFO] C7[http://0xa9fea9fe/metadata/instance?api-version=2021-02-01] code=200 (expect either same as canonical or refused; manual diff) -[INFO] C7[http://2852039166/metadata/instance?api-version=2021-02-01] code=200 (expect either same as canonical or refused; manual diff) -[FAIL] C5 IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify) - -Phase 3 complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv -Pcap: /home/zpeng/src/GuestProxyAgent/pentest/results/phase3-20260512T184948Z.pcap - -/home/zpeng/src/GuestProxyAgent/pentest/phase4_rules_fuzz/url_diff.py:74: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC). - ts = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z" -baseline (200) for canonical path - canonical status=200 same - uppercase_pct status=200 same - lowercase_pct status=200 same - double_encoded status=404 DIFF - trailing_dot_path status=404 DIFF - dot_segments status=404 DIFF - param_injection status=404 DIFF - case_path status=404 DIFF - extra_slashes status=404 DIFF - fragment status=200 same - unicode_dotless status=-1 DIFF - -Wrote 11 rows to /home/zpeng/src/GuestProxyAgent/pentest/phase4_rules_fuzz/../results/url_diff.tsv; 7 differ from baseline. - -[PASS] E1[/var/lib/azure-proxy-agent/keys] mode=700 owner=root -[PASS] E1[/var/log/azure-proxy-agent] mode=755 owner=root -[FAIL] E1[/usr/sbin/azure-proxy-agent] mode=775 (max 755) owner=root -[FAIL] E1[/usr/lib/systemd/system/azure-proxy-agent.service] mode=664 (max 644) owner=root -[INFO] E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] missing -[INFO] E1[/var/lib/azure-proxy-agent/keys/status.tag] missing -[INFO] E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] missing -[INFO] E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] missing -[INFO] E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] missing -[INFO] E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] missing -[PASS] F1[status.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] no obvious secret-shaped strings -[INFO] P5-bpftool saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results - -Phase 5 (read-only audit) complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv - -All safe phases done. Findings: /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv -[PASS] A1 3080 only bound to loopback (external IFs: 10.80.0.11) -[PASS] A1b loopback connect to 3080 OK -[PASS] A1c 3080 not reachable on external IP 10.80.0.11 -[PASS] A3 service survived malformed requests (pid=155895) -[FAIL] A4 CONNECT not refused (status='') -[PASS] A5 explicit-proxy to example.com refused (status=421) -[PASS] G1 survived 200-conn burst (pid=155895) - -Phase 2 complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv -[PASS] E1[/var/lib/azure-proxy-agent/keys] mode=700 owner=root -[PASS] E1[/var/log/azure-proxy-agent] mode=755 owner=root -[FAIL] E1[/usr/sbin/azure-proxy-agent] mode=775 (max 755) owner=root -[FAIL] E1[/usr/lib/systemd/system/azure-proxy-agent.service] mode=664 (max 644) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/2c9108d9-fb98-469f-bdf1-917226d35a75.key] mode=644 (max 600) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/status.tag] mode=644 (max 600) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/provisioned.tag] mode=644 (max 600) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/57f97889-ed85-4c3d-a530-bfa1356d2b4d.key] mode=644 (max 600) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/17864e62-b42f-4c94-80ba-14cce60b71da.key] mode=644 (max 600) owner=root -[FAIL] E1[/var/lib/azure-proxy-agent/keys/b8f16549-2d6d-49e7-a0eb-57dae9dd3642.key] mode=644 (max 600) owner=root -[PASS] F1[status.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-14T20.43.41.072-1776199421072120290.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-23T21.34.01.785-1776980041785536194.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-27T16.20.07.046-1777306807046204837.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-04-27T16.23.06.794-1777306986794596198.json] no obvious secret-shaped strings -[PASS] F1[AuthorizationRules_2026-05-12T18.31.38.302-1778610698302993568.json] no obvious secret-shaped strings -[INFO] P5-bpftool saved bpftool snapshots to /home/zpeng/src/GuestProxyAgent/pentest/results - -Phase 5 (read-only audit) complete. See /home/zpeng/src/GuestProxyAgent/pentest/results/findings.tsv diff --git a/pentest/results/url_diff.tsv b/pentest/results/url_diff.tsv deleted file mode 100644 index 78a5c2b0..00000000 --- a/pentest/results/url_diff.tsv +++ /dev/null @@ -1,11 +0,0 @@ -2026-05-12T18:49:50Z canonical 200 same /metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI -2026-05-12T18:49:50Z uppercase_pct 200 same /metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI -2026-05-12T18:49:50Z lowercase_pct 200 same /metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/ {"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI -2026-05-12T18:49:50Z double_encoded 404 DIFF /metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/ -2026-05-12T18:49:50Z trailing_dot_path 404 DIFF /metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/ {"error":"Not Found"} -2026-05-12T18:49:50Z dot_segments 404 DIFF /metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/ -2026-05-12T18:49:50Z param_injection 404 DIFF /metadata/identity/oauth2/token%3Bx=1?api-version=2018-02-01&resource=https://management.azure.com/ {"error":"Not Found"} -2026-05-12T18:49:50Z case_path 404 DIFF /Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/ { "error": "Not found" } -2026-05-12T18:49:50Z extra_slashes 404 DIFF //metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/ /dev/tcp/127.0.0.1/3080'`.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "timeout 2 bash -c '>/dev/tcp/127.0.0.1/3080' && echo OK", + "fix": "If failing, the agent is not running. `systemctl status azure-proxy-agent`; check journalctl for bind errors.", + }, + "A1c": { + "title": "Port 3080 NOT reachable on external IP", + "design": "Even if a misconfiguration binds 0.0.0.0, vNet peers must NOT be able to connect to :3080.", + "automation": "`timeout 2 bash -c '>/dev/tcp//3080'` against the first global-scope IPv4 — must time out.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "ip -o -4 addr show scope global # pick IP\ntimeout 2 bash -c '>/dev/tcp//3080' || echo refused", + "fix": "Same as A1 — confirm `bind(127.0.0.1, 3080)` and add a host-firewall (nftables/iptables) DROP on 3080 for non-loopback as defense-in-depth.", + }, + "A3": { + "title": "Service survives malformed HTTP", + "design": "Bad method, HTTP/9.9, 65 KB URI, slow-loris must not crash the agent.", + "automation": "Pipes 5 crafted requests through `nc` and verifies `pidof azure-proxy-agent` is unchanged.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "printf 'INVALIDMETHOD / HTTP/1.1\\r\\n\\r\\n' | nc -q1 127.0.0.1 3080\nprintf 'GET /%s HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n' \"$(python3 -c 'print(\"A\"*65000)')\" | nc -q1 127.0.0.1 3080", + "fix": "If FAIL, gather a core dump (`coredumpctl gdb azure-proxy-agent`) and harden the HTTP parser against the failing input class.", + }, + "A4": { + "title": "CONNECT method must be rejected", + "design": "GPA must not act as a generic HTTP tunnel. CONNECT to arbitrary :443 must be refused with an explicit 4xx, never silently closed.", + "automation": "Sends raw `CONNECT example.com:443 HTTP/1.1` over `nc`, parses the first response status. PASS if the status is non-2xx and non-empty.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "printf 'CONNECT example.com:443 HTTP/1.1\\r\\nHost: example.com:443\\r\\n\\r\\n' | nc -q2 127.0.0.1 3080", + "fix": "In the request parser (proxy_agent/src/proxy/proxy_server.rs / proxy_listener.rs) explicitly reject any non-{GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH} method with `405 Method Not Allowed` and a short body BEFORE closing. Never silently drop unsupported methods — write a unit test for CONNECT, TRACE, and a random verb.", + }, + "A5": { + "title": "Not an open proxy", + "design": "GPA must refuse to forward to arbitrary hosts even when explicitly proxied.", + "automation": "`curl -x http://127.0.0.1:3080 http://example.com/` — expects non-2xx.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "curl -v -x http://127.0.0.1:3080 http://example.com/", + "fix": "Allowlist destinations in the connect-policy redirector; reject any Host:/absolute-URI not in {WireServer, IMDS, HostGAPlugin}.", + }, + "G1": { + "title": "Connection burst doesn't crash", + "design": "200 concurrent /dev/tcp connections; service PID must be stable.", + "automation": "Spawns 200 background connect-and-read loops via `/dev/tcp/127.0.0.1/3080`, then compares pidof before/after.", + "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_manual": "for i in $(seq 1 200); do (exec 3<>/dev/tcp/127.0.0.1/3080; echo -e 'GET / HTTP/1.0\\r\\n\\r' >&3; cat <&3 >/dev/null) & done; wait\npidof azure-proxy-agent", + "fix": "Apply per-source connection limits in the listener and verify `systemctl cat azure-proxy-agent` includes the CPU/Memory drop-ins.", + }, + + # ----------------------------------------------------------------------- + # Phase 3 — AuthN/AuthZ + # ----------------------------------------------------------------------- + "P3-cap": { + "title": "Phase-3 packet capture", + "design": "Records traffic to fabric IPs + lo:3080 for offline analysis.", + "automation": "`sudo tcpdump -i any -U -w results/phase3-*.pcap` filtered to IMDS/WireServer/loopback:3080.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "sudo tcpdump -i any -w /tmp/p3.pcap 'host 169.254.169.254 or host 168.63.129.16 or (host 127.0.0.1 and port 3080)'", + }, + "B1a": { + "title": "IMDS reachable through GPA", + "design": "A normal IMDS call from the guest must succeed (200) thanks to eBPF redirect + GPA signature injection.", + "automation": "`curl -H 'Metadata: true' http://169.254.169.254/metadata/instance?api-version=2021-02-01`.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", + "fix": "If FAIL, check eBPF programs are attached (`bpftool prog show`, `bpftool cgroup tree`) and that key-keeper has latched a key (status.json secureChannelState).", + }, + "B1b": { + "title": "ss snapshot of own connection", + "design": "Record what the kernel sees as the peer for our IMDS connection (informational).", + "automation": "Opens `/dev/tcp/IMDS/80` and snapshots `ss -tnp` for current PID.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "( exec 3<>/dev/tcp/169.254.169.254/80 ); ss -tnp | grep $$", + }, + "B4": { + "title": "Attacker-supplied signature header is overwritten", + "design": "GPA must strip/overwrite client-supplied `x-ms-azure-signature` / `x-ms-azure-time-tick`. Successful 200 means GPA injected its own — confirm in the pcap that the on-wire header value differs.", + "automation": "curl with forged headers; expects 200, then manual pcap diff.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -H 'Metadata: true' -H 'x-ms-azure-signature: AAAA-attacker' -H 'x-ms-azure-time-tick: 0' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'\n# then inspect: tshark -r results/phase3-*.pcap -Y 'http and ip.dst==168.63.129.16' -T fields -e http.request.line", + "fix": "In the request-rewriting layer, unconditionally remove inbound `x-ms-azure-*` headers before computing/inserting GPA's own signature.", + }, + "B3": { + "title": "Key files not group/world readable", + "design": "Anything under /var/lib/azure-proxy-agent/keys must be 0600 root.", + "automation": "`find /var/lib/azure-proxy-agent/keys -type f \\( -perm -o+r -o -perm -g+r \\)` must return empty.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "sudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %n' {} \\;", + "fix": "Force 0600 on every key write (Rust: `OpenOptions::new().mode(0o600)`); add a self-heal pass on service start.", + }, + "C1": { + "title": "Non-elevated user denied WireServer", + "design": "WireServer requires `runAsElevated`; calls from `nobody` must be 401/403.", + "automation": "`sudo -u nobody curl -H 'x-ms-version: 2012-11-30' http://168.63.129.16/machine/?comp=goalstate`.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "sudo -u nobody curl -sS -o /dev/null -w '%{http_code}\\n' -H 'x-ms-version: 2012-11-30' 'http://168.63.129.16/machine/?comp=goalstate'", + "fix": "If FAIL with 200, check the WireServer authorizer (`proxy_agent/src/proxy/proxy_authorizer.rs`) — `runAsElevated` gate is broken.", + }, + "C5": { + "title": "cgroup-namespace evasion attempt", + "design": "Inside `unshare -Cr`, a process should still hit the cgroup-attached eBPF program (the cgroup IS still under the root cgroup hierarchy). Connection must remain redirected.", + "automation": "`unshare -Cr -- curl …/metadata/instance` — expects 200.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "unshare -Cr -- curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", + "fix": "A `000` likely means `unshare -Cr` failed before curl ran on this kernel (no CAP_SYS_ADMIN for the user namespace). Re-run as root, or update the harness to detect ENOSYS/EPERM from `unshare` and record INFO instead of FAIL. If unshare succeeds and curl gets 000 from the redirected port, the eBPF program is not catching the new namespace — investigate `bpftool cgroup tree` and the cgroup attach point.", + }, + "C7": { + "title": "Alternate IP-form bypass attempt", + "design": "`http://0xa9fea9fe/...` and `http://2852039166/...` resolve to 169.254.169.254. The eBPF redirect should treat them the same; AuthZ decision should be identical to the canonical form.", + "automation": "`curl` against each alternate URL, code recorded INFO.", + "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_manual": "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://0xa9fea9fe/metadata/instance?api-version=2021-02-01\ncurl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://2852039166/metadata/instance?api-version=2021-02-01", + "fix": "If a code DIFFERS from the canonical (200), file a finding: the redirector or matcher treats numeric host forms inconsistently.", + }, + + # ----------------------------------------------------------------------- + # Phase 4 — URL/encoding diff (all rows live in url_diff.tsv, not findings.tsv) + # ----------------------------------------------------------------------- + + # ----------------------------------------------------------------------- + # Phase 5 — fs/state audit + # ----------------------------------------------------------------------- + "E1": { + "title": "Filesystem mode/owner audit", + "design": "Each path's mode must be ≤ the documented max and owner == root. Key files MUST be 0600; dirs 0700/0755; binary 0755; unit file 0644.", + "automation": "`sudo stat -c '%a %U' ` for key dir, log dir, binary, unit file, and every regular file under keys/.", + "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_manual": "sudo stat -c '%a %U %n' /var/lib/azure-proxy-agent/keys /var/log/azure-proxy-agent /usr/sbin/azure-proxy-agent /usr/lib/systemd/system/azure-proxy-agent.service\nsudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %U %n' {} \\;", + "fix": ( + "Group-writable binary / unit (0775, 0664): fix the package to install with explicit modes " + "(`install -m 0755` for the binary in pkg_debian/rules; `install -m 0644` for the .service; " + "`%attr(0755,root,root)` / `%attr(0644,root,root)` in the RPM spec). " + "World-readable key files (0644) are the highest-severity finding here — patch the key writers " + "to use `OpenOptions::new().mode(0o600).open()` and explicitly `set_permissions(p, 0o600)` after " + "every write. Self-heal on start: chmod 0600 every file in /var/lib/azure-proxy-agent/keys/. " + "Quick mitigation: `sudo chmod 600 /var/lib/azure-proxy-agent/keys/*`." + ), + }, + "F1": { + "title": "Status / rules files contain no secrets", + "design": "World-readable status.json and AuthorizationRules_*.json must NOT contain key material, signatures, tokens, or HMACs.", + "automation": "`grep -aE '\"key\"|secret|signature|hmac|token'` over every matching file.", + "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_manual": "grep -aE '\"key\"|secret|signature|hmac|token' /var/log/azure-proxy-agent/status.json /var/log/azure-proxy-agent/AuthorizationRules_*.json", + "fix": "If FAIL, redact the offending field in the writer (search for the field name across `proxy_agent/src/proxy/`).", + }, + "P5-bpftool": { + "title": "bpftool snapshots saved", + "design": "Captures `bpftool prog show` and `bpftool cgroup tree` for offline review.", + "automation": "`bpftool prog show > results/bpftool_prog_show.txt`, `bpftool cgroup tree > results/bpftool_cgroup_tree.txt`.", + "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_manual": "sudo bpftool prog show\nsudo bpftool cgroup tree", + }, + + # ----------------------------------------------------------------------- + # Phase 4b — local-file rules. Same automation pattern for every scenario, + # so we describe the common shape once per scenario and add probe-specific + # fixes only where they actually FAILed in this run. + # ----------------------------------------------------------------------- + "PRE": { + "title": "Pre-flight: useLocalFileRules must be active", + "design": "Phase 4b only meaningful when the fabric delivers a ruleId whose decoded JSON has `useLocalFileRules: true`. Without it, the agent ignores the local files we write.", + "automation": "Reads latest `/var/log/azure-proxy-agent/AuthorizationRules_*.json` and looks for the substring `useLocalFileRules-true`.", + "repro_script": "sudo python3 pentest/phase4b_local_rules/run.py", + "repro_manual": "ls -t /var/log/azure-proxy-agent/AuthorizationRules_*.json | head -1 | xargs grep useLocalFileRules-true", + "fix": "Toggle the flag from your control plane / mock fabric, then re-run.", + }, +} + + +# Phase 4b scenario metadata. We key on the scenario base ID (without the +# /probe_name suffix). Each entry covers ALL probes in the scenario. +PHASE_4B: dict[str, TestInfo] = { + # ---------------- IMDS ---------------- + "IMDS-S1-disabled-allow": { + "title": "Control: mode=disabled + allow", + "design": "Baseline. With enforcement off and default-allow, every IMDS probe must return 200.", + "automation": "Writes `/var/lib/azure-proxy-agent/rules/IMDS_Rules.json` with `mode=disabled, defaultAccess=allow`, waits 20s, GETs `/metadata/instance` and `/metadata/identity/oauth2/token`.", + }, + "IMDS-S2-enforce-deny-empty": { + "title": "Fail-closed when enforce+deny with no allow rules", + "design": "All IMDS calls must return 403 — proves enforcement engages.", + "automation": "Writes `mode=enforce, defaultAccess=deny, rules={}`; expects every probe → 403.", + }, + "IMDS-S3-audit-deny-empty": { + "title": "Audit mode passes traffic through (logs deny, returns 200)", + "design": "When `mode=audit`, deny decisions should be LOGGED but the request must succeed.", + "automation": "Writes `mode=audit, defaultAccess=deny`; expects `/metadata/instance` → 200.", + "fix": "Audit mode currently returned 403 — the engine is treating audit identical to enforce. In `proxy_agent/src/proxy/proxy_authorizer.rs` (and authorization_rules.rs), when the configured `mode == 'audit'` log the decision and return Authorize::Allowed instead of Forbidden. Add a unit test exercising both audit-deny and audit-allow paths.", + }, + "IMDS-S4-allow-one-path": { + "title": "Allow exactly one path via privilege/role/identity/assignment", + "design": "An explicit allow for `/metadata/instance` for the current identity must let `instance` through (200) while everything else stays 403.", + "automation": "Writes a rule doc with one `privilege` (path=/metadata/instance), one `role`, one `identity` ({userName=root, groupName=root, exePath=/usr/bin/curl, processName=curl}), one `roleAssignment`. Probes: instance(200), token(403), versions(403).", + "fix": "instance came back 403 — identity match fails because the harness includes `exePath=/usr/bin/curl` but the actual probe is sent via python3 `http.client`, not curl. Two fixes: (1) Harness: in `phase4b_local_rules/run.py:get_current_user_identity()`, drop `exePath`/`processName` so the rule matches userName/groupName only. (2) Engine: confirm/document whether unset identity fields are wildcards vs strict-match — if they ARE wildcards, the test would already pass with #1.", + }, + "IMDS-S5-wrong-identity": { + "title": "Allow rule bound to non-matching identity", + "design": "Same allow rule, but identity userName=`nosuchuser_xyz`. Must 403.", + "automation": "Writes a rule with bogus identity and asserts `/metadata/instance` → 403.", + }, + "IMDS-S6-encoding-bypass": { + "title": "Encoding/path-traversal bypass attempts", + "design": "Allow only `/metadata/instance`. Probes try to reach `/metadata/identity/oauth2/token` via `%2F`, `%2f`, `%252F`, `%3F`, and `./../`. ALL must 403 — encoding must not let attackers reach denied paths.", + "automation": "Rule allows /metadata/instance; probes the encoded variants of the token path.", + "fix": "`token_dot_segments` returned 404 instead of 403. The dot-segments URL was forwarded raw to IMDS, which 404'd. Normalize `./` and `..` segments BEFORE rule matching in `authorization_rules.rs::is_match()`, then deny if normalization escapes the allowed prefix. Either way the GPA decision should be 403, not pass-through.", + }, + "IMDS-S7-query-param-required": { + "title": "Query parameter must match for allow", + "design": "Allow only when `api-version=2021-02-01`. Matching → 200, missing → 403, wrong value → 403.", + "automation": "Rule includes `queryParameters: {api-version: 2021-02-01}`; sends three variants.", + "fix": "`matching_version` returned 403 — same root cause as IMDS-S4 (identity exePath mismatch). Apply the S4 fix; re-run.", + }, + "IMDS-S8-group-only-identity": { + "title": "Identity match by groupName only", + "design": "Identity has only `groupName=root`. Must allow.", + "automation": "Writes identity with no userName/exePath; expects 200.", + }, + "IMDS-S9-exepath-identity": { + "title": "Per-process identity by exePath", + "design": "Allow only when `exePath=/usr/bin/curl`. Python http.client → 403; real `curl` subprocess → 200.", + "automation": "Two probes: one via http.client (denied), one via `subprocess.run(['curl', …])` (allowed).", + }, + "IMDS-S10-malformed-json": { + "title": "Malformed rules JSON → fail-closed", + "design": "If IMDS_Rules.json is invalid, every IMDS probe must 403 — never silently fall back to allow.", + "automation": "Writes the literal string `{ this is not valid json` and expects 403 on every probe.", + }, + + # ---------------- WireServer ---------------- + "WS-S1-disabled-allow": { + "title": "Control: mode=disabled + allow (WireServer)", + "design": "Baseline 200s for goalstate and versions.", + "automation": "Writes WireServer_Rules.json with mode=disabled; probes goalstate and versions.", + }, + "WS-S2-enforce-deny-empty": { + "title": "Fail-closed enforce+deny (WireServer)", + "design": "All WireServer calls 403.", + "automation": "Writes mode=enforce, defaultAccess=deny, no rules.", + }, + "WS-S3-audit-deny-empty": { + "title": "Audit mode passes through (WireServer)", + "design": "Goalstate must return 200 even though defaultAccess=deny, because mode=audit.", + "automation": "Writes mode=audit + defaultAccess=deny.", + "fix": "Same audit-mode bug as IMDS-S3 — fix in `proxy_authorizer.rs` to log-and-allow when mode=audit.", + }, + "WS-S4-allow-goalstate-only": { + "title": "Allow only /machine/?comp=goalstate", + "design": "goalstate → 200; sharedConfig / hosting / certs / extensionsConfig / versions → 403.", + "automation": "Writes rule with `path=/machine/, queryParameters={comp: goalstate}`.", + "fix": "`goalstate_allowed` returned 403 — same identity-exePath issue as IMDS-S4. Drop `exePath`/`processName` from the harness identity.", + }, + "WS-S5-wrong-identity": { + "title": "Allow goalstate but identity doesn't match", + "design": "Even goalstate → 403.", + "automation": "Writes rule whose identity userName=`nosuchuser_xyz`.", + }, + "WS-S6-encoding-bypass": { + "title": "Encoding/path-traversal bypass (WireServer)", + "design": "Allow only goalstate; encoded variants of `/machine/?comp=certificates` must all 403; `comp=GOALSTATE` (case-insensitive value) must 200.", + "automation": "Probes `/machine%2F?...`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates`, `comp=GOALSTATE`.", + "fix": ( + "Two failures:\n" + "• `certs_dot_segments` returned 404 — same issue as IMDS-S6: normalize ./ and .. segments in the matcher before deciding, and deny when normalization escapes the allow prefix.\n" + "• `goalstate_value_case_should_match` returned 403 — likely the same identity-exePath issue (S4); after fixing S4 also confirm query-value comparison is `to_ascii_lowercase()` on both sides in `authorization_rules.rs`." + ), + }, + "WS-S7-query-param-required": { + "title": "Query parameter must match (WireServer)", + "design": "Only `comp=goalstate` allowed; matching/extra-param → 200, missing/wrong → 403.", + "automation": "Probes /machine/?comp=goalstate, /machine/, ?comp=hostingenvironmentconfig, ?comp=goalstate&incarnation=1.", + "fix": "`matching_comp` and `extra_param_ok` returned 403 — same identity-exePath issue as S4. After the harness fix verify that extra (non-required) query params don't break the rule matcher.", + }, + "WS-S8-group-only-identity": { + "title": "Identity match by groupName only (WireServer)", + "design": "groupName=root → goalstate 200.", + "automation": "Writes identity with only groupName.", + }, + "WS-S9-exepath-identity": { + "title": "Per-process identity by exePath (WireServer)", + "design": "python caller denied, real curl allowed.", + "automation": "http.client probe (403), then subprocess curl probe (200).", + }, + "WS-S10-malformed-json": { + "title": "Malformed rules JSON → fail-closed (WireServer)", + "design": "All WireServer probes 403.", + "automation": "Writes invalid JSON literal as WireServer_Rules.json.", + }, +} + + +_BRACKET_RE = re.compile(r"\[.*?\]") + + +def lookup(test_id: str) -> Optional[TestInfo]: + """Return TestInfo for an exact or normalized test ID.""" + if not test_id: + return None + if test_id in CATALOG: + return CATALOG[test_id] + if test_id in PHASE_4B: + return PHASE_4B[test_id] + # Strip [...] suffix (e.g. E1[/path]) + base = _BRACKET_RE.sub("", test_id).strip() + if base in CATALOG: + return CATALOG[base] + # Strip /probe suffix (e.g. IMDS-S4-allow-one-path/instance_allowed) + if "/" in test_id: + head = test_id.split("/", 1)[0] + if head in PHASE_4B: + return PHASE_4B[head] + if head in CATALOG: + return CATALOG[head] + return None + + +def all_known_ids() -> list[str]: + return list(CATALOG.keys()) + list(PHASE_4B.keys()) diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index fb2a0abd..75e61895 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -377,6 +377,7 @@ impl ProxyServer { ); if http_connection_context.contains_traversal_characters() { + // If the proxied request contains traversal characters, we will return 404 Not Found to avoid potential security issues. self.log_connection_summary( &mut http_connection_context, StatusCode::NOT_FOUND, From d99f7dbc523d37004dc48f74cf8a84e037a651cc Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Wed, 13 May 2026 21:38:27 +0000 Subject: [PATCH 03/37] =?UTF-8?q?Set=20Linux=20file=E2=80=91mode=20for=20m?= =?UTF-8?q?ore=20restrictive=20permissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- proxy_agent/src/key_keeper.rs | 16 +++++++++++- proxy_agent/src/provision.rs | 25 ++++++++++++++++++- proxy_agent/src/proxy_agent_status.rs | 10 ++++++++ proxy_agent_setup/src/linux.rs | 36 ++++++++++++++++++++++++--- proxy_agent_shared/src/linux.rs | 20 +++++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/proxy_agent/src/key_keeper.rs b/proxy_agent/src/key_keeper.rs index 119f2902..a42f5bce 100644 --- a/proxy_agent/src/key_keeper.rs +++ b/proxy_agent/src/key_keeper.rs @@ -935,7 +935,21 @@ impl KeyKeeper { key_file.display(), e ))) - }) + })?; + + #[cfg(not(windows))] + { + // set the file permissions to 600 for non-windows platform + proxy_agent_shared::linux::set_file_permissions(&key_file, 0o600).map_err(|e| { + Error::Key(KeyErrorType::StoreLocalKey(format!( + "set_file_permissions '{}' failed {}", + key_file.display(), + e + ))) + })?; + } + + Ok(()) } } diff --git a/proxy_agent/src/provision.rs b/proxy_agent/src/provision.rs index 89862fc3..827af8f3 100644 --- a/proxy_agent/src/provision.rs +++ b/proxy_agent/src/provision.rs @@ -435,11 +435,21 @@ async fn write_provision_state( } if let Err(e) = std::fs::write( - provisioned_file, + &provisioned_file, misc_helpers::get_date_time_string_with_milliseconds(), ) { logger::write_error(format!("Failed to write provisioned file with error: {e}")); } + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions(&provisioned_file, 0o600).unwrap_or_else( + |e| { + logger::write_error(format!( + "Failed to set provisioned file permission to 600 with error: {e}" + )); + }, + ); + } let mut failed_state_message = get_provision_failed_state_message(provision_shared_state, agent_status_shared_state).await; @@ -484,6 +494,19 @@ async fn write_provision_state( logger::write_error(format!("Failed to write temp status file with error: {e}")); } } + + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions( + &provision_dir.join(STATUS_TAG_FILE_NAME), + 0o600, + ) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set status file permission to 600 with error: {e}" + )); + }); + } } /// Get provision failed state message diff --git a/proxy_agent/src/proxy_agent_status.rs b/proxy_agent/src/proxy_agent_status.rs index aec85804..d9c7a807 100644 --- a/proxy_agent/src/proxy_agent_status.rs +++ b/proxy_agent/src/proxy_agent_status.rs @@ -292,6 +292,16 @@ impl ProxyAgentStatusTask { async fn write_aggregate_status_to_file(&self, status: GuestProxyAgentAggregateStatus) { let full_file_path = self.status_dir.join("status.json"); if let Err(e) = misc_helpers::json_write_to_file_async(&status, &full_file_path).await { + #[cfg(not(windows))] + { + proxy_agent_shared::linux::set_file_permissions(&full_file_path, 0o640) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set status.json file permission to 640 with error: {e}" + )); + }); + } + self.update_agent_status_message(format!( "Error writing aggregate status to status file: {e}" )) diff --git a/proxy_agent_setup/src/linux.rs b/proxy_agent_setup/src/linux.rs index 70921167..93fb6b07 100644 --- a/proxy_agent_setup/src/linux.rs +++ b/proxy_agent_setup/src/linux.rs @@ -12,16 +12,31 @@ const EBPF_FILE: &str = "ebpf_cgroup.o"; const CONFIG_PATH: &str = "/etc/azure/proxy-agent.json"; const EBPF_PATH: &str = "/usr/lib/azure-proxy-agent/ebpf_cgroup.o"; -pub fn setup_service(service_name: &str, service_file_dir: PathBuf) -> Result { +pub fn setup_service(service_name: &str, service_file_dir: PathBuf) -> Result<()> { copy_service_config_file(service_name, service_file_dir) } -fn copy_service_config_file(service_name: &str, service_file_dir: PathBuf) -> Result { +fn copy_service_config_file(service_name: &str, service_file_dir: PathBuf) -> Result<()> { let service_config_name = format!("{service_name}.service"); let src_config_file_path = service_file_dir.join(&service_config_name); let dst_config_file_path = PathBuf::from(proxy_agent_shared::linux::SERVICE_CONFIG_FOLDER_PATH) .join(&service_config_name); - fs::copy(src_config_file_path, dst_config_file_path).map_err(Into::into) + fs::copy(src_config_file_path, &dst_config_file_path).map_err(|e| { + std::io::Error::other(format!( + "Failed to copy service config file to {dst_config_file_path:?} with error: {e}" + )) + })?; + // set the file permissions to 644 for the service config unit file + proxy_agent_shared::linux::set_file_permissions(&dst_config_file_path, 0o644).map_err(|e| { + std::io::Error::other(format!( + "Failed to set file permissions for {dst_config_file_path:?} with error: {e}" + )) + })?; + + logger::write(format!( + "Copied service config file to {dst_config_file_path:?}" + )); + Ok(()) } fn backup_service_config_file(backup_folder: PathBuf) { @@ -94,7 +109,22 @@ pub fn copy_files(src_folder: PathBuf) { src_folder.join("azure-proxy-agent"), dst_folder.join("azure-proxy-agent"), ); + // set the file permissions to 755 for the azure-proxy-agent binary + proxy_agent_shared::linux::set_file_permissions(&dst_folder.join("azure-proxy-agent"), 0o755) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set azure-proxy-agent file permission to 755 with error: {e}" + )); + }); + copy_file(src_folder.join(CONFIG_FILE), PathBuf::from(CONFIG_PATH)); + proxy_agent_shared::linux::set_file_permissions(&PathBuf::from(CONFIG_PATH), 0o644) + .unwrap_or_else(|e| { + logger::write_error(format!( + "Failed to set config file permission to 644 with error: {e}" + )); + }); + copy_file(src_folder.join(EBPF_FILE), PathBuf::from(EBPF_PATH)); } diff --git a/proxy_agent_shared/src/linux.rs b/proxy_agent_shared/src/linux.rs index b09cbe0b..b0481814 100644 --- a/proxy_agent_shared/src/linux.rs +++ b/proxy_agent_shared/src/linux.rs @@ -174,9 +174,18 @@ pub fn read_proc_memory_status(pid: u32) -> Result { Ok(MemStatus { vmrss_kb, vmhwm_kb }) } +/// Set the file permissions for a file or directory. +pub fn set_file_permissions(path: &PathBuf, mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let permissions = fs::Permissions::from_mode(mode); + fs::set_permissions(path, permissions)?; + Ok(()) +} + #[cfg(test)] mod tests { use crate::misc_helpers; + use std::os::unix::fs::PermissionsExt as _; #[test] fn get_os_version_tests() { @@ -228,4 +237,15 @@ mod tests { } }; } + + #[test] + fn set_file_permissions_test() { + let test_file_path = "/tmp/test_file_permissions.txt"; + std::fs::write(test_file_path, "test").unwrap(); + let path = std::path::PathBuf::from(test_file_path); + super::set_file_permissions(&path, 0o644).unwrap(); + let metadata = std::fs::metadata(test_file_path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o777, 0o644); + std::fs::remove_file(test_file_path).unwrap(); + } } From 0e370960915a1f6624ec0f25bef55f437050c80a Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 10:22:39 -0700 Subject: [PATCH 04/37] GPA proxy_server to have an explicit http-method allow-list --- proxy_agent/src/proxy/proxy_server.rs | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/proxy_agent/src/proxy/proxy_server.rs b/proxy_agent/src/proxy/proxy_server.rs index fb2a0abd..77cb0b59 100644 --- a/proxy_agent/src/proxy/proxy_server.rs +++ b/proxy_agent/src/proxy/proxy_server.rs @@ -38,6 +38,7 @@ use http_body_util::{combinators::BoxBody, BodyExt}; use hyper::body::{Bytes, Incoming}; use hyper::header::{HeaderName, HeaderValue}; use hyper::service::service_fn; +use hyper::Method; use hyper::StatusCode; use hyper::{Request, Response}; use hyper_util::rt::TokioIo; @@ -376,6 +377,32 @@ impl ProxyServer { ), ); + // Explicit method allow-list. GPA is an HTTP/1.1 forward proxy for a + // fixed set of fabric endpoints, so it must reject: + // * CONNECT — accepting it would turn GPA into a generic outbound TCP + // tunnel and bypass the per-endpoint authorization + signing logic. + // * TRACE — GPA injects an HMAC `x-ms-azure-host-authorization` + // signature and `x-ms-azure-host-claims` before forwarding; a TRACE + // would cause the upstream to echo those secrets back to the local + // (potentially unprivileged) caller. + // * Any non-standard verb — defense in depth against request smuggling + // and unexpected upstream behavior on unknown tokens. + // Reject *before* any auth/upstream lookup so attackers cannot pivot + // through unsupported methods, and respond with an explicit 405 + Allow + // (RFC 7231 §6.5.5) so clients and pen-test harnesses see a real status + // line instead of a silent RST. + if !Self::is_method_allowed(&http_connection_context.method) { + let bad_method = http_connection_context.method.to_string(); + self.log_connection_summary( + &mut http_connection_context, + StatusCode::METHOD_NOT_ALLOWED, + false, + format!("Rejected unsupported HTTP method '{bad_method}'"), + ) + .await; + return Ok(Self::method_not_allowed_response()); + } + if http_connection_context.contains_traversal_characters() { self.log_connection_summary( &mut http_connection_context, @@ -878,6 +905,48 @@ impl ProxyServer { response } + /// Methods that GPA's HTTP/1.1 forward-proxy will accept and try to route + /// to a fabric endpoint. Anything else (CONNECT, TRACE, made-up verbs) + /// is rejected with 405 by `handle_new_http_request` BEFORE any + /// authorization or upstream lookup happens. + const ALLOWED_METHODS: &'static [Method] = &[ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::HEAD, + Method::OPTIONS, + Method::PATCH, + ]; + const ALLOW_HEADER_VALUE: &'static str = "GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH"; + + fn is_method_allowed(method: &Method) -> bool { + Self::ALLOWED_METHODS.iter().any(|m| m == method) + } + + /// Build an explicit `405 Method Not Allowed` response with an `Allow` + /// header listing the supported verbs and a short text body so that + /// clients (and pen-test harnesses) can see a real HTTP status line + /// instead of a silent connection close. + fn method_not_allowed_response() -> Response> { + let body = Full::new(Bytes::from_static(b"Method Not Allowed")) + .map_err(|never| match never {}) + .boxed(); + let mut response = Response::new(body); + *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; + let headers = response.headers_mut(); + headers.insert( + hyper::header::ALLOW, + HeaderValue::from_static(Self::ALLOW_HEADER_VALUE), + ); + headers.insert( + hyper::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + headers.insert(hyper::header::CONNECTION, HeaderValue::from_static("close")); + response + } + async fn handle_request_with_signature( &self, mut http_connection_context: HttpConnectionContext, @@ -1139,4 +1208,62 @@ mod tests { // stop the listener cancellation_token.cancel(); } + + #[tokio::test] + async fn unsupported_methods_return_405() { + // GPA must reject CONNECT, TRACE, and any unknown verb with an + // explicit 405 + Allow header rather than silently closing the + // connection. Regression test for pen-test finding A4. + // We use std::net + spawn_blocking because this crate doesn't enable + // tokio's `io-util` feature. + use std::io::{Read, Write}; + use std::net::TcpStream as StdTcpStream; + use std::time::Duration as StdDuration; + + let host = "127.0.0.1"; + let port: u16 = 8092; // distinct from other tests + let shared_state = shared_state::SharedState::start_all(); + let cancellation_token = shared_state.get_cancellation_token(); + let proxy_server = proxy_server::ProxyServer::new(port, &shared_state); + + tokio::spawn({ + let proxy_server = proxy_server.clone(); + async move { + proxy_server.start().await; + } + }); + tokio::time::sleep(Duration::from_millis(100)).await; + + async fn first_status_line(host: &'static str, port: u16, raw: &'static str) -> String { + tokio::task::spawn_blocking(move || { + let mut stream = StdTcpStream::connect((host, port)).expect("connect"); + stream + .set_read_timeout(Some(StdDuration::from_secs(2))) + .expect("set_read_timeout"); + stream.write_all(raw.as_bytes()).expect("write"); + let mut buf = [0u8; 256]; + let n = stream.read(&mut buf).unwrap_or(0); + let text = String::from_utf8_lossy(&buf[..n]).to_string(); + text.lines().next().unwrap_or("").to_string() + }) + .await + .expect("spawn_blocking") + } + + for raw in [ + "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n", + "TRACE / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + "WIDGET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + ] { + let status_line = first_status_line(host, port, raw).await; + assert!( + status_line.starts_with("HTTP/1.1 405"), + "expected 405 status line for raw request {:?}, got {:?}", + raw, + status_line + ); + } + + cancellation_token.cancel(); + } } From fa3a9160504d4bcbc16d1e47c55b0d0dfeabff4a Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 14 May 2026 17:58:21 +0000 Subject: [PATCH 05/37] update test --- pentest/phase2_listener/run.sh | 9 ++++++--- pentest/phase3_authn_authz/run.sh | 30 ++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/pentest/phase2_listener/run.sh b/pentest/phase2_listener/run.sh index 5db4a9ae..d1ea445b 100755 --- a/pentest/phase2_listener/run.sh +++ b/pentest/phase2_listener/run.sh @@ -53,9 +53,12 @@ else record A3 FAIL "service pid changed ($pid_before -> $pid_after) after malformed requests" fi -# A4 — CONNECT method should not turn GPA into a generic tunnel -code=$(printf 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n' \ - | timeout 4 nc -q1 127.0.0.1 3080 | head -n1 | awk '{print $2}') +# A4 — CONNECT method should not turn GPA into a generic tunnel. +# Use curl (more reliable than nc, which races against the server's +# `Connection: close` FIN and frequently returns empty output). +code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \ + -X CONNECT --request-target 'example.com:443' \ + http://127.0.0.1:3080 2>/dev/null || true) case "$code" in ""|2*) record A4 FAIL "CONNECT not refused (status='$code')" ;; *) record A4 PASS "CONNECT refused (status='$code')" ;; diff --git a/pentest/phase3_authn_authz/run.sh b/pentest/phase3_authn_authz/run.sh index 2e943fbf..d4730335 100755 --- a/pentest/phase3_authn_authz/run.sh +++ b/pentest/phase3_authn_authz/run.sh @@ -99,15 +99,29 @@ for alt in "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01" \ record "C7[$alt]" INFO "code=$code (expect either same as canonical or refused; manual diff)" done -# C5 — cgroup-namespace evasion attempt (best-effort, requires unshare) +# C5 — cgroup-namespace evasion attempt (best-effort, requires unshare). +# `unshare -Cr` needs CAP_SYS_ADMIN in the new user namespace; if invoked as +# an unprivileged user on a kernel that disallows it, unshare(1) fails before +# curl ever runs and we see exit code 0/empty output → mis-attributed as FAIL. +# Prefix with sudo -n when not root and passwordless sudo is available; if +# unshare itself cannot run, record INFO (environment limitation), not FAIL. if command -v unshare >/dev/null; then - code=$(unshare -Cr -- curl -sS -o /dev/null -w '%{http_code}' --max-time 6 \ - -H "$imds_hdr" "$imds_url" 2>/dev/null || echo 000) - case "$code" in - 200) record C5 PASS "IMDS still reachable from new cgroup ns (redirect intact)" ;; - 000) record C5 FAIL "IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify)" ;; - *) record C5 INFO "code=$code from new cgroup ns (manual review)" ;; - esac + unshare_pfx=() + if [[ $EUID -ne 0 ]] && command -v sudo >/dev/null && sudo -n true 2>/dev/null; then + unshare_pfx=(sudo -n) + fi + # Probe whether unshare -Cr is usable in this environment. + if ! "${unshare_pfx[@]}" unshare -Cr -- true 2>/dev/null; then + record C5 INFO "unshare -Cr unavailable in this environment (needs CAP_SYS_ADMIN); re-run with sudo to exercise" + else + code=$("${unshare_pfx[@]}" unshare -Cr -- curl -sS -o /dev/null -w '%{http_code}' --max-time 6 \ + -H "$imds_hdr" "$imds_url" 2>/dev/null || echo 000) + case "$code" in + 200) record C5 PASS "IMDS still reachable from new cgroup ns (redirect intact)" ;; + 000) record C5 FAIL "IMDS unreachable from new cgroup ns — possible eBPF gap (or denied; verify)" ;; + *) record C5 INFO "code=$code from new cgroup ns (manual review)" ;; + esac + fi fi echo From b33d5e079ecdd792422207be2290be39d9833814 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 14 May 2026 19:10:54 +0000 Subject: [PATCH 06/37] Update reports --- pentest/generate_report.py | 64 ++++++++++++++++++++++++++++-- pentest/phase3_authn_authz/run.sh | 58 ++++++++++++++++++++++----- pentest/phase4b_local_rules/run.py | 28 ++++++++++++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/pentest/generate_report.py b/pentest/generate_report.py index cd9655f4..fbb632c1 100644 --- a/pentest/generate_report.py +++ b/pentest/generate_report.py @@ -198,6 +198,13 @@ def row_html(r: dict, *, with_phase_col: bool) -> str: def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: + # Setup/diagnostic rows emitted by harnesses (e.g. phase4b's CFG/PRE). + # They are not pen-test cases — surface them in their own "Run + # configuration" section and exclude from the test counts. + SETUP_IDS = {"CFG", "PRE"} + setup_rows = [r for r in findings if r["id"] in SETUP_IDS] + findings = [r for r in findings if r["id"] not in SETUP_IDS] + total = len(findings) counts = Counter(r["status"] for r in findings) n_pass, n_fail, n_info = counts.get("PASS", 0), counts.get("FAIL", 0), counts.get("INFO", 0) @@ -209,6 +216,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: phase_order = [name for name, _ in PHASE_PATTERNS] + ["Other"] phase_order = [p for p in phase_order if p in by_phase] fail_rows = [r for r in findings if r["status"] == "FAIL"] + info_rows = [r for r in findings if r["status"] == "INFO"] generated = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") host = socket.gethostname() @@ -237,6 +245,31 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: "design, automation, manual repro, and (when failed) the suggested fix. " "FAIL rows are auto-expanded.") + # Run configuration / harness setup section (not part of test results). + p.append("

Run configuration & harness setup

") + p.append("
" + "Diagnostic rows emitted by the test harnesses themselves (poll " + "interval, current identity, enabled targets, pre-flight checks). " + "They describe how the suite was run and are not pen-test " + "cases, so they are excluded from the counts above.
") + if not setup_rows: + p.append("
No setup records.
") + else: + p.append("" + "" + "" + "") + for r in setup_rows: + badge = SEVERITY.get(r["status"], ("info", "Info"))[0] + p.append( + f"" + f"" + f"" + f"" + ) + p.append("
TimeIDStatusDetail
{html.escape(r['ts'])}{html.escape(r['id'])}{html.escape(r['status'])}
{html.escape(r['msg'])}
") + p.append("
") + # Failures section p.append("

Failures requiring triage

") p.append("
" @@ -254,6 +287,28 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: p.append("") p.append("
") + # Informational findings section (between Failures and Per-phase) + p.append("

Informational findings (no action required)

") + p.append("
" + "" + "" + "
") + if not info_rows: + p.append("
No INFO entries.
") + else: + p.append("
" + "INFO rows record observations that are not failures: environment " + "limitations (e.g. unshare unavailable), audit-mode probes, scenarios " + "that require manual review, or by-design behavior that is logged for " + "the record.
") + p.append("" + "" + "") + for r in info_rows: + p.append(row_html(r, with_phase_col=True)) + p.append("
TimeTest IDPhaseSeverityDetail
") + p.append("
") + # Per-phase for phase in phase_order: rows = by_phase[phase] @@ -349,10 +404,13 @@ def main() -> int: print("No findings to report.", file=sys.stderr) return 1 OUTPUT.write_text(build_html(findings, url_rows), encoding="utf-8") - counts = Counter(r["status"] for r in findings) - print(f"Wrote {OUTPUT} ({len(findings)} entries: " + SETUP_IDS = {"CFG", "PRE"} + test_rows = [r for r in findings if r["id"] not in SETUP_IDS] + setup_n = len(findings) - len(test_rows) + counts = Counter(r["status"] for r in test_rows) + print(f"Wrote {OUTPUT} ({len(test_rows)} test entries: " f"{counts.get('PASS',0)} pass, {counts.get('FAIL',0)} fail, " - f"{counts.get('INFO',0)} info)") + f"{counts.get('INFO',0)} info; {setup_n} setup rows)") return 0 diff --git a/pentest/phase3_authn_authz/run.sh b/pentest/phase3_authn_authz/run.sh index d4730335..32aef151 100755 --- a/pentest/phase3_authn_authz/run.sh +++ b/pentest/phase3_authn_authz/run.sh @@ -43,15 +43,35 @@ if [[ "$code" == "200" ]]; then else record B1a FAIL "IMDS call failed code=$code" fi +# Canonical response captured for C7 parity comparison below. +canonical_code="$code" +canonical_sha=$(sha256sum /tmp/.gpa_imds.body 2>/dev/null | awk '{print $1}') -# B1b — Bypass attempt: try to talk to IMDS directly using a raw socket on a host route -# We can't easily detach from the cgroup-attached eBPF as a normal user, but we can verify -# the eBPF redirect map is applied for our cgroup by checking that our SYN goes to 127.0.0.1. +# B1b — Confirm the kernel sees our IMDS connection as redirected to 127.0.0.1:3080 +# (i.e. the cgroup-attached eBPF redirect is in effect for THIS shell's cgroup). +# Open the FD in the *current* shell — a subshell would close it before `ss` runs. if command -v ss >/dev/null; then - ( exec 3<>/dev/tcp/$IMDS_IP/80 ) 2>/dev/null && { - peer=$(ss -tnp 2>/dev/null | grep -E ":80 .*pid=$$" | head -n1 || true) - record B1b INFO "ss snapshot of own connection: ${peer:-}" - } + if exec 3<>/dev/tcp/$IMDS_IP/80 2>/dev/null; then + # ss may not match by pid on all kernels; fall back to matching the local + # ephemeral port from /proc/self/net/tcp via lsof or /proc parsing. + peer=$(ss -tnp 2>/dev/null | awk -v pid=$$ '$0 ~ "pid="pid {print; exit}') + if [[ -z "$peer" ]]; then + # Fallback: any ESTAB socket from this process to :3080 or :80 + peer=$(ss -tn 2>/dev/null | awk '/ESTAB/ && ($5 ~ /:80$/ || $5 ~ /:3080$/) {print; exit}') + fi + exec 3<&- + if [[ -z "$peer" ]]; then + record B1b INFO "ss snapshot of own connection: (kernel may not expose pid; rerun with sudo for full visibility)" + elif [[ "$peer" == *":3080"* || "$peer" == *"127.0.0.1"* ]]; then + record B1b PASS "kernel reports our IMDS connection landed on the GPA listener (eBPF redirect active): $peer" + elif [[ "$peer" == *"$IMDS_IP"* ]]; then + record B1b FAIL "kernel reports our IMDS connection went DIRECTLY to $IMDS_IP — eBPF redirect bypassed: $peer" + else + record B1b INFO "ss snapshot of own connection: $peer (manual review)" + fi + else + record B1b INFO "could not open /dev/tcp/$IMDS_IP/80 (no route or blocked)" + fi fi # B4 — Submit a forged signature header through the proxy. GPA should overwrite/ignore it. @@ -92,11 +112,29 @@ else record C1 INFO "skipped (need sudo + nobody account)" fi -# C7 — alternate IP forms should not bypass redirection/AuthZ +# C7 — alternate IP forms (hex, decimal) must not bypass the eBPF redirect / +# AuthZ. Compare each alt's HTTP status AND response-body sha256 against the +# canonical form captured above. Identical → eBPF treated them the same → +# PASS. Different → potential SSRF-filter bypass at the network layer → FAIL. for alt in "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01" \ "http://2852039166/metadata/instance?api-version=2021-02-01"; do - code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 6 -H "$imds_hdr" "$alt" || echo 000) - record "C7[$alt]" INFO "code=$code (expect either same as canonical or refused; manual diff)" + alt_body=/tmp/.gpa_c7_$$.body + alt_code=$(curl -sS -o "$alt_body" -w '%{http_code}' --max-time 6 -H "$imds_hdr" "$alt" || echo 000) + alt_sha=$(sha256sum "$alt_body" 2>/dev/null | awk '{print $1}') + rm -f "$alt_body" + if [[ -z "$canonical_code" ]]; then + record "C7[$alt]" INFO "code=$alt_code (no canonical baseline; manual diff)" + elif [[ "$alt_code" == "$canonical_code" && "$alt_sha" == "$canonical_sha" ]]; then + record "C7[$alt]" PASS "alt IP form parity with canonical (code=$alt_code, body sha matches)" + elif [[ "$alt_code" == "$canonical_code" ]]; then + # Same status, different body — could be a per-request token in IMDS reply; + # treat as INFO so the operator can diff manually rather than FAILing. + record "C7[$alt]" INFO "same code=$alt_code as canonical but body sha differs (canonical=$canonical_sha alt=$alt_sha) — manual diff" + elif [[ "$alt_code" == "000" || "$alt_code" =~ ^4 ]]; then + record "C7[$alt]" PASS "alt IP form refused (code=$alt_code) — no bypass" + else + record "C7[$alt]" FAIL "alt IP form returned code=$alt_code, canonical=$canonical_code — possible eBPF/SSRF-filter bypass" + fi done # C5 — cgroup-namespace evasion attempt (best-effort, requires unshare). diff --git a/pentest/phase4b_local_rules/run.py b/pentest/phase4b_local_rules/run.py index 5a5ad7d3..b840eac3 100755 --- a/pentest/phase4b_local_rules/run.py +++ b/pentest/phase4b_local_rules/run.py @@ -122,8 +122,34 @@ def assert_use_local_file_rules_active() -> None: "Enable useLocalFileRules from the control plane and retry.") sys.exit(2) + # The agent's local-rules merge logic honors `mode` ONLY from the remote + # rule (see local_rules.rs). Any `mode: enforce` we set in a local file + # is silently overridden by whatever the fabric is currently sending. + # If the live remote mode is `Audit` (or `Disabled`), every "expected + # 403" deny scenario will receive a 200 and report a misleading FAIL. + # Fail-fast here so the user knows to flip the fabric mode rather than + # interpret 32 bogus failures. + effective_modes = {} + try: + snap_json = json.loads(snap_text) + for tgt in ("imds", "wireserver"): + m = (snap_json.get("computedRules", {}).get(tgt, {}).get("mode") + or snap_json.get("inputRules", {}).get(tgt, {}).get("mode")) + if m: + effective_modes[tgt] = m + except Exception: + pass + non_enforce = {t: m for t, m in effective_modes.items() if m.lower() != "enforce"} + if non_enforce: + record("PRE", "FAIL", + f"effective remote mode is not Enforce ({non_enforce}); " + "local-rules deny scenarios will be silently audited and report bogus FAILs. " + "Switch the fabric/HostGAPlugin rule mode to Enforce and retry.") + sys.exit(2) + record("PRE", "PASS", - f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}") + f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}; " + f"effective remote mode={effective_modes}") def get_current_user_identity() -> dict: From 42988c1ed7463e0229e489ffd04503628fe4e36d Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 14 May 2026 19:17:49 +0000 Subject: [PATCH 07/37] Updated the design md file --- pentest/DESIGN.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pentest/DESIGN.md b/pentest/DESIGN.md index 8c1971c7..b1215153 100644 --- a/pentest/DESIGN.md +++ b/pentest/DESIGN.md @@ -41,7 +41,7 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM | A1 | Port-scan localhost and all NICs (`nmap -sT -p- 127.0.0.1` and ``). | Only `127.0.0.1:3080` open; nothing on external IFs. | The proxy must be reachable **only** from in-guest processes. Any binding to an external interface would let a network-adjacent attacker (vNet peer, lateral mover) speak to GPA directly and turn it into a fabric oracle. This is the most basic perimeter test — if it fails, every other AuthN/AuthZ control is moot. | | A2 | Connect to `3080` from another VM in the vNet. | TCP RST / unreachable. | Cross-checks A1 from the *attacker's* viewpoint: even if a misconfig binds to `0.0.0.0`, NSG/host firewall must still drop it. Verifies defense-in-depth between the listener config and the cloud network filter. | | A3 | Send malformed HTTP, oversized headers, slow-loris, chunked-encoding desync to `:3080`. | Graceful close, no crash, no memory growth. | Detects parser bugs (panic, OOM, integer overflow) in the hyper-based listener. A crash is an availability finding (fabric calls then fail-open or fail-closed depending on H4-style behavior); a desync would let one client smuggle requests under another client's identity — the worst-case AuthZ bypass. | -| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. | Rejected; no proxying to arbitrary hosts. | The proxy is HTTP/1.1-only and must not be a generic forward proxy. `CONNECT` or a smuggled `Host:` header could turn GPA into an SSRF gadget that talks to any internal IP from the *signed*, *authorized* GPA process — a privilege escalation against the host fabric. | +| A4 | TLS / HTTP/2 upgrade attempts, `CONNECT` method, Host-header smuggling. Automated probe is `curl -X CONNECT --request-target 'example.com:443' http://127.0.0.1:3080` (replaces an earlier `printf | nc` pipeline that raced the server's `Connection: close` FIN). | **HTTP/1.1 405 Method Not Allowed** with `Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH`; no proxying to arbitrary hosts. Implemented in [proxy_server.rs](../proxy_agent/src/proxy/proxy_server.rs) via an explicit `is_method_allowed` allow-list returned **before** any forwarding, plus `.with_upgrades()` on `serve_connection` so hyper actually delivers `CONNECT` to our handler instead of intercepting it as an upgrade. Unit test: `unsupported_methods_return_405`. | The proxy is HTTP/1.1-only and must not be a generic forward proxy. `CONNECT` or a smuggled `Host:` header could turn GPA into an SSRF gadget that talks to any internal IP from the *signed*, *authorized* GPA process — a privilege escalation against the host fabric. | | A5 | Open-proxy test: `curl -x 127.0.0.1:3080 http://example.com`. | Refused — only IMDS/WireServer/HostGA destinations reachable. | Confirms the destination allow-list is enforced regardless of how the request is framed. Prevents using GPA as an exfiltration / pivot proxy and ensures the `x-ms-azure-signature` is never minted for a non-fabric URL. | ### B. AuthN bypass — forging the signature @@ -50,6 +50,7 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM | B1 | Direct `curl http://168.63.129.16/...` and `curl http://169.254.169.254/...` from guest, observe whether GPA inserts a valid `x-ms-azure-signature`. Then try the **same request bypassing 3080** (raw-socket / scapy with crafted SYN to the real IP, dropping the cgroup hook). | Without GPA injection, WireServer rejects; the signature must not be forgeable offline. | Validates the **fundamental authentication invariant**: only requests that traverse GPA receive a valid signature, and the fabric refuses any request that arrives without one. If a raw-socket call ever succeeds, an attacker can talk to WireServer/HostGAPlugin without going through any of GPA's authorization rules — effectively bypassing the entire product. | | B2 | Replay attack: capture an authorized request from `/var/log/azure-proxy-agent/ProxyAgent.Connection.log` correlations + tcpdump on lo, replay it from another process. | Replay rejected (timestamp / nonce / per-connection binding). | A signature that is valid forever / for any caller would let any local process steal a previously-observed authorization. Confirms the signing scheme binds to time + connection context, not just URL+key. | | B3 | Steal the key file: as non-root, attempt to read `/var/lib/azure-proxy-agent/keys/*`. As root, exfiltrate and try to sign from another VM. | Non-root: EACCES. Root-exfil: signature still rejected by WireServer for a different VM identity (vTPM/HostGAPlugin binding). | Two-layer test: (1) least-privilege access to key material on disk; (2) even with the key, the fabric should bind it to *this* VM's identity (provisioned via HostGAPlugin), so a stolen key is useless on another VM. Failure of (2) would mean a single host compromise yields cross-VM fabric access. | +| B1b | Open a TCP socket to the IMDS IP from the test shell and snapshot `ss -tnp` for the FD. The harness keeps the FD open in the *current* shell (an earlier subshell-scoped `( exec 3<> ... )` closed the FD before `ss` ran and produced empty results). | Kernel reports the connection's remote endpoint as `127.0.0.1:3080` (or the local GPA listener), proving the cgroup-attached eBPF redirect is in effect for this cgroup. PASS on `:3080`/`127.0.0.1`, FAIL if the kernel shows the raw IMDS IP. | Automated proof that the eBPF redirect is doing its job for the calling process — without this, B1's bypass attempt is meaningless because we can't tell whether the request was forced through GPA or simply happened to be answered by IMDS. | | B4 | Submit a request with attacker-supplied `x-ms-azure-signature`/`x-ms-azure-time-tick` headers through the proxy. | GPA strips/overwrites client-supplied auth headers (verify in code path). | Prevents header smuggling: a malicious in-guest caller must not be able to *inject* a forged signature that GPA then forwards verbatim. GPA must always overwrite/normalize these headers before forwarding so the fabric only ever sees GPA's own signature. | ### C. AuthZ bypass — cgroup / eBPF audit spoofing @@ -59,9 +60,9 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM | C2 | `setuid` / capability dropping mid-process to confuse the `userId/runAsElevated` decision. | GPA reads creds at connect time from socket peer → still correct uid. | Confirms identity is captured from the kernel **at connect-time** via `SO_PEERCRED`, not re-read from `/proc` later. A TOCTOU here would let a process raise privileges *after* GPA cached its identity, or drop them to confuse audit attribution. | | C3 | Bind-mount `/proc/self/exe` over a path matching an allowed binary; check the `processFullPath` reported in the log. | Identity comes from kernel-side eBPF/audit, not `/proc//exe` readlink. Decision unchanged. | Ensures `exePath`-based identity rules can't be spoofed by a mount-namespace attacker pretending to be `/usr/bin/curl` (or any other allow-listed binary). The matcher must use a kernel source of truth, not a user-controllable filesystem view. | | C4 | `ptrace`-attach to an allowed process and inject syscall to `connect()` to IMDS. | Still attributed to that PID's cgroup/uid — this is by-design "in-process" trust; **document expected behavior**. | Documents the trust boundary: GPA cannot distinguish a victim process from a parasitic injector inside the same PID. The point of this test is to confirm the *boundary is documented*, not to claim it as a vuln — it sets reviewer expectations and prevents the threat model from drifting. | -| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. | Connection still redirected (or blocked). If not — finding. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | +| C5 | New cgroup that is **not** a descendant of `/sys/fs/cgroup` root (e.g. unshare / `cgroupv1` mount in container). Try to evade the cgroup-attached eBPF and reach `169.254.169.254:80` directly. Harness probes `unshare -Cr -- true` first; auto-prepends `sudo -n` when not root and passwordless sudo is available. If the kernel disallows `unshare -Cr` for the caller (no `CAP_SYS_ADMIN`), record **INFO** ("environment limitation") instead of a misleading FAIL. | Connection still redirected to GPA (HTTP 200 from `169.254.169.254` via the redirect). If a `000` is observed despite a successful `unshare`, that's a real eBPF/cgroup gap — investigate `bpftool cgroup tree` and the attach point. | The cgroup-root-attached eBPF is the **single chokepoint** that forces all 169.254/168.63 traffic through GPA. If a container or `unshare` workload can move outside that cgroup hierarchy, it can talk to IMDS/WireServer with no AuthN at all. This is one of the highest-impact bypasses in the entire suite. | | C6 | Inside an unprivileged user namespace + new net namespace, route 169.254/16 directly. | Should fail (no key/no signature). Verify. | Even if eBPF is bypassed in a netns, the request still has no signature and the fabric should refuse it. Combined with C5, this gives a *layered* assurance: either the redirect catches it, or the fabric does — never both failing simultaneously. | -| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). | Either redirected the same, or refused. No bypass. | Ensures the eBPF redirect matches the **destination IP**, not a string form, so address-encoding tricks (hex, decimal, IPv4-mapped IPv6) can't reach the fabric while skipping GPA. Classic SSRF-filter bypass technique applied at the network layer. | +| C7 | IPv6 / link-local / alternate forms (`http://[::ffff:169.254.169.254]/...`, `http://0xa9fea9fe/...`, `http://2852039166/...`). Harness now captures the canonical response (status + sha256 of body) at B1a and **automatically diffs** each alt form against it. | **PASS** when alt status code AND body sha256 match the canonical (proves eBPF treated them identically). **PASS** also when alt is refused with a 4xx / `000` (no bypass). **INFO** when status matches but body sha differs (per-request token noise — manual diff). **FAIL** when alt returns a different non-4xx status than the canonical — possible eBPF/SSRF-filter bypass. | Ensures the eBPF redirect matches the **destination IP**, not a string form, so address-encoding tricks (hex, decimal, IPv4-mapped IPv6) can't reach the fabric while skipping GPA. Classic SSRF-filter bypass technique applied at the network layer. | ### D. AuthZ rule engine fuzzing Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs) @@ -133,7 +134,12 @@ Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs Automated by [pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py). -**Pre-condition.** The agent only consumes `/var/lib/azure-proxy-agent/rules/{IMDS,WireServer}_Rules.json` when the rule ID delivered by the fabric (HostGAPlugin) is a base64-encoded JSON whose `useLocalFileRules` field is `true`. The harness verifies this at startup by checking the latest `AuthorizationRules_*.json` snapshot under `/var/log/azure-proxy-agent` for the marker `useLocalFileRules-true`. If the marker isn't present it prints a `PRE FAIL` and exits — flip the flag from your control plane / mock fabric and rerun. +**Pre-conditions.** Two checks run at harness startup, both fail-fast as `PRE FAIL` so a misconfigured environment doesn't produce a wall of misleading per-scenario failures: + +1. **`useLocalFileRules-true` marker present** — the agent only consumes `/var/lib/azure-proxy-agent/rules/{IMDS,WireServer}_Rules.json` when the rule ID delivered by the fabric (HostGAPlugin) is a base64-encoded JSON whose `useLocalFileRules` field is `true`. The harness verifies this by grepping the latest `AuthorizationRules_*.json` snapshot under `/var/log/azure-proxy-agent` for the literal `useLocalFileRules-true`. +2. **Effective remote `mode` is `Enforce`** — `merge_authorization_item()` in [local_rules.rs](../proxy_agent/src/key_keeper/local_rules.rs) honors `mode` *only* from the remote rule. If the fabric is currently sending `Audit` or `Disabled`, every "expected 403" deny scenario will silently pass through and report a bogus FAIL. The harness parses `computedRules.{imds,wireserver}.mode` from the latest snapshot and aborts with `PRE FAIL` if either is non-Enforce. + +If either pre-condition fails, flip the flag / mode from your control plane (or mock fabric) and rerun. **Operating model.** Each scenario: 1. atomically writes a crafted `IMDS_Rules.json` or `WireServer_Rules.json` (mode 0600 root) into the rules dir, @@ -207,6 +213,14 @@ Findings are appended to `pentest/results/findings.tsv`; a per-run console trans ### Phase 7 — Triage & report - For each finding: PoC, severity (CVSS), affected file/line, suggested fix, whether already mitigated upstream. +- All harness records are appended (TSV) to `pentest/results/findings.tsv`. The HTML report is built by [pentest/generate_report.py](generate_report.py) and written to `pentest/results/report.html`. The report is organized into: + 1. **Run configuration & harness setup** — diagnostic rows (`CFG`, `PRE`) emitted by harnesses (poll interval, current identity, enabled targets, pre-flight checks). Excluded from test counts. + 2. **Failures requiring triage** — only real test FAILs. + 3. **Informational findings (no action required)** — real INFO rows: environment limitations, audit-mode probes, by-design observations, manual-review items. + 4. **Per-phase tables** — full PASS/FAIL/INFO breakdown per phase. + 5. **Phase 4 URL/encoding differential** — last `url_diff.tsv`. + 6. **Cheat sheet & artifacts** — raw paths to pcaps, bpftool snapshots, etc. +- Driver: [pentest/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 5 in order; phase 4b ([pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py)) is invoked separately because it requires `sudo` and writes to `/var/lib/azure-proxy-agent/rules/`. Standard cycle: `> pentest/results/findings.tsv && bash pentest/run_all.sh && sudo python3 pentest/phase4b_local_rules/run.py && python3 pentest/generate_report.py`. --- From f7671cf3a0a778fe8f83536921194a88d1a0a235 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 14 May 2026 19:43:31 +0000 Subject: [PATCH 08/37] add windows pentests --- pentest/{ => linux}/DESIGN.md | 0 pentest/{ => linux}/README.md | 0 pentest/{ => linux}/generate_report.py | 0 pentest/{ => linux}/lib/common.sh | 0 pentest/{ => linux}/phase2_listener/run.sh | 0 pentest/{ => linux}/phase3_authn_authz/run.sh | 0 pentest/{ => linux}/phase4_rules_fuzz/url_diff.py | 0 .../__pycache__/run.cpython-312.pyc | Bin pentest/{ => linux}/phase4b_local_rules/run.py | 0 pentest/{ => linux}/phase5_state_fs/audit.sh | 0 pentest/{ => linux}/run_all.sh | 0 pentest/{ => linux}/test_catalog.py | 0 12 files changed, 0 insertions(+), 0 deletions(-) rename pentest/{ => linux}/DESIGN.md (100%) rename pentest/{ => linux}/README.md (100%) rename pentest/{ => linux}/generate_report.py (100%) rename pentest/{ => linux}/lib/common.sh (100%) rename pentest/{ => linux}/phase2_listener/run.sh (100%) rename pentest/{ => linux}/phase3_authn_authz/run.sh (100%) rename pentest/{ => linux}/phase4_rules_fuzz/url_diff.py (100%) rename pentest/{ => linux}/phase4b_local_rules/__pycache__/run.cpython-312.pyc (100%) rename pentest/{ => linux}/phase4b_local_rules/run.py (100%) rename pentest/{ => linux}/phase5_state_fs/audit.sh (100%) rename pentest/{ => linux}/run_all.sh (100%) rename pentest/{ => linux}/test_catalog.py (100%) diff --git a/pentest/DESIGN.md b/pentest/linux/DESIGN.md similarity index 100% rename from pentest/DESIGN.md rename to pentest/linux/DESIGN.md diff --git a/pentest/README.md b/pentest/linux/README.md similarity index 100% rename from pentest/README.md rename to pentest/linux/README.md diff --git a/pentest/generate_report.py b/pentest/linux/generate_report.py similarity index 100% rename from pentest/generate_report.py rename to pentest/linux/generate_report.py diff --git a/pentest/lib/common.sh b/pentest/linux/lib/common.sh similarity index 100% rename from pentest/lib/common.sh rename to pentest/linux/lib/common.sh diff --git a/pentest/phase2_listener/run.sh b/pentest/linux/phase2_listener/run.sh similarity index 100% rename from pentest/phase2_listener/run.sh rename to pentest/linux/phase2_listener/run.sh diff --git a/pentest/phase3_authn_authz/run.sh b/pentest/linux/phase3_authn_authz/run.sh similarity index 100% rename from pentest/phase3_authn_authz/run.sh rename to pentest/linux/phase3_authn_authz/run.sh diff --git a/pentest/phase4_rules_fuzz/url_diff.py b/pentest/linux/phase4_rules_fuzz/url_diff.py similarity index 100% rename from pentest/phase4_rules_fuzz/url_diff.py rename to pentest/linux/phase4_rules_fuzz/url_diff.py diff --git a/pentest/phase4b_local_rules/__pycache__/run.cpython-312.pyc b/pentest/linux/phase4b_local_rules/__pycache__/run.cpython-312.pyc similarity index 100% rename from pentest/phase4b_local_rules/__pycache__/run.cpython-312.pyc rename to pentest/linux/phase4b_local_rules/__pycache__/run.cpython-312.pyc diff --git a/pentest/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py similarity index 100% rename from pentest/phase4b_local_rules/run.py rename to pentest/linux/phase4b_local_rules/run.py diff --git a/pentest/phase5_state_fs/audit.sh b/pentest/linux/phase5_state_fs/audit.sh similarity index 100% rename from pentest/phase5_state_fs/audit.sh rename to pentest/linux/phase5_state_fs/audit.sh diff --git a/pentest/run_all.sh b/pentest/linux/run_all.sh similarity index 100% rename from pentest/run_all.sh rename to pentest/linux/run_all.sh diff --git a/pentest/test_catalog.py b/pentest/linux/test_catalog.py similarity index 100% rename from pentest/test_catalog.py rename to pentest/linux/test_catalog.py From 3266a4744cba234948183cff526f7652fdb61906 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 14 May 2026 20:13:51 +0000 Subject: [PATCH 09/37] re-struct --- .gitignore | 2 +- pentest/README.md | 33 ++ pentest/linux/DESIGN.md | 9 +- pentest/linux/generate_report.py | 26 +- pentest/linux/phase3_authn_authz/run.sh | 11 +- pentest/linux/test_catalog.py | 38 +- pentest/windows/Common.psm1 | 113 ++++ pentest/windows/DESIGN.md | 207 +++++++ pentest/windows/Generate-Report.ps1 | 390 ++++++++++++++ pentest/windows/Phase2-Listener.ps1 | 149 +++++ pentest/windows/Phase3-AuthN-AuthZ.ps1 | 183 +++++++ pentest/windows/Phase4-RulesFuzz.ps1 | 83 +++ pentest/windows/Phase4b-LocalRules.ps1 | 597 +++++++++++++++++++++ pentest/windows/Phase5-FileSystemAudit.ps1 | 106 ++++ pentest/windows/README.md | 82 +++ pentest/windows/Run-AllPenTests.ps1 | 69 +++ pentest/windows/TestCatalog.psm1 | 232 ++++++++ 17 files changed, 2291 insertions(+), 39 deletions(-) create mode 100644 pentest/README.md create mode 100644 pentest/windows/Common.psm1 create mode 100644 pentest/windows/DESIGN.md create mode 100644 pentest/windows/Generate-Report.ps1 create mode 100644 pentest/windows/Phase2-Listener.ps1 create mode 100644 pentest/windows/Phase3-AuthN-AuthZ.ps1 create mode 100644 pentest/windows/Phase4-RulesFuzz.ps1 create mode 100644 pentest/windows/Phase4b-LocalRules.ps1 create mode 100644 pentest/windows/Phase5-FileSystemAudit.ps1 create mode 100644 pentest/windows/README.md create mode 100644 pentest/windows/Run-AllPenTests.ps1 create mode 100644 pentest/windows/TestCatalog.psm1 diff --git a/.gitignore b/.gitignore index 425a529e..c3dc84e2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,5 +34,5 @@ .vs/ # pentest run & results -/pentest/results/ +/pentest/*/results/ __pycache__/ diff --git a/pentest/README.md b/pentest/README.md new file mode 100644 index 00000000..00ff92b7 --- /dev/null +++ b/pentest/README.md @@ -0,0 +1,33 @@ +# GPA Pen-Test Harness + +Two parallel harnesses — one per OS — exercising the same scenario set +(`A1` / `B1b` / `C7` / `IMDS-S6` / …) against the Guest Proxy Agent. Each +folder is self-contained: its own scripts, its own design doc, its own +report generator, its own `results/` directory. + +| Folder | Language | Report generator | Run from | +|---|---|---|---| +| [linux/](linux/) | Bash + Python | `linux/generate_report.py` | Bash on the GPA Linux VM (`bash linux/run_all.sh`) | +| [windows/](windows/) | PowerShell | `windows/Generate-Report.ps1` | Elevated PowerShell on the GPA Windows VM (`.\windows\Run-AllPenTests.ps1`) | + +Findings on either platform are written as TSV rows with the same schema: + +``` +\t\t\t +``` + +so a Linux `findings.tsv` and a Windows `findings.tsv` can be diffed +test-by-test. + +## Pick a side + +- **Linux harness** — see [linux/README.md](linux/README.md) and + [linux/DESIGN.md](linux/DESIGN.md). +- **Windows harness** — see [windows/README.md](windows/README.md) and + [windows/DESIGN.md](windows/DESIGN.md). + +The Windows DESIGN doc has a cross-platform mapping table at the bottom +([windows/DESIGN.md §5](windows/DESIGN.md)) that lists each Linux concept +(eBPF redirect, `bpftool`, `tcpdump`, POSIX modes, `unshare -Cr`, …) next +to its Windows equivalent (WFP, `netsh wfp show filters`, `pktmon`, ACLs, +Containers feature, …). diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index b1215153..87ad6def 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -132,7 +132,7 @@ Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs ### Phase 4b — Local-file authorization rules (`useLocalFileRules`) -Automated by [pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py). +Automated by [pentest/linux/phase4b_local_rules/run.py](phase4b_local_rules/run.py). **Pre-conditions.** Two checks run at harness startup, both fail-fast as `PRE FAIL` so a misconfigured environment doesn't produce a wall of misleading per-scenario failures: @@ -198,7 +198,7 @@ sudo phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encodi sudo phase4b_local_rules/run.py --poll 10 # override refresh wait ``` -Findings are appended to `pentest/results/findings.tsv`; a per-run console transcript lands in `pentest/results/phase4b_local_rules.log`. +Findings are appended to `pentest/linux/results/findings.tsv`; a per-run console transcript lands in `pentest/linux/results/phase4b_local_rules.log`. **Triage rules.** - Any `*S2`, `*S6`, `*S10` `FAIL` is a high-severity AuthZ-bypass / fail-open finding. @@ -213,14 +213,14 @@ Findings are appended to `pentest/results/findings.tsv`; a per-run console trans ### Phase 7 — Triage & report - For each finding: PoC, severity (CVSS), affected file/line, suggested fix, whether already mitigated upstream. -- All harness records are appended (TSV) to `pentest/results/findings.tsv`. The HTML report is built by [pentest/generate_report.py](generate_report.py) and written to `pentest/results/report.html`. The report is organized into: +- All harness records are appended (TSV) to `pentest/linux/results/findings.tsv`. The HTML report is built by [pentest/linux/generate_report.py](generate_report.py) and written to `pentest/linux/results/report.html`. The report is organized into: 1. **Run configuration & harness setup** — diagnostic rows (`CFG`, `PRE`) emitted by harnesses (poll interval, current identity, enabled targets, pre-flight checks). Excluded from test counts. 2. **Failures requiring triage** — only real test FAILs. 3. **Informational findings (no action required)** — real INFO rows: environment limitations, audit-mode probes, by-design observations, manual-review items. 4. **Per-phase tables** — full PASS/FAIL/INFO breakdown per phase. 5. **Phase 4 URL/encoding differential** — last `url_diff.tsv`. 6. **Cheat sheet & artifacts** — raw paths to pcaps, bpftool snapshots, etc. -- Driver: [pentest/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 5 in order; phase 4b ([pentest/phase4b_local_rules/run.py](phase4b_local_rules/run.py)) is invoked separately because it requires `sudo` and writes to `/var/lib/azure-proxy-agent/rules/`. Standard cycle: `> pentest/results/findings.tsv && bash pentest/run_all.sh && sudo python3 pentest/phase4b_local_rules/run.py && python3 pentest/generate_report.py`. +- Driver: [pentest/linux/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 5 in order; phase 4b ([pentest/linux/phase4b_local_rules/run.py](phase4b_local_rules/run.py)) is invoked separately because it requires `sudo` and writes to `/var/lib/azure-proxy-agent/rules/`. Standard cycle: `> pentest/linux/results/findings.tsv && bash pentest/linux/run_all.sh && sudo python3 pentest/linux/phase4b_local_rules/run.py && python3 pentest/linux/generate_report.py`. --- @@ -232,3 +232,4 @@ cargo install --locked httpx-cli # optional ``` Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). + diff --git a/pentest/linux/generate_report.py b/pentest/linux/generate_report.py index fbb632c1..11a8802b 100644 --- a/pentest/linux/generate_report.py +++ b/pentest/linux/generate_report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate an HTML pen-test report from pentest/results/findings.tsv. +"""Generate an HTML pen-test report from pentest/linux/results/findings.tsv. Each test row in the report includes inline design notes, how it's automated, how to reproduce it (script + manual), and — for FAILed rows — the suggested @@ -231,7 +231,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: p.append( f"
Generated {generated} · host {html.escape(host)} · " f"{html.escape(osinfo)} · " - "design source: pentest/DESIGN.md · catalog: pentest/test_catalog.py
" + "design source: pentest/linux/DESIGN.md · catalog: pentest/linux/test_catalog.py" ) p.append("
") @@ -337,7 +337,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: p.append("
" "Compares response codes for canonical vs encoded variants of the same path. " "DIFF rows warrant review.
" - "Repro (script): python3 pentest/phase4_rules_fuzz/url_diff.py
" + "Repro (script): python3 pentest/linux/phase4_rules_fuzz/url_diff.py
" "Repro (manual): " "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' 'http://169.254.169.254<variant-path>'" "
") @@ -365,18 +365,18 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: p.append("

Run from the workspace root:

") p.append("
"
              "# All safe phases (2, 3, 4, 5):\n"
-             "bash pentest/run_all.sh\n\n"
+             "bash pentest/linux/run_all.sh\n\n"
              "# Individual phases:\n"
-             "bash    pentest/phase2_listener/run.sh           # A1–A5, G1\n"
-             "bash    pentest/phase3_authn_authz/run.sh        # B1, B3, B4, C1, C5, C7 (+ tcpdump)\n"
-             "python3 pentest/phase4_rules_fuzz/url_diff.py    # URL encoding differential\n"
-             "bash    pentest/phase5_state_fs/audit.sh         # E1, F1 (+ bpftool snapshots)\n\n"
+             "bash    pentest/linux/phase2_listener/run.sh           # A1–A5, G1\n"
+             "bash    pentest/linux/phase3_authn_authz/run.sh        # B1, B3, B4, C1, C5, C7 (+ tcpdump)\n"
+             "python3 pentest/linux/phase4_rules_fuzz/url_diff.py    # URL encoding differential\n"
+             "bash    pentest/linux/phase5_state_fs/audit.sh         # E1, F1 (+ bpftool snapshots)\n\n"
              "# Phase 4b — local-file rules (needs root + useLocalFileRules=true on fabric):\n"
-             "sudo python3 pentest/phase4b_local_rules/run.py\n"
-             "sudo python3 pentest/phase4b_local_rules/run.py --target imds\n"
-             "sudo python3 pentest/phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass\n\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py --target imds\n"
+             "sudo python3 pentest/linux/phase4b_local_rules/run.py --scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass\n\n"
              "# Regenerate this report:\n"
-             "python3 pentest/generate_report.py"
+             "python3 pentest/linux/generate_report.py"
              "
") p.append("
") @@ -392,7 +392,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: p.append("") p.append("") + "see pentest/linux/DESIGN.md for the full scenario taxonomy.") p.append("") return "".join(p) diff --git a/pentest/linux/phase3_authn_authz/run.sh b/pentest/linux/phase3_authn_authz/run.sh index 32aef151..d4d6e13e 100755 --- a/pentest/linux/phase3_authn_authz/run.sh +++ b/pentest/linux/phase3_authn_authz/run.sh @@ -29,6 +29,7 @@ fi cleanup() { if [[ -n "$TCPDUMP_PID" ]]; then $SUDO kill "$TCPDUMP_PID" 2>/dev/null || true; fi + [[ -n "${imds_body:-}" ]] && rm -f "$imds_body" } trap cleanup EXIT @@ -36,8 +37,14 @@ trap cleanup EXIT imds_url="http://$IMDS_IP/metadata/instance?api-version=2021-02-01" imds_hdr="Metadata: true" +# Per-run body cache. Using mktemp avoids the fs.protected_regular=2 trap on +# Ubuntu, where root cannot overwrite a /tmp file owned by a different user +# (curl would print "Failure writing output to destination" and exit 23, +# making B1a a false positive on subsequent runs). +imds_body=$(mktemp -t gpa_imds_body.XXXXXX) + # B1a — IMDS through redirected path: should succeed and GPA should inject signature -code=$(curl -sS -o /tmp/.gpa_imds.body -w '%{http_code}' --max-time 8 -H "$imds_hdr" "$imds_url" || echo 000) +code=$(curl -sS -o "$imds_body" -w '%{http_code}' --max-time 8 -H "$imds_hdr" "$imds_url" || echo 000) if [[ "$code" == "200" ]]; then record B1a PASS "IMDS reachable through GPA-redirected path (200)" else @@ -45,7 +52,7 @@ else fi # Canonical response captured for C7 parity comparison below. canonical_code="$code" -canonical_sha=$(sha256sum /tmp/.gpa_imds.body 2>/dev/null | awk '{print $1}') +canonical_sha=$(sha256sum "$imds_body" 2>/dev/null | awk '{print $1}') # B1b — Confirm the kernel sees our IMDS connection as redirected to 127.0.0.1:3080 # (i.e. the cgroup-attached eBPF redirect is in effect for THIS shell's cgroup). diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index 17e0e4a2..d8f00a4d 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -29,7 +29,7 @@ class TestInfo(TypedDict, total=False): "title": "Listener bound only to loopback", "design": "GPA must listen ONLY on 127.0.0.1:3080. Any non-loopback bind would expose the proxy to other VMs on the vNet.", "automation": "ss -tnlH | grep ':3080$' filtered against external IPv4 addresses (`ip -o -4 addr show scope global`).", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "ss -tnlp | grep 3080\nip -o -4 addr show scope global", "fix": "Bind the proxy listener explicitly to 127.0.0.1, not 0.0.0.0. Check `bind` call in proxy_agent/src/proxy/proxy_listener.rs.", }, @@ -37,7 +37,7 @@ class TestInfo(TypedDict, total=False): "title": "Loopback connect to 3080 succeeds", "design": "Sanity check: the listener is reachable on 127.0.0.1:3080.", "automation": "`timeout 2 bash -c '>/dev/tcp/127.0.0.1/3080'`.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "timeout 2 bash -c '>/dev/tcp/127.0.0.1/3080' && echo OK", "fix": "If failing, the agent is not running. `systemctl status azure-proxy-agent`; check journalctl for bind errors.", }, @@ -45,7 +45,7 @@ class TestInfo(TypedDict, total=False): "title": "Port 3080 NOT reachable on external IP", "design": "Even if a misconfiguration binds 0.0.0.0, vNet peers must NOT be able to connect to :3080.", "automation": "`timeout 2 bash -c '>/dev/tcp//3080'` against the first global-scope IPv4 — must time out.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "ip -o -4 addr show scope global # pick IP\ntimeout 2 bash -c '>/dev/tcp//3080' || echo refused", "fix": "Same as A1 — confirm `bind(127.0.0.1, 3080)` and add a host-firewall (nftables/iptables) DROP on 3080 for non-loopback as defense-in-depth.", }, @@ -53,7 +53,7 @@ class TestInfo(TypedDict, total=False): "title": "Service survives malformed HTTP", "design": "Bad method, HTTP/9.9, 65 KB URI, slow-loris must not crash the agent.", "automation": "Pipes 5 crafted requests through `nc` and verifies `pidof azure-proxy-agent` is unchanged.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "printf 'INVALIDMETHOD / HTTP/1.1\\r\\n\\r\\n' | nc -q1 127.0.0.1 3080\nprintf 'GET /%s HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n' \"$(python3 -c 'print(\"A\"*65000)')\" | nc -q1 127.0.0.1 3080", "fix": "If FAIL, gather a core dump (`coredumpctl gdb azure-proxy-agent`) and harden the HTTP parser against the failing input class.", }, @@ -61,7 +61,7 @@ class TestInfo(TypedDict, total=False): "title": "CONNECT method must be rejected", "design": "GPA must not act as a generic HTTP tunnel. CONNECT to arbitrary :443 must be refused with an explicit 4xx, never silently closed.", "automation": "Sends raw `CONNECT example.com:443 HTTP/1.1` over `nc`, parses the first response status. PASS if the status is non-2xx and non-empty.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "printf 'CONNECT example.com:443 HTTP/1.1\\r\\nHost: example.com:443\\r\\n\\r\\n' | nc -q2 127.0.0.1 3080", "fix": "In the request parser (proxy_agent/src/proxy/proxy_server.rs / proxy_listener.rs) explicitly reject any non-{GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH} method with `405 Method Not Allowed` and a short body BEFORE closing. Never silently drop unsupported methods — write a unit test for CONNECT, TRACE, and a random verb.", }, @@ -69,7 +69,7 @@ class TestInfo(TypedDict, total=False): "title": "Not an open proxy", "design": "GPA must refuse to forward to arbitrary hosts even when explicitly proxied.", "automation": "`curl -x http://127.0.0.1:3080 http://example.com/` — expects non-2xx.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "curl -v -x http://127.0.0.1:3080 http://example.com/", "fix": "Allowlist destinations in the connect-policy redirector; reject any Host:/absolute-URI not in {WireServer, IMDS, HostGAPlugin}.", }, @@ -77,7 +77,7 @@ class TestInfo(TypedDict, total=False): "title": "Connection burst doesn't crash", "design": "200 concurrent /dev/tcp connections; service PID must be stable.", "automation": "Spawns 200 background connect-and-read loops via `/dev/tcp/127.0.0.1/3080`, then compares pidof before/after.", - "repro_script": "bash pentest/phase2_listener/run.sh", + "repro_script": "bash pentest/linux/phase2_listener/run.sh", "repro_manual": "for i in $(seq 1 200); do (exec 3<>/dev/tcp/127.0.0.1/3080; echo -e 'GET / HTTP/1.0\\r\\n\\r' >&3; cat <&3 >/dev/null) & done; wait\npidof azure-proxy-agent", "fix": "Apply per-source connection limits in the listener and verify `systemctl cat azure-proxy-agent` includes the CPU/Memory drop-ins.", }, @@ -89,14 +89,14 @@ class TestInfo(TypedDict, total=False): "title": "Phase-3 packet capture", "design": "Records traffic to fabric IPs + lo:3080 for offline analysis.", "automation": "`sudo tcpdump -i any -U -w results/phase3-*.pcap` filtered to IMDS/WireServer/loopback:3080.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "sudo tcpdump -i any -w /tmp/p3.pcap 'host 169.254.169.254 or host 168.63.129.16 or (host 127.0.0.1 and port 3080)'", }, "B1a": { "title": "IMDS reachable through GPA", "design": "A normal IMDS call from the guest must succeed (200) thanks to eBPF redirect + GPA signature injection.", "automation": "`curl -H 'Metadata: true' http://169.254.169.254/metadata/instance?api-version=2021-02-01`.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", "fix": "If FAIL, check eBPF programs are attached (`bpftool prog show`, `bpftool cgroup tree`) and that key-keeper has latched a key (status.json secureChannelState).", }, @@ -104,14 +104,14 @@ class TestInfo(TypedDict, total=False): "title": "ss snapshot of own connection", "design": "Record what the kernel sees as the peer for our IMDS connection (informational).", "automation": "Opens `/dev/tcp/IMDS/80` and snapshots `ss -tnp` for current PID.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "( exec 3<>/dev/tcp/169.254.169.254/80 ); ss -tnp | grep $$", }, "B4": { "title": "Attacker-supplied signature header is overwritten", "design": "GPA must strip/overwrite client-supplied `x-ms-azure-signature` / `x-ms-azure-time-tick`. Successful 200 means GPA injected its own — confirm in the pcap that the on-wire header value differs.", "automation": "curl with forged headers; expects 200, then manual pcap diff.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "curl -sS -H 'Metadata: true' -H 'x-ms-azure-signature: AAAA-attacker' -H 'x-ms-azure-time-tick: 0' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'\n# then inspect: tshark -r results/phase3-*.pcap -Y 'http and ip.dst==168.63.129.16' -T fields -e http.request.line", "fix": "In the request-rewriting layer, unconditionally remove inbound `x-ms-azure-*` headers before computing/inserting GPA's own signature.", }, @@ -119,7 +119,7 @@ class TestInfo(TypedDict, total=False): "title": "Key files not group/world readable", "design": "Anything under /var/lib/azure-proxy-agent/keys must be 0600 root.", "automation": "`find /var/lib/azure-proxy-agent/keys -type f \\( -perm -o+r -o -perm -g+r \\)` must return empty.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "sudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %n' {} \\;", "fix": "Force 0600 on every key write (Rust: `OpenOptions::new().mode(0o600)`); add a self-heal pass on service start.", }, @@ -127,7 +127,7 @@ class TestInfo(TypedDict, total=False): "title": "Non-elevated user denied WireServer", "design": "WireServer requires `runAsElevated`; calls from `nobody` must be 401/403.", "automation": "`sudo -u nobody curl -H 'x-ms-version: 2012-11-30' http://168.63.129.16/machine/?comp=goalstate`.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "sudo -u nobody curl -sS -o /dev/null -w '%{http_code}\\n' -H 'x-ms-version: 2012-11-30' 'http://168.63.129.16/machine/?comp=goalstate'", "fix": "If FAIL with 200, check the WireServer authorizer (`proxy_agent/src/proxy/proxy_authorizer.rs`) — `runAsElevated` gate is broken.", }, @@ -135,7 +135,7 @@ class TestInfo(TypedDict, total=False): "title": "cgroup-namespace evasion attempt", "design": "Inside `unshare -Cr`, a process should still hit the cgroup-attached eBPF program (the cgroup IS still under the root cgroup hierarchy). Connection must remain redirected.", "automation": "`unshare -Cr -- curl …/metadata/instance` — expects 200.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "unshare -Cr -- curl -sS -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'", "fix": "A `000` likely means `unshare -Cr` failed before curl ran on this kernel (no CAP_SYS_ADMIN for the user namespace). Re-run as root, or update the harness to detect ENOSYS/EPERM from `unshare` and record INFO instead of FAIL. If unshare succeeds and curl gets 000 from the redirected port, the eBPF program is not catching the new namespace — investigate `bpftool cgroup tree` and the cgroup attach point.", }, @@ -143,7 +143,7 @@ class TestInfo(TypedDict, total=False): "title": "Alternate IP-form bypass attempt", "design": "`http://0xa9fea9fe/...` and `http://2852039166/...` resolve to 169.254.169.254. The eBPF redirect should treat them the same; AuthZ decision should be identical to the canonical form.", "automation": "`curl` against each alternate URL, code recorded INFO.", - "repro_script": "bash pentest/phase3_authn_authz/run.sh", + "repro_script": "bash pentest/linux/phase3_authn_authz/run.sh", "repro_manual": "curl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://0xa9fea9fe/metadata/instance?api-version=2021-02-01\ncurl -sS -o /dev/null -w '%{http_code}\\n' -H 'Metadata: true' http://2852039166/metadata/instance?api-version=2021-02-01", "fix": "If a code DIFFERS from the canonical (200), file a finding: the redirector or matcher treats numeric host forms inconsistently.", }, @@ -159,7 +159,7 @@ class TestInfo(TypedDict, total=False): "title": "Filesystem mode/owner audit", "design": "Each path's mode must be ≤ the documented max and owner == root. Key files MUST be 0600; dirs 0700/0755; binary 0755; unit file 0644.", "automation": "`sudo stat -c '%a %U' ` for key dir, log dir, binary, unit file, and every regular file under keys/.", - "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", "repro_manual": "sudo stat -c '%a %U %n' /var/lib/azure-proxy-agent/keys /var/log/azure-proxy-agent /usr/sbin/azure-proxy-agent /usr/lib/systemd/system/azure-proxy-agent.service\nsudo find /var/lib/azure-proxy-agent/keys -type f -exec stat -c '%a %U %n' {} \\;", "fix": ( "Group-writable binary / unit (0775, 0664): fix the package to install with explicit modes " @@ -175,7 +175,7 @@ class TestInfo(TypedDict, total=False): "title": "Status / rules files contain no secrets", "design": "World-readable status.json and AuthorizationRules_*.json must NOT contain key material, signatures, tokens, or HMACs.", "automation": "`grep -aE '\"key\"|secret|signature|hmac|token'` over every matching file.", - "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", "repro_manual": "grep -aE '\"key\"|secret|signature|hmac|token' /var/log/azure-proxy-agent/status.json /var/log/azure-proxy-agent/AuthorizationRules_*.json", "fix": "If FAIL, redact the offending field in the writer (search for the field name across `proxy_agent/src/proxy/`).", }, @@ -183,7 +183,7 @@ class TestInfo(TypedDict, total=False): "title": "bpftool snapshots saved", "design": "Captures `bpftool prog show` and `bpftool cgroup tree` for offline review.", "automation": "`bpftool prog show > results/bpftool_prog_show.txt`, `bpftool cgroup tree > results/bpftool_cgroup_tree.txt`.", - "repro_script": "bash pentest/phase5_state_fs/audit.sh", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", "repro_manual": "sudo bpftool prog show\nsudo bpftool cgroup tree", }, @@ -196,7 +196,7 @@ class TestInfo(TypedDict, total=False): "title": "Pre-flight: useLocalFileRules must be active", "design": "Phase 4b only meaningful when the fabric delivers a ruleId whose decoded JSON has `useLocalFileRules: true`. Without it, the agent ignores the local files we write.", "automation": "Reads latest `/var/log/azure-proxy-agent/AuthorizationRules_*.json` and looks for the substring `useLocalFileRules-true`.", - "repro_script": "sudo python3 pentest/phase4b_local_rules/run.py", + "repro_script": "sudo python3 pentest/linux/phase4b_local_rules/run.py", "repro_manual": "ls -t /var/log/azure-proxy-agent/AuthorizationRules_*.json | head -1 | xargs grep useLocalFileRules-true", "fix": "Toggle the flag from your control plane / mock fabric, then re-run.", }, diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 new file mode 100644 index 00000000..eadaf089 --- /dev/null +++ b/pentest/windows/Common.psm1 @@ -0,0 +1,113 @@ +# Common.psm1 — shared helpers for the Windows pen-test harness. +# +# Mirrors the Linux harness's record/append-to-findings.tsv contract so the +# same Python report generator (pentest/generate_report.py) can render Windows +# results without modification. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' # individual probes record FAIL, don't abort the suite + +# --- Paths ------------------------------------------------------------------- + +# Repo root = grandparent of this module file (\pentest\windows\..\..). +$script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$script:WindowsRoot = Join-Path $RepoRoot 'pentest\windows' +$script:ResultsDir = Join-Path $WindowsRoot 'results' +$script:Findings = Join-Path $ResultsDir 'findings.tsv' +$script:UrlDiffTsv = Join-Path $ResultsDir 'url_diff.tsv' +$script:Phase4bLog = Join-Path $ResultsDir 'phase4b_local_rules.log' + +# Windows GPA layout (from proxy_agent/config/GuestProxyAgent.windows.json +# and proxy_agent_setup/src/main.rs). +$script:GpaServiceName = 'GuestProxyAgent' +$script:GpaInstallRoot = "$env:SystemDrive\WindowsAzure\ProxyAgent" +$script:GpaExe = Join-Path $GpaInstallRoot 'GuestProxyAgent\GuestProxyAgent.exe' +$script:GpaLogDir = Join-Path $GpaInstallRoot 'Logs' +$script:GpaEventDir = Join-Path $GpaInstallRoot 'Events' +$script:GpaKeyDir = Join-Path $GpaInstallRoot 'Keys' +$script:GpaRulesDir = Join-Path $GpaInstallRoot 'Rules' + +# Fabric endpoints (identical to Linux). +$script:ImdsIp = '169.254.169.254' +$script:WireServerIp = '168.63.129.16' +$script:HostGaPort = 32526 +$script:GpaListenerPort = 3080 +$script:GpaListener = "127.0.0.1:$GpaListenerPort" + +$script:ImdsUrl = "http://$ImdsIp/metadata/instance?api-version=2021-02-01" +$script:ImdsHeaders = @{ 'Metadata' = 'true' } +$script:WireServerUrl = "http://$WireServerIp/machine/?comp=goalstate" +$script:WireHeaders = @{ 'x-ms-version' = '2012-11-30' } + +if (-not (Test-Path $ResultsDir)) { + New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null +} + +# --- record() — append a single finding row to findings.tsv ------------------ +# +# Columns: \t\t\t +# status ∈ { PASS, FAIL, INFO } + +function Write-Finding { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [ValidateSet('PASS','FAIL','INFO')] [string] $Status, + [Parameter(Mandatory)] [string] $Message + ) + # Sanitize message: strip TAB and CR/LF so the TSV stays one row per record. + $clean = ($Message -replace "[\t\r\n]+", ' ').Trim() + $ts = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + $row = "$ts`t$Id`t$Status`t$clean" + Add-Content -Path $Findings -Value $row -Encoding UTF8 + + $color = switch ($Status) { 'PASS' {'Green'} 'FAIL' {'Red'} default {'Yellow'} } + Write-Host "[$Status] $Id`t$clean" -ForegroundColor $color +} + +# --- Convenience wrappers ---------------------------------------------------- + +function Test-ServiceRunning { + param([string] $Name) + try { (Get-Service -Name $Name -ErrorAction Stop).Status -eq 'Running' } + catch { $false } +} + +function Get-HttpStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Url, + [hashtable] $Headers = @{}, + [int] $TimeoutSec = 8, + [string] $Method = 'GET', + [System.Net.ICredentials] $Credentials + ) + try { + $resp = Invoke-WebRequest -Uri $Url -Headers $Headers -Method $Method ` + -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + return [pscustomobject]@{ Code = [int]$resp.StatusCode; Body = $resp.Content } + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } + return [pscustomobject]@{ Code = $code; Body = '' } + } catch { + return [pscustomobject]@{ Code = 0; Body = '' } + } +} + +function Get-Sha256 { + param([string] $Text) + if (-not $Text) { return '' } + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join '' + } finally { $sha.Dispose() } +} + +function Test-Administrator { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $p = New-Object System.Security.Principal.WindowsPrincipal($id) + $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +Export-ModuleMember -Function * -Variable * diff --git a/pentest/windows/DESIGN.md b/pentest/windows/DESIGN.md new file mode 100644 index 00000000..97311514 --- /dev/null +++ b/pentest/windows/DESIGN.md @@ -0,0 +1,207 @@ +# GPA Pen-Test Design — Windows + +Target: Guest Proxy Agent (GPA) running on Windows +(service `GuestProxyAgent`, listener `127.0.0.1:3080`, **WFP** (Windows +Filtering Platform) callouts redirecting IMDS `169.254.169.254:80`, +WireServer `168.63.129.16:80`, HostGAPlugin `168.63.129.16:32526`). + +This document is the Windows companion to +[../linux/DESIGN.md](../linux/DESIGN.md). The threat model and scenario IDs +(`A1`, `B1b`, `C7`, `IMDS-S6`, …) are intentionally identical so findings can +be compared across platforms; only the *implementation* of each probe differs. + +--- + +## 1. Scope & Trust Model + +### In scope (Windows enforcement boundary) +- TCP listener `127.0.0.1:3080` (`GuestProxyAgent.exe`). +- WFP callout(s) registered by the agent that intercept connections to + IMDS / WireServer / HostGAPlugin and rewrite the destination to the local + listener. +- HMAC signing path injected by GPA on authorized requests + (`x-ms-azure-signature`). +- AuthZ engine in [proxy_authorizer.rs](../../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state under + `%SystemDrive%\WindowsAzure\ProxyAgent\Keys` and config in + [GuestProxyAgent.windows.json](../../proxy_agent/config/GuestProxyAgent.windows.json). +- Provisioning, status, and log surfaces under + `%SystemDrive%\WindowsAzure\ProxyAgent\Logs` and `…\Events`. +- Service binary: `%SystemDrive%\WindowsAzure\ProxyAgent\GuestProxyAgent\GuestProxyAgent.exe`. + +### Out of scope +- WireServer / IMDS server-side responses, host fabric, Azure control plane. +- Windows kernel / WFP framework CVEs unrelated to GPA. + +### Attacker models to test against +1. **Standard-user** Windows account (no admin) — primary threat: confused-deputy / SSRF. +2. **Local Administrator** — GPA must still not be a privilege oracle for + WireServer if not allowed. +3. **Workload inside a Windows container** (Server Containers, Hyper-V + isolation) — verify the WFP redirect still applies, or the container + network is denied entirely. +4. **Network-adjacent attacker** (vNet peer, NSG misconfig) — must not + authenticate. +5. **Local malicious binary with ACL/symlink tricks** — can it forge + `processFullPath` / `exePath` identity? + +--- + +## 2. Pen-Test Scenarios (mapped to GPA invariants) + +Scenarios are identical to the Linux harness; only the **implementation** +column differs. See [../linux/DESIGN.md](../linux/DESIGN.md) for the full +"why this matters" rationale per scenario. + +### A. Network exposure / listener hardening + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| A1 | `Get-NetTCPConnection -State Listen | Where LocalPort -eq 3080`. | Only loopback (`127.0.0.1` / `::1`); FAIL on any external/wildcard bind. | +| A1b | `Test-NetConnection 127.0.0.1 -Port 3080`. | Connection accepted. | +| A2 | `Test-NetConnection -Port 3080` against every non-loopback IPv4 from `Get-NetIPAddress`. | All refused. | +| A3 | Send 64 KB `X-Big:` header via raw `TcpClient`; re-check `Get-Service GuestProxyAgent` (recorded as `A3-survive`). | Graceful close, service still Running. | +| A4 | Raw `CONNECT example.com:443 HTTP/1.1` over `TcpClient`. | `HTTP/1.1 405 Method Not Allowed`. | +| A5 | `HttpWebRequest` with `WebProxy("http://127.0.0.1:3080")` to `http://example.com/`. | Non-2xx — GPA refuses to act as a forward proxy. | +| G1 | 200 sequential `TcpClient.Connect` to `:3080`, then re-check service status. | Service still Running. | + +### B. AuthN bypass — forging the signature + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| B1a | `Invoke-WebRequest http://169.254.169.254/metadata/instance` with `Metadata: true`. | 200; capture body sha256 as canonical for C7. | +| B1b | Open `TcpClient` to `169.254.169.254:80`; read `Get-NetTCPConnection -LocalPort -State Established`. | Kernel reports the established peer as `127.0.0.1:3080` (proves WFP redirect is active for THIS process). FAIL if it shows the raw IMDS IP. | +| B3 | `Get-Acl` on every file under `…\ProxyAgent\Keys`. | No Read/Modify/FullControl ACE for `Users` (S-1-5-32-545), `Authenticated Users` (S-1-5-11), `Everyone` (S-1-1-0), or `Anonymous` (S-1-5-7). | +| B4 | `Invoke-WebRequest` with forged `x-ms-azure-signature: AAAA-attacker-supplied` header. | 200 (GPA overwrote it; verify in pktmon capture that on-wire header value differs). | + +### C. AuthZ bypass + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| C1 | If harness was launched as a standard user, probe WireServer directly. If launched as Administrator, attempt `runas /trustlevel:0x20000` (basic user) — `runas` cannot return stdout reliably, so this case is recorded as INFO with a manual-repro hint. | Standard-user probe → 401/403. INFO when not testable. | +| C5 | `Get-WindowsOptionalFeature -Online -FeatureName Containers`. | INFO. Manual verification required: from inside a container, IMDS must reach GPA or fail entirely. | +| C7 | `Invoke-WebRequest` against `http://0xa9fea9fe/...` and `http://2852039166/...`; compare status + body sha256 to canonical from B1a. | PASS on parity, INFO on body-only drift, PASS on 4xx, FAIL on differing non-4xx. | + +### D / 4b. AuthZ rule engine — local-file rules + +Phase 4b ([Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1)) is the +PowerShell port of the Linux Phase 4b harness and runs the **same 20 +scenarios** (`IMDS-S1`…`S10`, `WS-S1`…`S10`). Pre-flight, scenario +construction, refresh-wait, probe matrix, and triage rules are +identical. See the Linux design doc's +[Phase 4b section](../linux/DESIGN.md) for the per-scenario rationale. + +Windows-specific deltas in Phase 4b: + +- **Rules dir**: `%SystemDrive%\WindowsAzure\ProxyAgent\Rules\` (vs + `/var/lib/azure-proxy-agent/rules/`). +- **Identity** is captured from `WindowsIdentity::GetCurrent()`. The default + identity used in non-S9 scenarios pins only `userName` + `groupName` + (`Administrators`); `exePath` / `processName` are deliberately left UNSET so + the matcher's wildcard semantics apply (avoids false-alarm 403s when probes + come from `pwsh.exe` or `powershell.exe`). +- **S9 (`exePath` identity)** allow-lists the *current* PowerShell + interpreter (`(Get-Process -Id $PID).Path`); the negative side issues a + probe via `curl.exe` (shipped with Windows 10/11) which must be denied + because its `exePath` differs. +- **S3 (audit mode)** is recorded as INFO for the same reason as Linux: the + agent's local-rules merge logic in + [local_rules.rs](../../proxy_agent/src/key_keeper/local_rules.rs) takes + `mode` only from the remote (fabric-delivered) AuthorizationItem. + +### E / F. Filesystem / state audit + +| ID | Test (Windows implementation) | Expected | +|----|-------------------------------|----------| +| E1 | `Get-Acl` on `…\Keys` (with `-SecretsOnly`), `…\Logs`, `…\GuestProxyAgent\GuestProxyAgent.exe`, install root, and per-file under Keys. | No `Write`/`Modify`/`FullControl`/`Delete`/`ChangePermissions`/`TakeOwnership` ACE for principals outside `{NT AUTHORITY\SYSTEM, BUILTIN\Administrators, NT SERVICE\TrustedInstaller, COMPUTERNAME\Administrator}`. Secrets paths additionally FAIL on any `Read` from non-admin principals. | +| F1 | Regex `"(key|secret|signature|hmac|token)"\s*:\s*"[^"]+"` against `status.json` + `AuthorizationRules_*.json` under `…\Logs`. | No match. | +| P5-svc | `Get-Service GuestProxyAgent`. | INFO with status / start type. | +| P5-wfp | `netsh wfp show filters file=results\wfp_filters_windows.txt`. | Saved snapshot for offline review (Windows analog of Linux `bpftool prog show`). | + +--- + +## 3. Execution Plan + +### Phase 0 — Prep +- Snapshot the VM (or at least back up `%SystemDrive%\WindowsAzure\ProxyAgent`). +- Record baseline: `Get-Service GuestProxyAgent`, + `Get-NetTCPConnection -State Listen`, + `netsh wfp show filters file=baseline.txt`, + ACLs of Keys/Logs/exe, `Get-FileHash GuestProxyAgent.exe`. + +### Phase 2 — listener / DoS +- A1, A1b, A2, A3, A4, A5, G1 → [Phase2-Listener.ps1](Phase2-Listener.ps1). + +### Phase 3 — AuthN / AuthZ +- P3-cap, B1a, B1b, B3, B4, C1, C5, C7 → [Phase3-AuthN-AuthZ.ps1](Phase3-AuthN-AuthZ.ps1). +- Captures `phase3-windows-*.etl` via `pktmon` for offline analysis (convert + to `.pcapng` via `pktmon etl2pcap` if needed). + +### Phase 4 — URL / encoding diff +- 11 URL variants → [Phase4-RulesFuzz.ps1](Phase4-RulesFuzz.ps1). +- Output: `results\url_diff.tsv`. Latest run is rendered in the report + appendix. + +### Phase 4b — local-file rules +- IMDS S1–S10 + WS S1–S10 → [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1). +- **Pre-conditions** (auto-checked, fail-fast `PRE FAIL`): + 1. `useLocalFileRules-true` substring present in the latest + `AuthorizationRules_*.json`. + 2. Effective remote `mode` is `Enforce` for the chosen target(s). +- Backups of any pre-existing `IMDS_Rules.json` / `WireServer_Rules.json` + are restored on exit (including on `Ctrl+C` / crash). +- Opt-in from the driver: `-IncludePhase4b`. + +### Phase 5 — Filesystem / state audit +- E1, F1, P5-svc, P5-wfp → [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1). + +### Phase 7 — Triage & report +- All harness records are appended (TSV) to + `pentest\windows\results\findings.tsv` with the same schema as the Linux + harness: + ``` + \t\t\t + ``` +- HTML report is built by [Generate-Report.ps1](Generate-Report.ps1) and + written to `pentest\windows\results\report.html`. Report sections (mirror + of the Linux generator): + 1. **Run configuration & harness setup** — `CFG`/`PRE` rows. + 2. **Failures requiring triage**. + 3. **Informational findings**. + 4. **Per-phase tables**. + 5. **Phase 4 URL/encoding differential** — latest `url_diff.tsv`. + 6. **Cheat sheet & artifacts**. +- Driver: [Run-AllPenTests.ps1](Run-AllPenTests.ps1) runs phases 2 / 3 / 4 / 5 + by default; phase 4b is opt-in via `-IncludePhase4b`. + +--- + +## 4. Tooling staged on this box + +Built-in to Windows 10/11 / Server 2019+: +- `pktmon` (packet capture, ETL output) +- `netsh wfp show filters` (WFP snapshot) +- `Get-NetTCPConnection`, `Test-NetConnection`, `Get-NetIPAddress` +- `Get-Acl` / DACL inspection +- `curl.exe` (used by Phase 4b S9 negative probe) + +No third-party tools or Python required. + +--- + +## 5. Cross-platform mapping (Linux concept → Windows implementation) + +| Linux | Windows | +|---|---| +| cgroup-attached eBPF redirect | WFP (Windows Filtering Platform) callouts in `GuestProxyAgent` service | +| `ss -tnp` (B1b kernel-side proof) | `Get-NetTCPConnection` keyed on local ephemeral port | +| `tcpdump -i any` (P3-cap) | `pktmon start --etw -f *.etl` filtered on IMDS / WireServer / `:3080` | +| `bpftool prog show` / `cgroup tree` | `netsh wfp show filters` | +| POSIX modes (E1) | DACL ACEs (`Get-Acl`) — flag non-{SYSTEM, Administrators, TrustedInstaller} write/modify; secrets paths flag any non-admin read | +| `sudo -u nobody curl …` (C1) | Re-run from a standard-user PowerShell (no analogous always-present unprivileged account) | +| `unshare -Cr` (C5) | INFO note — `Get-WindowsOptionalFeature Containers`; manual verification | +| `/var/lib/azure-proxy-agent/keys` | `%SystemDrive%\WindowsAzure\ProxyAgent\Keys` | +| `/var/log/azure-proxy-agent` | `%SystemDrive%\WindowsAzure\ProxyAgent\Logs` | +| `/var/lib/azure-proxy-agent/rules` | `%SystemDrive%\WindowsAzure\ProxyAgent\Rules` | +| `/usr/sbin/azure-proxy-agent` | `%SystemDrive%\WindowsAzure\ProxyAgent\GuestProxyAgent\GuestProxyAgent.exe` | +| `python3 generate_report.py` | `Generate-Report.ps1` (pure PowerShell, no Python dependency) | diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 new file mode 100644 index 00000000..37798701 --- /dev/null +++ b/pentest/windows/Generate-Report.ps1 @@ -0,0 +1,390 @@ +# Generate-Report.ps1 — Windows port of pentest/linux/generate_report.py. +# +# Builds an HTML pen-test report from results\findings.tsv (and the latest +# results\url_diff.tsv run, if present). Pure PowerShell — no Python, no +# external dependencies. Output: results\report.html. + +[CmdletBinding()] +param([string] $Output) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force +Import-Module (Join-Path $PSScriptRoot 'TestCatalog.psm1') -Force + +if (-not $Output) { $Output = Join-Path $ResultsDir 'report.html' } + +# --- helpers ---------------------------------------------------------------- + +$PhasePatterns = @( + @{ Name = 'Phase 2 — Listener / DoS (A,G)'; Regex = '^(A\d|G\d)' } + @{ Name = 'Phase 3 — AuthN / AuthZ (B,C,P3)'; Regex = '^(B\d|C\d|P3)' } + @{ Name = 'Phase 4b — Local-file rules (IMDS)'; Regex = '^IMDS-S' } + @{ Name = 'Phase 4b — Local-file rules (WireServer)'; Regex = '^WS-S' } + @{ Name = 'Phase 4b — pre-flight'; Regex = '^PRE\b' } + @{ Name = 'Phase 5 — Filesystem / state (E,F,P5)'; Regex = '^(E\d|F\d|P5)' } +) + +function Get-PhaseFor { + param([string] $Id) + foreach ($p in $PhasePatterns) { + if ($Id -match $p.Regex) { return $p.Name } + } + return 'Other' +} + +function HtmlEncode { + param([string] $S) + if ($null -eq $S) { return '' } + return [System.Net.WebUtility]::HtmlEncode($S) +} + +function Get-Findings { + if (-not (Test-Path $Findings)) { return @() } + $rows = @() + foreach ($line in Get-Content -Path $Findings) { + if (-not $line) { continue } + $parts = $line -split "`t", 4 + while ($parts.Count -lt 4) { $parts += '' } + $rows += [pscustomobject]@{ + Ts = $parts[0] + Id = $parts[1] + Status = $parts[2].ToUpper() + Msg = $parts[3] + Phase = Get-PhaseFor $parts[1] + } + } + return $rows +} + +function Get-UrlDiffLatest { + if (-not (Test-Path $UrlDiffTsv)) { return @() } + $rows = @() + foreach ($line in Get-Content -Path $UrlDiffTsv) { + if ($line) { $rows += , ($line -split "`t") } + } + if (-not $rows) { return @() } + $lastTs = $rows[-1][0] + return $rows | Where-Object { $_[0] -eq $lastTs } +} + +# --- detail block (per-row design / repro / fix) ---------------------------- + +function Get-DetailHtml { + param([pscustomobject] $Row) + $info = Get-CatalogEntry -TestId $Row.Id + if (-not $info) { + return "no catalog entry for this ID" + } + $parts = New-Object System.Collections.Generic.List[string] + + if ($info.Title) { + $parts.Add("
$(HtmlEncode $info.Title)
") + } + function Section($label, $body, $mono) { + $cls = if ($mono) { 'mono' } else { '' } + return "
$(HtmlEncode $label)
" + + "
$(HtmlEncode $body)
" + } + if ($info.Design) { $parts.Add( (Section 'Design' $info.Design $false) ) } + if ($info.Automation) { $parts.Add( (Section 'Automation' $info.Automation $false) ) } + if ($info.ReproScript) { $parts.Add( (Section 'Repro (script)' $info.ReproScript $true ) ) } + if ($info.ReproManual) { $parts.Add( (Section 'Repro (manual)' $info.ReproManual $true ) ) } + if ($Row.Status -eq 'FAIL' -and $info.Fix) { + $parts.Add("
Suggested fix
" + + "
$(HtmlEncode $info.Fix)
") + } + return ($parts -join '') +} + +function Get-RowHtml { + param([pscustomobject] $Row, [bool] $WithPhaseCol) + $cls = switch ($Row.Status) { 'FAIL' { 'fail' } 'PASS' { 'pass' } default { 'info' } } + $sev = if ($Row.Status -eq 'FAIL' -and $WithPhaseCol) { 'High' } else { $Row.Status } + $openAttr = if ($Row.Status -eq 'FAIL') { ' open' } else { '' } + $cells = New-Object System.Collections.Generic.List[string] + $cells.Add("$(HtmlEncode $Row.Ts)") + $cells.Add("$(HtmlEncode $Row.Id)") + if ($WithPhaseCol) { $cells.Add("$(HtmlEncode $Row.Phase)") } + $cells.Add("$(HtmlEncode $sev)") + $cells.Add("
$(HtmlEncode $Row.Msg)
" + + "
details" + + "
$(Get-DetailHtml -Row $Row)
") + return '' + ($cells -join '') + '' +} + +# --- CSS / JS (kept in lockstep with linux/generate_report.py) -------------- + +$Css = @' +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; } +h1 { margin: 0 0 4px 0; font-size: 24px; } +.sub { color: #57606a; margin-bottom: 24px; font-size: 13px; } +.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; } +.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 16px 20px; flex: 1 1 140px; } +.card .num { font-size: 28px; font-weight: 700; } +.card .lbl { font-size: 12px; color: #57606a; text-transform: uppercase; letter-spacing: .04em; } +.card.pass .num { color: #1a7f37; } +.card.fail .num { color: #cf222e; } +.card.info .num { color: #9a6700; } +section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; margin-bottom: 20px; } +section > h2 { margin: 0; padding: 12px 16px; border-bottom: 1px solid #d0d7de; font-size: 16px; + background: #f6f8fa; border-radius: 8px 8px 0 0; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eaeef2; vertical-align: top; } +th { background: #f6f8fa; font-weight: 600; color: #57606a; } +tr:last-child > td { border-bottom: none; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; } +.badge.pass { background: #dafbe1; color: #116329; } +.badge.fail { background: #ffebe9; color: #82071e; } +.badge.info { background: #fff8c5; color: #7d4e00; } +.id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; margin-bottom: 4px; } +.ts { color: #57606a; white-space: nowrap; font-variant-numeric: tabular-nums; } +details > summary { cursor: pointer; color: #0969da; font-size: 12px; list-style: none; } +details > summary::-webkit-details-marker { display: none; } +details > summary::before { content: '▸ '; transition: transform .15s; } +details[open] > summary::before { content: '▾ '; } +.detbox { display: flex; flex-direction: column; gap: 6px; padding: 8px 0 4px 0; } +.dettitle { font-weight: 600; color: #24292f; } +.det { display: flex; gap: 10px; flex-wrap: wrap; } +.detlbl { flex: 0 0 110px; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #57606a; padding-top: 2px; } +.detval { flex: 1 1 320px; font-size: 12px; } +.detval.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; + background: #f6f8fa; border: 1px solid #eaeef2; border-radius: 4px; padding: 6px 8px; } +.det.fix .detlbl { color: #cf222e; } +.det.fix .detval { background: #ffebe9; border: 1px solid #ffcecb; border-radius: 4px; padding: 6px 8px; color: #82071e; white-space: pre-wrap; } +.nodet { color: #8c959f; font-size: 12px; font-style: italic; } +.legend { font-size: 12px; color: #57606a; margin-bottom: 12px; } +.footer { color: #57606a; font-size: 12px; margin-top: 24px; text-align: center; } +.meta { font-size: 12px; color: #57606a; padding: 8px 16px 0; } +.toolbar { display: flex; gap: 8px; align-items: center; padding: 6px 16px; border-bottom: 1px solid #eaeef2; + background: #fafbfc; font-size: 12px; } +.toolbar button { font: inherit; padding: 3px 10px; border: 1px solid #d0d7de; border-radius: 6px; + background: #fff; color: #24292f; cursor: pointer; } +.toolbar button:hover { background: #eef1f4; } +@media (prefers-color-scheme: dark) { + body { background: #0d1117; color: #c9d1d9; } + .card, section { background: #161b22; border-color: #30363d; } + section > h2, th, .toolbar { background: #161b22; color: #8b949e; border-color: #30363d; } + td { border-color: #21262d; } + .badge.pass { background: #033a16; color: #56d364; } + .badge.fail { background: #3c0a13; color: #ff7b72; } + .badge.info { background: #3a2d04; color: #e3b341; } + details > summary { color: #58a6ff; } + .detval.mono { background: #0d1117; border-color: #30363d; } + .det.fix .detval { background: #3c0a13; border-color: #5a1018; color: #ff7b72; } + .toolbar button { background: #21262d; color: #c9d1d9; border-color: #30363d; } + .toolbar button:hover { background: #30363d; } +} +'@ + +$Js = @' +function expandIn(el, open){ el.querySelectorAll('details.row').forEach(d => d.open = open); } +'@ + +# --- main build ------------------------------------------------------------- + +$findings = Get-Findings +$urlRows = Get-UrlDiffLatest + +if (-not $findings) { + Write-Error "No findings to report (no rows in $Findings)." + exit 1 +} + +$SetupIds = @('CFG', 'PRE') +$setupRows = $findings | Where-Object { $SetupIds -contains $_.Id } +$testRows = $findings | Where-Object { $SetupIds -notcontains $_.Id } + +$total = $testRows.Count +$nPass = ($testRows | Where-Object Status -eq 'PASS').Count +$nFail = ($testRows | Where-Object Status -eq 'FAIL').Count +$nInfo = ($testRows | Where-Object Status -eq 'INFO').Count + +$failRows = $testRows | Where-Object Status -eq 'FAIL' +$infoRows = $testRows | Where-Object Status -eq 'INFO' + +$byPhase = @{} +foreach ($p in $PhasePatterns) { $byPhase[$p.Name] = @() } +$byPhase['Other'] = @() +foreach ($r in $testRows) { $byPhase[$r.Phase] += $r } +$phaseOrder = ($PhasePatterns.Name + 'Other') | Where-Object { $byPhase[$_].Count -gt 0 } + +$generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss UTC') +$hostName = $env:COMPUTERNAME +$osinfo = "Windows $((Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue).Version) ($([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture))" + +$out = New-Object System.Collections.Generic.List[string] +$out.Add('') +$out.Add('') +$out.Add('GPA Pen-Test Report (Windows)') +$out.Add("") +$out.Add('

Guest Proxy Agent — Pen-Test Report (Windows)

') +$out.Add("
Generated $generated · host $(HtmlEncode $hostName) · " + + "$(HtmlEncode $osinfo) · " + + 'design source: pentest/windows/DESIGN.md · catalog: pentest/windows/TestCatalog.psm1
') + +$out.Add('
') +$out.Add("
$total
Total tests
") +$out.Add("
$nPass
Pass
") +$out.Add("
$nFail
Fail
") +$out.Add("
$nInfo
Info
") +$out.Add('
') + +$out.Add('
Each row is expandable — click details for ' + + 'design, automation, manual repro, and (when failed) the suggested fix. ' + + 'FAIL rows are auto-expanded.
') + +# Run configuration / setup +$out.Add('

Run configuration & harness setup

') +$out.Add("
" + + 'Diagnostic rows emitted by the test harnesses themselves (poll interval, current identity, ' + + 'enabled targets, pre-flight checks). They describe how the suite was run and are ' + + 'not pen-test cases, so they are excluded from the counts above.
') +if (-not $setupRows) { + $out.Add("
No setup records.
") +} else { + $out.Add('' + + "" + + "") + foreach ($r in $setupRows) { + $cls = switch ($r.Status) { 'FAIL' {'fail'} 'PASS' {'pass'} default {'info'} } + $out.Add("" + + "" + + "" + + "") + } + $out.Add('
TimeIDStatusDetail
$(HtmlEncode $r.Ts)$(HtmlEncode $r.Id)$(HtmlEncode $r.Status)
$(HtmlEncode $r.Msg)
') +} +$out.Add('
') + +# Failures +$out.Add('

Failures requiring triage

') +$out.Add("
" + + "" + + "
") +if (-not $failRows) { + $out.Add("
No FAIL entries — all invariants held.
") +} else { + $out.Add('' + + "" + + '') + foreach ($r in $failRows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $true) ) } + $out.Add('
TimeTest IDPhaseSeverityDetail
') +} +$out.Add('
') + +# Informational +$out.Add('

Informational findings (no action required)

') +$out.Add("
" + + "" + + "
") +if (-not $infoRows) { + $out.Add("
No INFO entries.
") +} else { + $out.Add("
" + + 'INFO rows record observations that are not failures: environment limitations ' + + '(e.g. low-IL probe could not be spawned), audit-mode probes, scenarios that require ' + + 'manual review, or by-design behavior that is logged for the record.
') + $out.Add('' + + "" + + '') + foreach ($r in $infoRows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $true) ) } + $out.Add('
TimeTest IDPhaseSeverityDetail
') +} +$out.Add('
') + +# Per-phase +foreach ($phase in $phaseOrder) { + $rows = $byPhase[$phase] + $cP = ($rows | Where-Object Status -eq 'PASS').Count + $cF = ($rows | Where-Object Status -eq 'FAIL').Count + $cI = ($rows | Where-Object Status -eq 'INFO').Count + $out.Add('
') + $out.Add("

$(HtmlEncode $phase) " + + "" + + "$cP pass · $cF fail · $cI info

") + $out.Add("
" + + "" + + "
") + $out.Add('' + + "" + + '') + foreach ($r in $rows) { $out.Add( (Get-RowHtml -Row $r -WithPhaseCol $false) ) } + $out.Add('
TimeTest IDStatusDetail
') +} + +# URL diff appendix +if ($urlRows) { + $out.Add('

Phase 4 — URL/encoding differential (latest run)

') + $out.Add('
' + + 'Compares response codes for canonical vs encoded variants of the same path. ' + + 'DIFF rows warrant review.
' + + 'Repro (script): .\\pentest\\windows\\Phase4-RulesFuzz.ps1
' + + '
') + $out.Add('' + + '' + + '') + foreach ($r in $urlRows) { + $verdict = if ($r.Count -gt 3) { $r[3] } else { '' } + $cls = if ($verdict.Trim().ToUpper() -eq 'DIFF') { 'fail' } else { 'pass' } + $out.Add('' + + "" + + "" + + "" + + "" + + "" + + '') + } + $out.Add('
TimeVariantStatusVerdictPath
$(HtmlEncode $r[0])$(HtmlEncode $r[1])$(HtmlEncode $r[2])$(HtmlEncode $verdict)$(HtmlEncode ($r[4]))
') +} + +# Cheat sheet +$out.Add('

How to reproduce — cheat sheet

') +$out.Add('

Run from the workspace root in an elevated PowerShell:

') +$cheat = @" +# All phases plus report (one shot): +.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings + +# Individual phases: +.\pentest\windows\Phase2-Listener.ps1 # A1-A5, G1 +.\pentest\windows\Phase3-AuthN-AuthZ.ps1 # B1, B3, B4, C1, C5, C7 (+ pktmon capture) +.\pentest\windows\Phase4-RulesFuzz.ps1 # URL encoding differential +.\pentest\windows\Phase4b-LocalRules.ps1 # local-file rules (needs useLocalFileRules=true) +.\pentest\windows\Phase4b-LocalRules.ps1 -Target imds +.\pentest\windows\Phase4b-LocalRules.ps1 -Scenarios IMDS-S6-encoding-bypass,WS-S6-encoding-bypass +.\pentest\windows\Phase5-FileSystemAudit.ps1 # E1, F1 (+ netsh wfp snapshot) + +# Regenerate this report: +.\pentest\windows\Generate-Report.ps1 +"@ +$out.Add("
" +
+         (HtmlEncode $cheat) + '
') +$out.Add('
') + +# Artifacts +$out.Add('

Artifacts

    ') +foreach ($p in @($Findings, $UrlDiffTsv, $Phase4bLog)) { + if (Test-Path $p) { + $sz = (Get-Item $p).Length + $out.Add("
  • $(HtmlEncode $p) ($sz bytes)
  • ") + } +} +foreach ($p in (Get-ChildItem -Path $ResultsDir -Filter 'phase3-*.etl' -ErrorAction SilentlyContinue | Sort-Object Name)) { + $out.Add("
  • $(HtmlEncode $p.FullName) ($($p.Length) bytes)
  • ") +} +foreach ($p in (Get-ChildItem -Path $ResultsDir -Filter 'wfp_*.txt' -ErrorAction SilentlyContinue | Sort-Object Name)) { + $out.Add("
  • $(HtmlEncode $p.FullName) ($($p.Length) bytes)
  • ") +} +$out.Add('
') + +$out.Add('') +$out.Add('') + +Set-Content -Path $Output -Value ($out -join "`n") -Encoding UTF8 + +Write-Host ("Wrote {0} ({1} test entries: {2} pass, {3} fail, {4} info; {5} setup rows)" -f ` + $Output, $total, $nPass, $nFail, $nInfo, $setupRows.Count) diff --git a/pentest/windows/Phase2-Listener.ps1 b/pentest/windows/Phase2-Listener.ps1 new file mode 100644 index 00000000..6bdf00e2 --- /dev/null +++ b/pentest/windows/Phase2-Listener.ps1 @@ -0,0 +1,149 @@ +# Phase2-Listener.ps1 — Windows port of pentest/phase2_listener/run.sh. +# +# Scenarios A1, A2 (cross-host probe), A3 (malformed HTTP), A4 (CONNECT), +# A5 (open-proxy), G1 (connection burst). +# +# Run from an elevated PowerShell: +# powershell -ExecutionPolicy Bypass -File .\Phase2-Listener.ps1 + +param([switch] $SkipBurst) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# A1 — only 127.0.0.1:3080 should be open; nothing on external NICs. +$listening = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPort -eq $GpaListenerPort } +$onLoopback = $listening | Where-Object { $_.LocalAddress -in @('127.0.0.1','::1') } +$onExternal = $listening | Where-Object { $_.LocalAddress -notin @('127.0.0.1','::1','0.0.0.0','::') } +$onWildcard = $listening | Where-Object { $_.LocalAddress -in @('0.0.0.0','::') } + +if (-not $listening) { + Write-Finding A1 FAIL "no process listening on TCP $GpaListenerPort — is $GpaServiceName running?" +} elseif ($onExternal -or $onWildcard) { + $bad = ($listening | ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" }) -join ', ' + Write-Finding A1 FAIL "GPA listener bound to non-loopback address ($bad) — exposed to network" +} else { + Write-Finding A1 PASS "GPA listener bound only to loopback ($($onLoopback.Count) socket(s) on $GpaListenerPort)" +} + +# A1b — confirm loopback connect succeeds. +$tnc = Test-NetConnection -ComputerName 127.0.0.1 -Port $GpaListenerPort -InformationLevel Quiet -WarningAction SilentlyContinue +if ($tnc) { + Write-Finding A1b PASS "loopback TCP connect to $GpaListener OK" +} else { + Write-Finding A1b FAIL "cannot connect to $GpaListener" +} + +# A2 — cross-host: try every non-loopback IPv4 the box has, the listener must NOT answer. +$exposed = @() +$ifaceIps = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | + Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -ne '0.0.0.0' } +foreach ($ip in $ifaceIps) { + $r = Test-NetConnection -ComputerName $ip.IPAddress -Port $GpaListenerPort ` + -InformationLevel Quiet -WarningAction SilentlyContinue + if ($r) { $exposed += $ip.IPAddress } +} +if ($exposed) { + Write-Finding A2 FAIL "GPA listener answered on non-loopback IF: $($exposed -join ', ')" +} else { + Write-Finding A2 PASS "GPA listener does not answer on any external interface" +} + +# A3 — malformed HTTP: oversized header, slow-loris-style partial line. +function Send-RawTcp { + param([string] $Payload, [int] $ReadTimeoutMs = 3000) + $client = New-Object System.Net.Sockets.TcpClient + try { + $client.Connect('127.0.0.1', $GpaListenerPort) + $stream = $client.GetStream() + $stream.ReadTimeout = $ReadTimeoutMs + $bytes = [System.Text.Encoding]::ASCII.GetBytes($Payload) + $stream.Write($bytes, 0, $bytes.Length) + $buf = New-Object byte[] 4096 + try { + $n = $stream.Read($buf, 0, $buf.Length) + return [System.Text.Encoding]::ASCII.GetString($buf, 0, $n) + } catch { return '' } + } finally { $client.Close() } +} + +$bigHeader = "GET / HTTP/1.1`r`nHost: 127.0.0.1`r`nX-Big: " + ('A' * 65536) + "`r`n`r`n" +try { + $resp = Send-RawTcp $bigHeader + if ($resp -match '^HTTP/1\.[01] (4\d\d|5\d\d)') { + Write-Finding A3 PASS "oversized-header request rejected gracefully ($($resp.Split("`n")[0].Trim()))" + } elseif (-not $resp) { + Write-Finding A3 PASS "oversized-header request closed without crash (no response)" + } else { + Write-Finding A3 INFO "oversized-header response: $($resp.Split("`n")[0].Trim())" + } +} catch { + Write-Finding A3 INFO "raw socket test failed: $_" +} + +if (-not (Test-ServiceRunning $GpaServiceName)) { + Write-Finding A3-survive FAIL "$GpaServiceName not running after malformed-HTTP probe" +} else { + Write-Finding A3-survive PASS "$GpaServiceName still running after malformed-HTTP probe" +} + +# A4 — CONNECT must return 405 (no generic forward proxy). We send a raw +# `CONNECT example.com:443 HTTP/1.1` since Invoke-WebRequest doesn't expose +# the CONNECT method directly. +$connectReq = "CONNECT example.com:443 HTTP/1.1`r`nHost: example.com:443`r`n`r`n" +$resp = Send-RawTcp $connectReq +$firstLine = ($resp -split "`r?`n")[0] +if ($firstLine -match 'HTTP/1\.[01] 405') { + Write-Finding A4 PASS "CONNECT rejected with 405 ($firstLine)" +} elseif ($firstLine -match 'HTTP/1\.[01] (4\d\d|5\d\d)') { + Write-Finding A4 PASS "CONNECT rejected ($firstLine)" +} elseif ($firstLine -match 'HTTP/1\.[01] 2\d\d') { + Write-Finding A4 FAIL "CONNECT not refused — GPA may be acting as forward proxy ($firstLine)" +} else { + Write-Finding A4 FAIL "CONNECT response unexpected/empty: '$firstLine'" +} + +# A5 — open-proxy attempt: target an arbitrary external host through GPA's +# listener as if it were an HTTP forward proxy. +try { + $proxy = New-Object System.Net.WebProxy("http://127.0.0.1:$GpaListenerPort", $true) + $req = [System.Net.HttpWebRequest]::Create("http://example.com/") + $req.Proxy = $proxy + $req.Timeout = 5000 + try { + $r = $req.GetResponse() + $code = [int]$r.StatusCode + $r.Close() + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } + } + if ($code -in 200..299) { + Write-Finding A5 FAIL "open-proxy: GPA forwarded a request to example.com (code=$code)" + } else { + Write-Finding A5 PASS "open-proxy refused (code=$code)" + } +} catch { + Write-Finding A5 PASS "open-proxy attempt rejected: $_" +} + +# G1 — light connection burst, just confirm service stays up. +if (-not $SkipBurst) { + $errors = 0 + 1..200 | ForEach-Object { + try { + $c = New-Object System.Net.Sockets.TcpClient + $c.SendTimeout = 1000 + $c.Connect('127.0.0.1', $GpaListenerPort) + $c.Close() + } catch { $errors++ } + } + if (-not (Test-ServiceRunning $GpaServiceName)) { + Write-Finding G1 FAIL "$GpaServiceName died after 200-conn burst (errors=$errors)" + } else { + Write-Finding G1 PASS "$GpaServiceName survived 200-conn burst (errors=$errors)" + } +} + +Write-Host "" +Write-Host "Phase 2 complete. Findings: $Findings" diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 new file mode 100644 index 00000000..45248b56 --- /dev/null +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -0,0 +1,183 @@ +# Phase3-AuthN-AuthZ.ps1 — Windows port of pentest/phase3_authn_authz/run.sh. +# +# Scenarios B1, B1b (redirect-active proof), B3, B4, C1, C5-equivalent +# (AppContainer / WSL bypass note), C7 (alt IP-form parity). +# +# WindowsRedirector vs Linux eBPF: on Windows the redirect is implemented via +# WFP (Windows Filtering Platform) callouts in the GuestProxyAgent service, +# not eBPF cgroup attach. The behavioural invariant — "any in-guest connection +# to 169.254.169.254:80 / 168.63.129.16:80 / :32526 must land on the local GPA +# listener" — is the same, so the probes (B1b, C7) test that invariant +# end-to-end without caring how it's implemented. + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# ---------- Optional packet capture (P3-cap) --------------------------------- +$pktmonStarted = $false +if (Get-Command pktmon -ErrorAction SilentlyContinue) { + try { + $cap = Join-Path $ResultsDir ("phase3-windows-{0}.etl" -f (Get-Date -Format yyyyMMddTHHmmssZ)) + & pktmon filter remove | Out-Null + & pktmon filter add -i $ImdsIp | Out-Null + & pktmon filter add -i $WireServerIp | Out-Null + & pktmon filter add -p $GpaListenerPort | Out-Null + & pktmon start --etw -f $cap | Out-Null + $pktmonStarted = $true + Write-Finding P3-cap INFO "pktmon capture started → $cap" + } catch { + Write-Finding P3-cap INFO "could not start pktmon: $_" + } +} else { + Write-Finding P3-cap INFO "pktmon not available; skipping packet capture" +} + +# ---------- B1a — IMDS reachable through GPA path (canonical baseline) ------- +$canonical = Get-HttpStatus -Url $ImdsUrl -Headers $ImdsHeaders -TimeoutSec 8 +if ($canonical.Code -eq 200) { + Write-Finding B1a PASS "IMDS reachable through GPA-redirected path (200)" +} else { + Write-Finding B1a FAIL "IMDS call failed code=$($canonical.Code)" +} +$canonicalSha = Get-Sha256 $canonical.Body + +# ---------- B1b — kernel-side proof the redirect is in effect ---------------- +# Open a TCP connection to the IMDS IP and check what the local TCP stack +# reports as the remote endpoint. If the WFP redirect is active, the kernel +# rewrites the destination to 127.0.0.1:3080 BEFORE the SYN goes out, and +# Get-NetTCPConnection will show that as the established peer. +try { + $client = New-Object System.Net.Sockets.TcpClient + $client.Connect($ImdsIp, 80) + $localEp = $client.Client.LocalEndPoint + $remoteEp = $client.Client.RemoteEndPoint + # Match by local port for our PID. + $own = Get-NetTCPConnection -LocalPort $localEp.Port -ErrorAction SilentlyContinue | + Where-Object { $_.State -eq 'Established' } | Select-Object -First 1 + $client.Close() + $kernelPeer = if ($own) { "$($own.RemoteAddress):$($own.RemotePort)" } else { "$remoteEp" } + + if ($kernelPeer -match "127\.0\.0\.1:$GpaListenerPort" -or $kernelPeer -match ":$GpaListenerPort$") { + Write-Finding B1b PASS "kernel reports our IMDS connection landed on the GPA listener (WFP redirect active): $kernelPeer" + } elseif ($kernelPeer -match [regex]::Escape($ImdsIp)) { + Write-Finding B1b FAIL "kernel reports our IMDS connection went DIRECTLY to $ImdsIp — WFP redirect bypassed: $kernelPeer" + } else { + Write-Finding B1b INFO "kernel-side peer for IMDS connection: $kernelPeer (manual review)" + } +} catch { + Write-Finding B1b INFO "could not open TCP to ${ImdsIp}:80 (no route or blocked): $_" +} + +# ---------- B3 — key files must not be readable by non-Administrators -------- +if (Test-Path $GpaKeyDir) { + $bad = @() + Get-ChildItem -Path $GpaKeyDir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { + try { + $acl = Get-Acl $_.FullName + foreach ($ace in $acl.Access) { + $sid = $ace.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]) + # Flag any non-system principal with read access: + # Users (S-1-5-32-545), Authenticated Users (S-1-5-11), + # Everyone (S-1-1-0), Anonymous (S-1-5-7). + if ($sid.Value -in @('S-1-5-32-545','S-1-5-11','S-1-1-0','S-1-5-7') ` + -and ($ace.FileSystemRights -match 'Read|FullControl|Modify') ` + -and $ace.AccessControlType -eq 'Allow') { + $bad += "$($_.FullName) grants $($ace.FileSystemRights) to $($ace.IdentityReference)" + } + } + } catch { } + } + if ($bad) { + Write-Finding B3 FAIL ("key files ACL too permissive: " + ($bad -join '; ')) + } else { + Write-Finding B3 PASS "key files in $GpaKeyDir restricted to admin/system principals" + } +} else { + Write-Finding B3 INFO "key dir $GpaKeyDir not present" +} + +# ---------- B4 — forged signature header must be ignored / overwritten ------- +$forgedHeaders = @{ + 'Metadata' = 'true' + 'x-ms-azure-signature' = 'AAAA-attacker-supplied' + 'x-ms-azure-time-tick' = '0' +} +$r = Get-HttpStatus -Url $ImdsUrl -Headers $forgedHeaders -TimeoutSec 8 +if ($r.Code -eq 200) { + Write-Finding B4 PASS "request with attacker-supplied signature still succeeded (GPA overwrote it; verify in pktmon capture)" +} else { + Write-Finding B4 INFO "request with forged signature got code=$($r.Code) (manual review)" +} + +# ---------- C1 — non-elevated WireServer call must be denied ----------------- +# Spawn a child PowerShell as the well-known low-privilege "NETWORK SERVICE" +# account isn't simple from PS without psexec; instead we run the probe under +# the current low-IL token. If the harness is being executed as Administrator, +# we fall back to recording INFO with a manual-repro hint. +function Invoke-AsLowIL { + param([string] $Url, [hashtable] $Headers) + # If the current token is already non-admin, just call. + if (-not (Test-Administrator)) { + return (Get-HttpStatus -Url $Url -Headers $Headers).Code + } + # Try to use 'runas /trustlevel:0x20000' (basic user) via cmd. + try { + $script = @" +`$ProgressPreference='SilentlyContinue' +try { (Invoke-WebRequest -UseBasicParsing -Uri '$Url' -Headers @{'x-ms-version'='2012-11-30'} -TimeoutSec 8).StatusCode } +catch { if (`$_.Exception.Response) { [int]`$_.Exception.Response.StatusCode } else { 0 } } +"@ + $tmp = [IO.Path]::GetTempFileName() + '.ps1' + Set-Content -Path $tmp -Value $script -Encoding UTF8 + $out = & cmd /c "runas /trustlevel:0x20000 ""powershell -NoProfile -ExecutionPolicy Bypass -File $tmp""" 2>&1 + Remove-Item $tmp -ErrorAction SilentlyContinue + # runas /trustlevel cannot return stdout reliably; treat as inconclusive. + return -1 + } catch { return -1 } +} +$c1code = Invoke-AsLowIL -Url $WireServerUrl -Headers $WireHeaders +switch ($c1code) { + {$_ -in 401,403} { Write-Finding C1 PASS "non-elevated WireServer call denied ($c1code)" } + 200 { Write-Finding C1 FAIL "non-elevated WireServer call SUCCEEDED ($c1code) — AuthZ bypass" } + -1 { Write-Finding C1 INFO "could not spawn low-IL probe; rerun script from a standard-user PowerShell to exercise C1" } + default { Write-Finding C1 INFO "non-elevated WireServer call code=$c1code (inconclusive)" } +} + +# ---------- C5-equivalent — workload outside the redirect scope -------------- +# On Windows the WFP redirect attaches at the system level, but the agent's +# scoping rules vary by version (per-process, per-app-container, per-silo). +# Best-effort probe: if the Windows Sandbox or Server Container feature is +# enabled, note that as a manual-review item for the operator. +$silo = Get-WindowsOptionalFeature -Online -FeatureName Containers -ErrorAction SilentlyContinue +if ($silo -and $silo.State -eq 'Enabled') { + Write-Finding C5 INFO "Windows Containers feature enabled — manually verify a container can still reach IMDS only through GPA" +} else { + Write-Finding C5 INFO "Windows Containers feature not enabled; C5 (silo evasion) not applicable here" +} + +# ---------- C7 — alt IP-form parity vs canonical ----------------------------- +$alts = @( + "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01", + "http://2852039166/metadata/instance?api-version=2021-02-01" +) +foreach ($alt in $alts) { + $r = Get-HttpStatus -Url $alt -Headers $ImdsHeaders -TimeoutSec 6 + $altSha = Get-Sha256 $r.Body + if ($r.Code -eq $canonical.Code -and $altSha -eq $canonicalSha) { + Write-Finding "C7[$alt]" PASS "alt IP form parity with canonical (code=$($r.Code), body sha matches)" + } elseif ($r.Code -eq $canonical.Code) { + Write-Finding "C7[$alt]" INFO "same code=$($r.Code) but body sha differs (canonical=$canonicalSha alt=$altSha) — manual diff" + } elseif ($r.Code -eq 0 -or ($r.Code -ge 400 -and $r.Code -lt 500)) { + Write-Finding "C7[$alt]" PASS "alt IP form refused (code=$($r.Code)) — no bypass" + } else { + Write-Finding "C7[$alt]" FAIL "alt IP form returned code=$($r.Code), canonical=$($canonical.Code) — possible WFP/SSRF-filter bypass" + } +} + +# ---------- Stop pktmon, if started ------------------------------------------ +if ($pktmonStarted) { + & pktmon stop | Out-Null +} + +Write-Host "" +Write-Host "Phase 3 complete. Findings: $Findings" diff --git a/pentest/windows/Phase4-RulesFuzz.ps1 b/pentest/windows/Phase4-RulesFuzz.ps1 new file mode 100644 index 00000000..918d815c --- /dev/null +++ b/pentest/windows/Phase4-RulesFuzz.ps1 @@ -0,0 +1,83 @@ +# Phase4-RulesFuzz.ps1 — Windows port of pentest/linux/phase4_rules_fuzz/url_diff.py. +# +# Sends a matrix of equivalent URLs (different encodings of the same path) to +# the GPA-redirected IMDS endpoint and records the response code GPA hands back. +# A correctly-implemented rule engine should produce the same authorization +# decision for every variant; differences are potential bypasses. +# +# Output: appends one TSV row per variant to results\url_diff.tsv: +# \t\t\t\t\t +# +# Generate-Report.ps1 renders the latest run's rows in the report. + +param([string] $Out) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +if (-not $Out) { $Out = $UrlDiffTsv } + +$canonical = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + +$variants = [ordered]@{ + 'canonical' = $canonical + 'uppercase_pct' = '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'lowercase_pct' = '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'double_encoded' = '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/' + 'trailing_dot_path' = '/metadata/identity/oauth2/token./?api-version=2018-02-01&resource=https://management.azure.com/' + 'dot_segments' = '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' + 'param_injection' = '/metadata/identity/oauth2/token;x=1?api-version=2018-02-01&resource=https://management.azure.com/' + 'case_path' = '/Metadata/Identity/OAuth2/Token?api-version=2018-02-01&resource=https://management.azure.com/' + 'extra_slashes' = '//metadata///identity//oauth2//token?api-version=2018-02-01&resource=https://management.azure.com/' + 'fragment' = '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/#x' + 'unicode_dotless' = "/metadata/$([char]0x131)dentity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" +} + +function Send-Variant { + param([string] $Path) + # Use HttpWebRequest so we can pass paths with raw %-sequences without + # Invoke-WebRequest re-encoding them. + $url = "http://${ImdsIp}${Path}" + try { + $req = [System.Net.HttpWebRequest]::Create($url) + $req.Method = 'GET' + $req.Timeout = 8000 + $req.Headers.Add('Metadata', 'true') + $resp = $req.GetResponse() + $code = [int]$resp.StatusCode + $sr = New-Object IO.StreamReader($resp.GetResponseStream()) + $body = $sr.ReadToEnd() + $sr.Close(); $resp.Close() + $head = ($body.Substring(0, [Math]::Min(80, $body.Length))) -replace "[`r`n`t]", ' ' + return [pscustomobject]@{ Code = $code; Head = $head } + } catch [System.Net.WebException] { + $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { -1 } + $head = "$($_.Exception.GetType().Name): $($_.Exception.Message)" + $head = ($head.Substring(0, [Math]::Min(60, $head.Length))) -replace "[`r`n`t]", ' ' + return [pscustomobject]@{ Code = $code; Head = $head } + } catch { + return [pscustomobject]@{ Code = -1; Head = ($_.Exception.Message -replace "[`r`n`t]", ' ') } + } +} + +$baseline = Send-Variant $canonical +Write-Host ("baseline ({0}) for canonical path" -f $baseline.Code) + +$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') +$rows = @() +$diffs = 0 +foreach ($k in $variants.Keys) { + $r = Send-Variant $variants[$k] + $verdict = if ($r.Code -ne $baseline.Code) { 'DIFF' } else { 'same' } + if ($verdict -eq 'DIFF') { $diffs++ } + Write-Host (" {0,-18} status={1,-4} {2}" -f $k, $r.Code, $verdict) + $rows += "$ts`t$k`t$($r.Code)`t$verdict`t$($variants[$k])`t$($r.Head)" +} + +if ($Out) { + $dir = Split-Path -Parent $Out + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + Add-Content -Path $Out -Value $rows -Encoding UTF8 + Write-Host "" + Write-Host ("Wrote {0} rows to {1}; {2} differ from baseline." -f $rows.Count, $Out, $diffs) +} diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 new file mode 100644 index 00000000..dff4ad40 --- /dev/null +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -0,0 +1,597 @@ +# Phase4b-LocalRules.ps1 — Windows port of pentest/linux/phase4b_local_rules/run.py. +# +# Auto-runs the local-file authorization-rules scenario matrix against the +# Windows GuestProxyAgent. +# +# PRE-REQ: +# The HostGAPlugin / fabric must already be delivering a ruleId whose +# decoded JSON has useLocalFileRules:true (so the agent merges the files +# in %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ instead of ignoring them). +# The script verifies this at startup. +# +# Run from elevated PowerShell: +# .\Phase4b-LocalRules.ps1 [-Target imds|wireserver|both] [-Scenarios sid1,sid2] +# [-PollSeconds 15] + +[CmdletBinding()] +param( + [ValidateSet('imds', 'wireserver', 'both')] + [string] $Target = 'both', + [string[]] $Scenarios, + [int] $PollSeconds +) + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# --- environment ------------------------------------------------------------- + +$ImdsRulesFile = Join-Path $GpaRulesDir 'IMDS_Rules.json' +$WsRulesFile = Join-Path $GpaRulesDir 'WireServer_Rules.json' +$PollFallback = 15 +$SlackSeconds = 5 + +if (-not (Test-Administrator)) { + Write-Finding PRE FAIL 'Phase 4b must be run elevated (the Rules dir is admin/SYSTEM-only).' + exit 2 +} + +if (-not (Test-Path $GpaRulesDir)) { + try { + New-Item -ItemType Directory -Path $GpaRulesDir -Force -ErrorAction Stop | Out-Null + } catch { + Write-Finding PRE FAIL "Rules dir $GpaRulesDir does not exist and could not be created: $_" + exit 2 + } +} + +# Resolve poll interval from agent config if not overridden. +if (-not $PollSeconds -or $PollSeconds -le 0) { + $cfg = Join-Path $GpaInstallRoot 'GuestProxyAgent\GuestProxyAgent.json' + if (Test-Path $cfg) { + try { + $j = Get-Content -Raw $cfg | ConvertFrom-Json + if ($j.pollKeyStatusIntervalInSeconds) { + $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds + } + } catch { } + } + if (-not $PollSeconds) { + # Try the source-tree config as a last resort. + $repoCfg = Join-Path $RepoRoot 'proxy_agent\config\GuestProxyAgent.windows.json' + if (Test-Path $repoCfg) { + try { + $j = Get-Content -Raw $repoCfg | ConvertFrom-Json + if ($j.pollKeyStatusIntervalInSeconds) { + $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds + } + } catch { } + } + } + if (-not $PollSeconds) { $PollSeconds = $PollFallback } +} + +Write-Finding CFG INFO "poll interval = ${PollSeconds}s" + +# --- pre-flight: useLocalFileRules + effective remote mode ------------------- + +function Get-LatestAuthRulesSnapshot { + Get-ChildItem -Path $GpaLogDir -Filter 'AuthorizationRules_*.json' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 +} + +function Assert-UseLocalFileRules { + $snap = Get-LatestAuthRulesSnapshot + if (-not $snap) { + Write-Finding PRE FAIL "no AuthorizationRules_*.json under $GpaLogDir; is GuestProxyAgent running?" + exit 2 + } + $text = Get-Content -Raw $snap.FullName + if ($text -notmatch 'useLocalFileRules-true') { + Write-Finding PRE FAIL ("useLocalFileRules-true NOT present in $($snap.Name); " + + 'fabric is delivering plain rule ids — local rules will be ignored. ' + + 'Enable useLocalFileRules from the control plane and retry.') + exit 2 + } + # Effective remote mode must be Enforce, otherwise deny scenarios silently 200. + $modes = @{} + try { + $j = $text | ConvertFrom-Json + foreach ($t in @('imds', 'wireserver')) { + $m = $null + if ($j.computedRules -and $j.computedRules.$t) { $m = $j.computedRules.$t.mode } + if (-not $m -and $j.inputRules -and $j.inputRules.$t) { $m = $j.inputRules.$t.mode } + if ($m) { $modes[$t] = $m } + } + } catch { } + $nonEnforce = @{} + foreach ($k in $modes.Keys) { + if ($modes[$k].ToLower() -ne 'enforce') { $nonEnforce[$k] = $modes[$k] } + } + if ($nonEnforce.Count -gt 0) { + $detail = ($nonEnforce.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' + Write-Finding PRE FAIL ("effective remote mode is not Enforce ($detail); " + + 'deny scenarios will be silently audited and report bogus FAILs. ' + + 'Switch the fabric/HostGAPlugin rule mode to Enforce and retry.') + exit 2 + } + $modeStr = ($modes.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' + Write-Finding PRE PASS "useLocalFileRules-true confirmed in $($snap.Name); effective remote mode={$modeStr}" +} + +Assert-UseLocalFileRules + +# --- identity captured at probe-time ---------------------------------------- +# Probes are sent from this PowerShell process. The matcher AND-matches every +# Some(_) field on Identity (None == wildcard). To avoid false-alarm 403s in +# the non-S9 scenarios we pin only userName + groupName here. The S9 scenarios +# use a separate, exePath-pinned identity built inline. + +$me = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$Identity = @{ + name = 'selfAdmin' + userName = ($me.Name -replace '.*\\', '') + groupName = 'Administrators' +} +Write-Finding CFG INFO "current identity: user=$($Identity.userName) group=$($Identity.groupName)" +Write-Finding CFG INFO "targets: $Target" + +# --- scenario data ---------------------------------------------------------- + +class Probe { + [string] $Name + [string] $Path + [object] $Expected # int or int[] + [string] $Method = 'GET' + Probe([string]$n, [string]$p, [object]$e) { + $this.Name = $n; $this.Path = $p; $this.Expected = $e + } +} + +class Scenario { + [string] $Sid + [string] $TargetName # 'imds' | 'wireserver' + [string] $Description + [object] $Rules # hashtable or raw string + [Probe[]] $Probes + [bool] $Raw = $false +} + +function New-Scenario { + param( + [string] $Sid, [string] $TargetName, [string] $Description, + [object] $Rules, [Probe[]] $Probes, [switch] $Raw + ) + $s = [Scenario]::new() + $s.Sid = $Sid; $s.TargetName = $TargetName; $s.Description = $Description + $s.Rules = $Rules; $s.Probes = $Probes; $s.Raw = [bool]$Raw + return $s +} + +function Build-ImdsScenarios { + param([hashtable] $Identity) + $pfx = 'IMDS' + $scn = @() + + # S1 + $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName imds ` + -Description 'mode=disabled, defaultAccess=allow → no enforcement, baseline 200s' ` + -Rules @{ defaultAccess='allow'; mode='disabled'; id='pentest-s1'; rules=@{} } ` + -Probes @( + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 200) + ) + + # S2 + $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName imds ` + -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` + -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-s2'; rules=@{} } ` + -Probes @( + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions', '/metadata/versions', 403) + ) + + # S3 — not testable from local rules (mode is honored only from remote). + Write-Finding "$pfx-S3-audit-deny-empty" INFO ('audit-mode behavior is not testable via local rules: ' + + 'merge_authorization_item() takes mode from remote rules only (see proxy_agent/src/key_keeper/local_rules.rs).') + + # S4 + $scn += New-Scenario -Sid "$pfx-S4-allow-one-path" -TargetName imds ` + -Description 'enforce + deny, allow only /metadata/instance for current identity' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s4' + rules=@{ + privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_instance'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token_denied', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S5 + $bogus = $Identity.Clone(); $bogus.name='nobodyId'; $bogus.userName='nosuchuser_xyz' + $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName imds ` + -Description 'enforce + deny, allow path /metadata/instance for a non-matching user' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s5' + rules=@{ + privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role='r_instance'; identities=@($bogus.name) } ) + } + } ` + -Probes @( [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403) ) + + # S6 + $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName imds ` + -Description 'enforce + deny; allow /metadata/instance; test %2F-encoded slash variants of token path' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s6' + rules=@{ + privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_instance'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('token_uppercase_pct', '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_lowercase_pct', '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_double_encoded', '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_question_encoded', '/metadata/identity/oauth2/token%3Fapi-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_dot_segments', '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', @(403, 404)) + ) + + # S7 + $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName imds ` + -Description 'enforce + deny; allow /metadata/instance ONLY with api-version=2021-02-01' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s7' + rules=@{ + privileges = @( @{ name='p_instance_q'; path='/metadata/instance'; queryParameters=@{ 'api-version'='2021-02-01' } } ) + roles = @( @{ name='r_q'; privileges=@('p_instance_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_q'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('matching_version', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('missing_version', '/metadata/instance', 403), + [Probe]::new('wrong_version', '/metadata/instance?api-version=2017-04-02', 403) + ) + + # S8 + $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName imds ` + -Description 'enforce + deny; identity matches by groupName only' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s8' + rules=@{ + privileges = @( @{ name='p_any'; path='/metadata/' } ) + roles = @( @{ name='r_any'; privileges=@('p_any') } ) + identities = @( @{ name='adminGroup'; groupName=$Identity.groupName } ) + roleAssignments = @( @{ role='r_any'; identities=@('adminGroup') } ) + } + } ` + -Probes @( [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) + + # S9 — caller is PowerShell; allow only that exePath. Probe sent via this + # process is allowed; the harness then issues an extra probe via curl.exe + # (shipped with Windows 10/11) which must be denied. + $psExe = (Get-Process -Id $PID).Path + $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName imds ` + -Description "enforce + deny; identity restricts to exePath=$psExe" ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s9' + rules=@{ + privileges = @( @{ name='p_any'; path='/metadata/' } ) + roles = @( @{ name='r_any'; privileges=@('p_any') } ) + identities = @( @{ name='psOnly'; exePath=$psExe } ) + roleAssignments = @( @{ role='r_any'; identities=@('psOnly') } ) + } + } ` + -Probes @( [Probe]::new('powershell_caller_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) + + # S10 — malformed JSON + $scn += New-Scenario -Sid "$pfx-S10-malformed-json" -TargetName imds ` + -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` + -Rules '{ this is not valid json' -Raw ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + return $scn +} + +function Build-WireServerScenarios { + param([hashtable] $Identity) + $pfx = 'WS' + $scn = @() + + $GOALSTATE = '/machine/?comp=goalstate' + $SHARED = '/machine/?comp=sharedConfig' + $HOSTING = '/machine/?comp=hostingenvironmentconfig' + $CERTS = '/machine/?comp=certificates' + $EXTCONFIG = '/machine/?comp=extensionsConfig' + $VERSIONS = '/?comp=versions' + + $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName wireserver ` + -Description 'mode=disabled, defaultAccess=allow → baseline 200s' ` + -Rules @{ defaultAccess='allow'; mode='disabled'; id='pentest-ws-s1'; rules=@{} } ` + -Probes @( + [Probe]::new('goalstate', $GOALSTATE, 200), + [Probe]::new('versions', $VERSIONS, 200) + ) + + $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName wireserver ` + -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` + -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-ws-s2'; rules=@{} } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403), + [Probe]::new('shared_denied', $SHARED, 403) + ) + + Write-Finding "$pfx-S3-audit-deny-empty" INFO ('audit-mode behavior is not testable via local rules: ' + + 'merge_authorization_item() takes mode from remote rules only (see proxy_agent/src/key_keeper/local_rules.rs).') + + $scn += New-Scenario -Sid "$pfx-S4-allow-goalstate-only" -TargetName wireserver ` + -Description 'enforce + deny; allow only /machine/ with comp=goalstate' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s4' + rules=@{ + privileges = @( @{ name='p_goalstate'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) + roles = @( @{ name='r_goalstate'; privileges=@('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_goalstate'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_allowed', $GOALSTATE, 200), + [Probe]::new('shared_denied', $SHARED, 403), + [Probe]::new('hosting_denied', $HOSTING, 403), + [Probe]::new('certs_denied', $CERTS, 403), + [Probe]::new('extconfig_denied', $EXTCONFIG, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $bogus = $Identity.Clone(); $bogus.name='nobodyId'; $bogus.userName='nosuchuser_xyz' + $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName wireserver ` + -Description 'enforce + deny; allow goalstate but bound to non-matching user' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s5' + rules=@{ + privileges = @( @{ name='p_machine'; path='/machine/' } ) + roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role='r_machine'; identities=@($bogus.name) } ) + } + } ` + -Probes @( [Probe]::new('goalstate_denied', $GOALSTATE, 403) ) + + $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName wireserver ` + -Description 'enforce + deny; allow only goalstate; test encoded/path-tricks for /machine/?comp=certificates' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s6' + rules=@{ + privileges = @( @{ name='p_goalstate'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) + roles = @( @{ name='r_goalstate'; privileges=@('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_goalstate'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('certs_uppercase_pct', '/machine%2F?comp=certificates', 403), + [Probe]::new('certs_question_encoded', '/machine/%3Fcomp=certificates', 403), + [Probe]::new('certs_dot_segments', '/machine/./../machine/?comp=certificates', @(403, 404)), + [Probe]::new('certs_extra_slashes', '//machine///?comp=certificates', 403), + [Probe]::new('certs_value_case', '/machine/?comp=Certificates', 403), + [Probe]::new('goalstate_value_case_should_match', '/machine/?comp=GOALSTATE', 200) + ) + + $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName wireserver ` + -Description 'enforce + deny; allow /machine/ ONLY with comp=goalstate' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s7' + rules=@{ + privileges = @( @{ name='p_goalstate_q'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) + roles = @( @{ name='r_q'; privileges=@('p_goalstate_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_q'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('matching_comp', $GOALSTATE, 200), + [Probe]::new('missing_comp', '/machine/', 403), + [Probe]::new('wrong_comp', '/machine/?comp=hostingenvironmentconfig', 403), + [Probe]::new('extra_param_ok', '/machine/?comp=goalstate&incarnation=1', @(200, 400)) + ) + + $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName wireserver ` + -Description 'enforce + deny; identity matches by groupName only' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s8' + rules=@{ + privileges = @( @{ name='p_machine'; path='/machine/' } ) + roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) + identities = @( @{ name='adminGroup'; groupName=$Identity.groupName } ) + roleAssignments = @( @{ role='r_machine'; identities=@('adminGroup') } ) + } + } ` + -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) + + $psExe = (Get-Process -Id $PID).Path + $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName wireserver ` + -Description "enforce + deny; identity restricts to exePath=$psExe" ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s9' + rules=@{ + privileges = @( @{ name='p_machine'; path='/machine/' } ) + roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) + identities = @( @{ name='psOnly'; exePath=$psExe } ) + roleAssignments = @( @{ role='r_machine'; identities=@('psOnly') } ) + } + } ` + -Probes @( [Probe]::new('powershell_caller_allowed', $GOALSTATE, 200) ) + + $scn += New-Scenario -Sid "$pfx-S10-malformed-json" -TargetName wireserver ` + -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` + -Rules '{ this is not valid json' -Raw ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + return $scn +} + +# --- HTTP probe -------------------------------------------------------------- + +function Send-Probe { + param([string] $TargetName, [string] $Method, [string] $Path) + $hostHeader = if ($TargetName -eq 'imds') { $ImdsIp } else { $WireServerIp } + $defaultHeaders = if ($TargetName -eq 'imds') { @{ 'Metadata' = 'true' } } else { @{ 'x-ms-version' = '2012-11-30' } } + $url = "http://${hostHeader}${Path}" + try { + $req = [System.Net.HttpWebRequest]::Create($url) + $req.Method = $Method + $req.Timeout = 8000 + foreach ($k in $defaultHeaders.Keys) { $req.Headers.Add($k, $defaultHeaders[$k]) } + $resp = $req.GetResponse() + $code = [int]$resp.StatusCode + $resp.Close() + return $code + } catch [System.Net.WebException] { + if ($_.Exception.Response) { return [int]$_.Exception.Response.StatusCode } + return -1 + } catch { return -1 } +} + +# --- file management -------------------------------------------------------- + +function Backup-RulesDir { + $bak = @{} + foreach ($p in @($ImdsRulesFile, $WsRulesFile)) { + if (Test-Path $p) { + $b = "$p.pentest-bak.$([int][double]::Parse((Get-Date -UFormat %s)))" + Copy-Item -Path $p -Destination $b -Force + $bak[$p] = $b + } + } + return $bak +} + +function Restore-RulesDir { + param([hashtable] $Backups) + foreach ($p in @($ImdsRulesFile, $WsRulesFile)) { + if (Test-Path $p) { Remove-Item -Path $p -Force -ErrorAction SilentlyContinue } + } + foreach ($orig in $Backups.Keys) { + Move-Item -Path $Backups[$orig] -Destination $orig -Force + } +} + +function Write-Rules { + param([string] $Path, [object] $Doc, [bool] $Raw) + $tmp = "$Path.tmp" + if ($Raw) { + Set-Content -Path $tmp -Value $Doc -NoNewline -Encoding UTF8 + } else { + $json = $Doc | ConvertTo-Json -Depth 10 + Set-Content -Path $tmp -Value $json -NoNewline -Encoding UTF8 + } + Move-Item -Path $tmp -Destination $Path -Force +} + +# --- runner ----------------------------------------------------------------- + +function Wait-ForRefresh { + $delay = $PollSeconds + $SlackSeconds + Write-Host " ... waiting ${delay}s for rule refresh ..." + Start-Sleep -Seconds $delay +} + +function Invoke-CurlExe { + param([string] $TargetName, [string] $Path) + $curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source + if (-not $curl) { return -1 } + $hostHeader = if ($TargetName -eq 'imds') { $ImdsIp } else { $WireServerIp } + $hdr = if ($TargetName -eq 'imds') { 'Metadata: true' } else { 'x-ms-version: 2012-11-30' } + $url = "http://${hostHeader}${Path}" + try { + $out = & $curl -sS -o NUL -w '%{http_code}' --max-time 8 -H $hdr $url 2>$null + return [int]$out + } catch { return -1 } +} + +function Invoke-Scenario { + param([Scenario] $Scenario) + Write-Host "" + Write-Host ("=== {0}: {1}" -f $Scenario.Sid, $Scenario.Description) + $rulesPath = if ($Scenario.TargetName -eq 'imds') { $ImdsRulesFile } else { $WsRulesFile } + Write-Rules -Path $rulesPath -Doc $Scenario.Rules -Raw $Scenario.Raw + Wait-ForRefresh + + $passes = 0; $fails = 0 + foreach ($p in $Scenario.Probes) { + $actual = Send-Probe $Scenario.TargetName $p.Method $p.Path + $ok = if ($p.Expected -is [array]) { $p.Expected -contains $actual } else { $actual -eq $p.Expected } + $expectedStr = if ($p.Expected -is [array]) { '[' + ($p.Expected -join ',') + ']' } else { "$($p.Expected)" } + $shortPath = if ($p.Path.Length -gt 80) { $p.Path.Substring(0, 80) } else { $p.Path } + Write-Finding "$($Scenario.Sid)/$($p.Name)" ($(if ($ok) {'PASS'} else {'FAIL'})) ` + "expected=$expectedStr actual=$actual path=$shortPath" + if ($ok) { $passes++ } else { $fails++ } + } + + # Extra negative probe for S9: invoke via curl.exe so the agent sees a + # different exePath; the rule must deny it. + if ($Scenario.Sid -like '*S9-exepath-identity') { + $defaultPath = if ($Scenario.TargetName -eq 'imds') { '/metadata/instance?api-version=2021-02-01' } else { '/machine/?comp=goalstate' } + $actual = Invoke-CurlExe $Scenario.TargetName $defaultPath + $ok = ($actual -eq 403) + Write-Finding "$($Scenario.Sid)/curl_caller_denied" ($(if ($ok) {'PASS'} else {'FAIL'})) ` + "expected=403 actual=$actual (curl.exe invocation — different exePath)" + if ($ok) { $passes++ } else { $fails++ } + } + + return @($passes, $fails) +} + +function Build-AllScenarios { + $all = @() + if ($Target -in @('imds', 'both')) { $all += Build-ImdsScenarios -Identity $Identity } + if ($Target -in @('wireserver', 'both')) { $all += Build-WireServerScenarios -Identity $Identity } + if ($Scenarios) { $all = $all | Where-Object { $Scenarios -contains $_.Sid } } + return $all +} + +# --- main ------------------------------------------------------------------- + +$all = Build-AllScenarios +$totalP = 0; $totalF = 0 +$backups = Backup-RulesDir +try { + foreach ($s in $all) { + try { + $r = Invoke-Scenario $s + $totalP += $r[0]; $totalF += $r[1] + } catch { + Write-Finding $s.Sid FAIL "scenario crashed: $_" + $totalF++ + } + } +} finally { + Restore-RulesDir -Backups $backups +} + +Write-Host "" +Write-Host ("local-rules pen-test: {0} PASS, {1} FAIL" -f $totalP, $totalF) +Write-Host "Findings appended to $Findings" + +if ($totalF -gt 0) { exit 1 } diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 new file mode 100644 index 00000000..9577e056 --- /dev/null +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -0,0 +1,106 @@ +# Phase5-FileSystemAudit.ps1 — Windows port of pentest/phase5_state_fs/audit.sh. +# +# Scenarios E1 (ACL audit on key dir, log dir, binary, service config), +# F1 (no secret-shaped strings in world-readable status/rules JSON), and a +# read-only snapshot of WFP filters / GuestProxyAgent service config (E4 +# adjacent — analogous to Linux's bpftool snapshot). + +$ErrorActionPreference = 'Continue' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +# --- E1 — ACLs on critical paths --------------------------------------------- +# +# Windows policy: Administrators + SYSTEM should have full control; no other +# principal should have Write/Modify/FullControl. Read-only is acceptable on +# logs/binaries (see DESIGN.md F1: status surface is intentionally observable), +# but the Keys directory must be admin/SYSTEM-only for both read AND write. +$ALLOWED_PRINCIPALS = @( + 'NT AUTHORITY\SYSTEM', + 'BUILTIN\Administrators', + 'NT SERVICE\TrustedInstaller', + "$env:COMPUTERNAME\Administrator" +) + +function Test-AclTight { + param( + [string] $Path, + [switch] $SecretsOnly # if set, even READ access from non-admins is a FAIL + ) + if (-not (Test-Path $Path)) { + Write-Finding "E1[$Path]" INFO "missing" + return + } + try { $acl = Get-Acl $Path } catch { + Write-Finding "E1[$Path]" INFO "could not read ACL: $_"; return + } + $bad = @() + foreach ($ace in $acl.Access) { + $name = $ace.IdentityReference.Value + if ($name -in $ALLOWED_PRINCIPALS) { continue } + $rights = [string]$ace.FileSystemRights + $writeLike = $rights -match 'Write|Modify|FullControl|Delete|ChangePermissions|TakeOwnership' + $readLike = $rights -match 'Read|FullControl' + if ($SecretsOnly -and $readLike -and $ace.AccessControlType -eq 'Allow') { + $bad += "$name has $rights (read on a secrets path)" + } elseif ($writeLike -and $ace.AccessControlType -eq 'Allow') { + $bad += "$name has $rights (write on a system-managed path)" + } + } + if ($bad) { + Write-Finding "E1[$Path]" FAIL ("ACL too permissive: " + ($bad -join '; ')) + } else { + Write-Finding "E1[$Path]" PASS "ACL restricted to admin/SYSTEM principals" + } +} + +Test-AclTight $GpaKeyDir -SecretsOnly +Test-AclTight $GpaLogDir +Test-AclTight $GpaExe +Test-AclTight $GpaInstallRoot + +# Per-key file ACL (mirrors the Linux per-file 0600 check). +if (Test-Path $GpaKeyDir) { + Get-ChildItem -Path $GpaKeyDir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { + Test-AclTight $_.FullName -SecretsOnly + } +} + +# --- F1 — secret-shaped strings in observable status / rules JSON ------------ +$candidates = @() +$candidates += Get-ChildItem -Path $GpaLogDir -Filter 'status.json' -Recurse -ErrorAction SilentlyContinue +$candidates += Get-ChildItem -Path $GpaLogDir -Filter 'AuthorizationRules_*.json' -Recurse -ErrorAction SilentlyContinue +$secretRegex = '"(key|secret|signature|hmac|token)"\s*:\s*"[^"]+"' +foreach ($f in $candidates) { + try { + $text = Get-Content -Raw -Path $f.FullName + if ($text -match $secretRegex) { + Write-Finding "F1[$($f.Name)]" FAIL "possible secret-shaped string in observable file" + } else { + Write-Finding "F1[$($f.Name)]" PASS "no obvious secret-shaped strings" + } + } catch { + Write-Finding "F1[$($f.Name)]" INFO "could not read: $_" + } +} + +# --- WFP / service snapshots (informational, analogous to bpftool) ---------- +$svc = Get-Service -Name $GpaServiceName -ErrorAction SilentlyContinue +if ($svc) { + $svcInfo = "$($svc.Name) status=$($svc.Status) startType=$($svc.StartType)" + Write-Finding P5-svc INFO "GuestProxyAgent service: $svcInfo" +} else { + Write-Finding P5-svc FAIL "GuestProxyAgent service not installed" +} + +if (Get-Command netsh -ErrorAction SilentlyContinue) { + try { + $wfpDump = Join-Path $ResultsDir 'wfp_filters_windows.txt' + & netsh wfp show filters file=$wfpDump 2>&1 | Out-Null + Write-Finding P5-wfp INFO "saved WFP filter dump to $wfpDump" + } catch { + Write-Finding P5-wfp INFO "netsh wfp show filters failed: $_" + } +} + +Write-Host "" +Write-Host "Phase 5 complete. Findings: $Findings" diff --git a/pentest/windows/README.md b/pentest/windows/README.md new file mode 100644 index 00000000..d2cd6c85 --- /dev/null +++ b/pentest/windows/README.md @@ -0,0 +1,82 @@ +# GPA Pen-Test — Windows Harness + +Local pen-test scaffolding for the Windows build of the Guest Proxy Agent. +Pure PowerShell — no Python, no third-party tools required. + +Mirror of the Linux harness under [../linux/](../linux/), with the same +scenario IDs and the same `findings.tsv` schema; design rationale lives in +[DESIGN.md](DESIGN.md). + +## Layout + +| File | Purpose | +|------|---------| +| [Common.psm1](Common.psm1) | Shared module: paths, `Write-Finding` (TSV writer), HTTP helpers, admin check. | +| [TestCatalog.psm1](TestCatalog.psm1) | Per-test metadata (title / design / automation / repro / fix) consumed by the report. | +| [Phase2-Listener.ps1](Phase2-Listener.ps1) | A1, A1b, A2, A3, A3-survive, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | +| [Phase3-AuthN-AuthZ.ps1](Phase3-AuthN-AuthZ.ps1) | P3-cap (pktmon), B1a, B1b (kernel-side WFP-redirect proof), B3 (key ACL), B4 (forged signature), C1 (low-IL probe), C5 (Containers note), C7 (alt-IP-form parity). | +| [Phase4-RulesFuzz.ps1](Phase4-RulesFuzz.ps1) | URL / encoding differential (11 variants of the IMDS token URL). | +| [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1) | Local-file authorization-rules matrix: IMDS S1–S10 + WS S1–S10. | +| [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1) | E1 (ACL audit), F1 (no secrets in observable JSON), P5-svc, P5-wfp (`netsh wfp show filters`). | +| [Generate-Report.ps1](Generate-Report.ps1) | Renders `results\report.html` from `results\findings.tsv` (+ latest `url_diff.tsv`). Pure PowerShell. | +| [Run-AllPenTests.ps1](Run-AllPenTests.ps1) | Driver. Runs phases 2 / 3 / 4 / 5 + report by default; `-IncludePhase4b` to also run Phase 4b. | +| `results/` | Populated at runtime: `findings.tsv`, `url_diff.tsv`, `report.html`, pktmon `.etl` captures, `wfp_filters_windows.txt`. | + +## Prereqs + +- Windows 10 / 11 / Server 2019+ with PowerShell 5.1 or PowerShell 7. +- An elevated PowerShell session (ACL audit, pktmon capture, and Phase 4b + require Administrator). +- The `GuestProxyAgent` service must be installed and running for the + product-side scenarios to mean anything. + +No `pip install`, no Python — `Generate-Report.ps1` is the pure-PowerShell +equivalent of `pentest/linux/generate_report.py`. + +## Run + +```powershell +# All default phases (2, 3, 4, 5) + report: +powershell -ExecutionPolicy Bypass -File .\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings + +# Include Phase 4b (opt-in; mutates the Rules dir, requires useLocalFileRules=true): +.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings -IncludePhase4b + +# Phase 4b alone, IMDS only: +.\pentest\windows\Phase4b-LocalRules.ps1 -Target imds + +# Re-render the report from the existing findings.tsv: +.\pentest\windows\Generate-Report.ps1 +``` + +`-TruncateFindings` clears `results\findings.tsv` first (equivalent to +`> findings.tsv` on Linux). Omit it to append. +`-SkipBurst` skips the Phase 2 G1 burst (useful on production VMs where the +200-connect spike would trigger alerts). +`-NoReport` skips the final `Generate-Report.ps1` invocation. + +## Result format + +Same TSV schema as the Linux harness: + +``` +\t\t\t +``` + +A `FAIL` means the GPA invariant was violated (potential finding) and warrants +triage. Designs, repro steps, and suggested fixes are inlined per row in the +HTML report. + +## Notes vs the Linux harness + +- Same scenario IDs (A4, B1b, C7, IMDS-S6, …) — findings can be compared + side-by-side with the Linux runs. +- Same report sections (Run configuration / Failures / Informational / + Per-phase / URL diff / Cheat sheet / Artifacts). +- Differences in *implementation* are documented in [DESIGN.md §5](DESIGN.md): + WFP instead of eBPF, ACLs instead of POSIX modes, `pktmon` instead of + `tcpdump`, `netsh wfp show filters` instead of `bpftool prog show`, + Containers feature instead of `unshare -Cr`, etc. +- C1 caveat: launch from a *standard-user* PowerShell to actually exercise + the low-IL path; otherwise the harness records INFO with a manual-repro + hint. diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 new file mode 100644 index 00000000..2adf2215 --- /dev/null +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -0,0 +1,69 @@ +# Run-AllPenTests.ps1 — Windows driver, equivalent of pentest/linux/run_all.sh. +# +# Usage (from an elevated PowerShell): +# powershell -ExecutionPolicy Bypass -File .\Run-AllPenTests.ps1 [options] +# +# By default runs phases 2, 3, 4 (URL diff), 5, then renders the HTML report. +# Phase 4b (local-file rules) is OPT-IN with -IncludePhase4b because it +# mutates %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ and requires +# useLocalFileRules=true on the fabric. + +[CmdletBinding()] +param( + [switch] $TruncateFindings, + [switch] $SkipBurst, + [switch] $IncludePhase4b, + [ValidateSet('imds', 'wireserver', 'both')] + [string] $Phase4bTarget = 'both', + [switch] $NoReport +) + +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force + +if (-not (Test-Administrator)) { + Write-Warning 'Not running as Administrator. E1 ACL audit, pktmon capture, and Phase 4b will be limited or skipped.' +} + +if ($TruncateFindings) { + Set-Content -Path $Findings -Value '' -Encoding UTF8 + Write-Host "Truncated $Findings" +} + +# CFG / PRE rows are surfaced in their own section by Generate-Report.ps1. +Write-Finding CFG INFO "platform = Windows $((Get-CimInstance Win32_OperatingSystem).Version)" +Write-Finding CFG INFO "current identity: user=$env:USERNAME admin=$(Test-Administrator)" +Write-Finding CFG INFO "service=$GpaServiceName running=$(Test-ServiceRunning $GpaServiceName) listener=$GpaListener" + +$phases = @( + 'Phase2-Listener.ps1', + 'Phase3-AuthN-AuthZ.ps1', + 'Phase4-RulesFuzz.ps1', + 'Phase5-FileSystemAudit.ps1' +) + +foreach ($p in $phases) { + $script = Join-Path $PSScriptRoot $p + Write-Host '' + Write-Host "===== $p =====" -ForegroundColor Cyan + if ($p -eq 'Phase2-Listener.ps1' -and $SkipBurst) { + & $script -SkipBurst + } else { + & $script + } +} + +if ($IncludePhase4b) { + Write-Host '' + Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget +} + +if (-not $NoReport) { + Write-Host '' + Write-Host '===== Generate-Report.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Generate-Report.ps1') +} + +Write-Host '' +Write-Host "All Windows phases complete. Findings: $Findings" diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 new file mode 100644 index 00000000..f55bb239 --- /dev/null +++ b/pentest/windows/TestCatalog.psm1 @@ -0,0 +1,232 @@ +# TestCatalog.psm1 — per-test metadata for the Windows pen-test report. +# +# Mirrors pentest/linux/test_catalog.py for the Windows scenario set. Used by +# Generate-Report.ps1 to inline design notes, automation summary, and repro +# steps into each row of the HTML report. +# +# Each entry has: Title, Design, Automation, ReproScript, ReproManual, Fix. + +Set-StrictMode -Version Latest + +$script:Catalog = @{ + # ---------- Phase 2 — listener / DoS ---------- + 'A1' = @{ + Title = 'Listener bound only to loopback' + Design = 'GPA must listen ONLY on 127.0.0.1:3080. Any non-loopback bind would expose the proxy to other VMs on the vNet.' + Automation = "Get-NetTCPConnection -State Listen | Where LocalPort -eq 3080; FAIL on any LocalAddress not in {127.0.0.1, ::1}." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = "Get-NetTCPConnection -State Listen | Where-Object { `$_.LocalPort -eq 3080 }" + Fix = 'Bind the proxy listener explicitly to 127.0.0.1, not 0.0.0.0. Check the bind call in proxy_agent/src/proxy/proxy_listener.rs and the Windows-specific wiring under proxy_agent/src/service/windows/.' + } + 'A1b' = @{ + Title = 'Loopback connect to 3080 succeeds' + Design = 'Sanity check: the listener is reachable on 127.0.0.1:3080.' + Automation = 'Test-NetConnection -ComputerName 127.0.0.1 -Port 3080.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = 'Test-NetConnection 127.0.0.1 -Port 3080' + Fix = 'If failing, the agent is not running. Get-Service GuestProxyAgent; check the Application/System event log for service errors.' + } + 'A2' = @{ + Title = 'Port 3080 NOT reachable on external IP' + Design = 'Even if a misconfiguration binds 0.0.0.0, vNet peers must NOT be able to connect to :3080.' + Automation = 'Test-NetConnection against every non-loopback IPv4 from Get-NetIPAddress.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = "Get-NetIPAddress -AddressFamily IPv4 | Where IPAddress -notlike '127.*'`nTest-NetConnection -Port 3080" + Fix = 'Same as A1 — confirm bind(127.0.0.1, 3080) and add a Windows Firewall rule blocking inbound 3080 from non-loopback as defense-in-depth.' + } + 'A3' = @{ + Title = 'Service survives malformed HTTP' + Design = 'Bad method, oversized headers, slow-loris must not crash GuestProxyAgent.exe.' + Automation = 'Sends a 64KB X-Big header via a raw TcpClient and re-checks Get-Service status.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '$c = New-Object System.Net.Sockets.TcpClient; $c.Connect("127.0.0.1",3080); ...' + Fix = 'If FAIL, gather a crash dump (Windows Error Reporting / procdump) and harden the HTTP parser.' + } + 'A3-survive' = @{ + Title = 'Service still running after malformed HTTP' + Design = 'Companion check to A3 — Get-Service must report Running after the burst.' + Automation = 'Test-ServiceRunning GuestProxyAgent after the A3 probes.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = 'Get-Service GuestProxyAgent' + Fix = 'See A3.' + } + 'A4' = @{ + Title = 'CONNECT method must be rejected' + Design = 'GPA must not act as a generic HTTP tunnel. CONNECT to arbitrary :443 must be refused with an explicit 4xx (preferably 405).' + Automation = "Sends raw 'CONNECT example.com:443 HTTP/1.1' over a TcpClient; expects HTTP/1.1 405." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '(via raw TcpClient — see Send-RawTcp helper in Phase2-Listener.ps1)' + Fix = "In the request parser (proxy_agent/src/proxy/proxy_server.rs) explicitly reject any non-{GET,POST,PUT,DELETE,HEAD,OPTIONS,PATCH} method with 405 Method Not Allowed and a short body BEFORE closing. Never silently drop unsupported methods." + } + 'A5' = @{ + Title = 'Not an open proxy' + Design = 'GPA must refuse to forward to arbitrary external hosts even when explicitly configured as an HTTP proxy.' + Automation = "HttpWebRequest with WebProxy('http://127.0.0.1:3080') to http://example.com/ — expects non-2xx." + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '$proxy=New-Object System.Net.WebProxy("http://127.0.0.1:3080",$true); $r=[Net.HttpWebRequest]::Create("http://example.com/"); $r.Proxy=$proxy; $r.GetResponse()' + Fix = 'Allowlist destinations in the connect-policy redirector; reject any Host:/absolute-URI not in {WireServer, IMDS, HostGAPlugin}.' + } + 'G1' = @{ + Title = "Connection burst doesn't crash" + Design = '200 sequential TcpClient connects; service must remain Running.' + Automation = 'Loops 1..200 with TcpClient.Connect, then re-checks Get-Service status.' + ReproScript = '.\pentest\windows\Phase2-Listener.ps1' + ReproManual = '1..200 | %% { (New-Object Net.Sockets.TcpClient).Connect("127.0.0.1",3080) }' + Fix = 'Apply per-source connection limits in the listener; verify service recovery options in services.msc are set to Restart.' + } + + # ---------- Phase 3 — AuthN/AuthZ ---------- + 'P3-cap' = @{ + Title = 'Phase-3 packet capture' + Design = 'Records traffic to fabric IPs + lo:3080 for offline analysis (Windows uses pktmon).' + Automation = 'pktmon filter add IMDS/WireServer/3080; pktmon start --etw -f results\phase3-windows-*.etl.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = 'pktmon filter add -i 169.254.169.254; pktmon start --etw -f c:\temp\p3.etl' + } + 'B1a' = @{ + Title = 'IMDS reachable through GPA' + Design = 'A normal IMDS call from the guest must succeed (200) thanks to WFP redirect + GPA signature injection.' + Automation = "Invoke-WebRequest http://169.254.169.254/metadata/instance?api-version=2021-02-01 -Headers @{'Metadata'='true'}." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -UseBasicParsing -Headers @{'Metadata'='true'} 'http://169.254.169.254/metadata/instance?api-version=2021-02-01'" + Fix = 'If FAIL, check WFP filters (netsh wfp show filters) and that key-keeper has latched a key (status.json secureChannelState).' + } + 'B1b' = @{ + Title = 'Kernel-side proof WFP redirect is in effect' + Design = 'Open a TCP connection to 169.254.169.254:80 and read what Get-NetTCPConnection reports as the established peer. PASS if 127.0.0.1:3080.' + Automation = 'TcpClient.Connect; match Get-NetTCPConnection -LocalPort -State Established.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = '$c=New-Object Net.Sockets.TcpClient; $c.Connect("169.254.169.254",80); Get-NetTCPConnection -LocalPort $c.Client.LocalEndPoint.Port' + Fix = "If kernel reports the raw IMDS IP, the WFP callout is not attached for this connection's owner. Check the redirector service status and any per-process exclusions." + } + 'B3' = @{ + Title = 'Key files NOT readable by non-admins' + Design = "Anything under %SystemDrive%\\WindowsAzure\\ProxyAgent\\Keys must grant access only to SYSTEM / BUILTIN\\Administrators / TrustedInstaller. Any read grant to Users / Authenticated Users / Everyone / Anonymous is a FAIL." + Automation = 'Get-Acl on every file in the Keys dir; FAIL on Read/Modify/FullControl ACEs for the four well-known low-trust SIDs.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Get-ChildItem `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Keys -Recurse | Get-Acl | Format-List Path,AccessToString" + Fix = 'Force restrictive ACLs on every key write (Rust: SetSecurityInfo/SetNamedSecurityInfo). Add a self-heal pass on service start that re-applies the canonical DACL.' + } + 'B4' = @{ + Title = 'Attacker-supplied signature header is overwritten' + Design = "GPA must strip/overwrite client-supplied x-ms-azure-signature / x-ms-azure-time-tick. A 200 response means GPA injected its own — confirm in the pktmon capture that the on-wire header value differs." + Automation = "Invoke-WebRequest with forged headers; expects 200, then manual pktmon diff." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -Headers @{'Metadata'='true';'x-ms-azure-signature'='AAAA';'x-ms-azure-time-tick'='0'} ..." + Fix = "In the request-rewriting layer, unconditionally remove inbound x-ms-azure-* headers before computing/inserting GPA's own signature." + } + 'C1' = @{ + Title = 'Non-elevated user denied WireServer' + Design = 'WireServer requires elevated identity at GPA; calls from a low-IL/standard-user token must be 401/403.' + Automation = "If harness runs as Admin: tries 'runas /trustlevel:0x20000' (basic user) — captures stdout unreliable on Windows so falls back to INFO. If harness runs as standard user: probes WireServer directly under that token." + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "From a standard-user PowerShell:`nInvoke-WebRequest -UseBasicParsing -Headers @{'x-ms-version'='2012-11-30'} 'http://168.63.129.16/machine/?comp=goalstate'" + Fix = 'If FAIL with 200 from a low-IL/standard-user PowerShell, the WireServer authorizer (proxy_agent/src/proxy/proxy_authorizer.rs) is not enforcing runAsElevated. Verify the per-connection identity capture on Windows is reading the actual peer token, not the agent process token.' + } + 'C5' = @{ + Title = 'Container / silo evasion (Windows Containers)' + Design = 'Workloads running inside a Windows container (Server Containers / Hyper-V Isolation) must still be subject to the WFP redirect, OR be denied at the network layer entirely. There is no Windows analog to `unshare -Cr` outside containers, so the harness records INFO when the feature is enabled and asks for manual verification.' + Automation = 'Get-WindowsOptionalFeature -Online -FeatureName Containers; INFO with manual-review note.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "From inside a container: Invoke-WebRequest -Headers @{'Metadata'='true'} 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' — must either land on GPA or fail." + } + 'C7' = @{ + Title = 'Alternate IP-form bypass attempt' + Design = 'http://0xa9fea9fe/... and http://2852039166/... resolve to 169.254.169.254. The WFP redirect must treat them identically; AuthZ decision must match the canonical form.' + Automation = 'Captures canonical (status, body sha256) at B1a; for each alt URL: PASS on parity, INFO on body-only drift, PASS on 4xx, FAIL on differing non-4xx.' + ReproScript = '.\pentest\windows\Phase3-AuthN-AuthZ.ps1' + ReproManual = "Invoke-WebRequest -Headers @{'Metadata'='true'} 'http://0xa9fea9fe/metadata/instance?api-version=2021-02-01'" + Fix = 'If a code DIFFERS from the canonical (200), file a finding: the WFP redirect or matcher treats numeric host forms inconsistently.' + } + + # ---------- Phase 5 — fs/state audit ---------- + 'E1' = @{ + Title = 'ACL audit on critical paths' + Design = "Each path's DACL must allow Write/Modify/FullControl ONLY to SYSTEM / BUILTIN\\Administrators / TrustedInstaller. The Keys directory (and every file under it) is also Secrets-only: any READ grant to a non-admin principal is a FAIL." + Automation = "Get-Acl + Test-AclTight per path; -SecretsOnly switch on the Keys dir tightens to read-as-FAIL for non-admins." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Get-Acl `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Keys | Format-List" + Fix = "Force the canonical DACL on install (SDDL: O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)) and re-apply on service start." + } + 'F1' = @{ + Title = 'Status / rules files contain no secrets' + Design = "Observable files (status.json, AuthorizationRules_*.json) under the Logs dir must NOT contain key material, signatures, tokens, or HMACs." + Automation = "Regex '`"(key|secret|signature|hmac|token)`"\\s*:\\s*`"[^`"]+`"' against each file." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Select-String -Pattern '`"(key|secret|signature|hmac|token)`":' `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Logs\\*.json" + Fix = 'If FAIL, redact the offending field in the writer (search for the field name across proxy_agent/src/proxy/).' + } + 'P5-svc' = @{ + Title = 'GuestProxyAgent service status' + Design = 'Confirms the service is installed and (ideally) Running with StartType=Automatic.' + Automation = 'Get-Service GuestProxyAgent.' + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = 'Get-Service GuestProxyAgent | Format-List *' + } + 'P5-wfp' = @{ + Title = 'WFP filter snapshot' + Design = "Captures 'netsh wfp show filters' for offline review — Windows analog of bpftool prog show / cgroup tree on Linux." + Automation = 'netsh wfp show filters file=results\wfp_filters_windows.txt.' + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = 'netsh wfp show filters file=c:\temp\wfp.txt' + } + + # ---------- Phase 4 — URL/encoding diff ---------- + # Rows live in url_diff.tsv; the report renders them in their own section. + + # ---------- Phase 4b — local-file rules pre-flight ---------- + 'PRE' = @{ + Title = 'Pre-flight: useLocalFileRules must be active' + Design = 'Phase 4b is only meaningful when the fabric delivers a ruleId whose decoded JSON has useLocalFileRules:true. Without it, the agent ignores the local files we write.' + Automation = 'Reads the latest AuthorizationRules_*.json from the Logs dir and looks for the substring useLocalFileRules-true.' + ReproScript = '.\pentest\windows\Phase4b-LocalRules.ps1' + ReproManual = "Get-ChildItem `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Logs\\AuthorizationRules_*.json | Sort LastWriteTime -Desc | Select -First 1 | Select-String useLocalFileRules-true" + Fix = 'Toggle the flag from your control plane / mock fabric, then re-run.' + } +} + +# Phase 4b scenarios — same shape across IMDS/WS, so per-scenario titles only. +$script:Phase4b = @{ + 'IMDS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow'; Design='Baseline. Every IMDS probe must return 200.' } + 'IMDS-S2-enforce-deny-empty' = @{ Title='Fail-closed when enforce+deny with no allow rules'; Design='All IMDS calls must return 403 — proves enforcement engages.' } + 'IMDS-S3-audit-deny-empty' = @{ Title='Audit mode passes traffic through (logs deny, returns 200)'; Design='Recorded as INFO from local rules: merge_authorization_item() honors `mode` only from the remote rule.' } + 'IMDS-S4-allow-one-path' = @{ Title='Allow exactly one path via privilege/role/identity/assignment'; Design='instance(200), token(403), versions(403).' } + 'IMDS-S5-wrong-identity' = @{ Title='Allow rule bound to non-matching identity'; Design='Same allow rule but identity userName=nosuchuser_xyz. Must 403.' } + 'IMDS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass attempts'; Design="Allow only /metadata/instance. Probes try %2F/%2f/%252F/dot-segments to reach token. ALL must 403 (or 404 for dot-segments)." } + 'IMDS-S7-query-param-required'= @{ Title='Query parameter must match for allow'; Design='Allow only when api-version=2021-02-01. Matching → 200, missing/wrong → 403.' } + 'IMDS-S8-group-only-identity' = @{ Title='Identity match by groupName only'; Design='Identity has only groupName. Must allow.' } + 'IMDS-S9-exepath-identity' = @{ Title='Per-process identity by exePath'; Design='Allow only when exePath=PowerShell.exe (Windows variant). PowerShell caller → 200; arbitrary exe → 403.' } + 'IMDS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed'; Design='Every IMDS probe must 403 — never silently fall back to allow.' } + 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } + 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } + 'WS-S3-audit-deny-empty' = @{ Title='Audit mode passes through (WireServer)'; Design='Recorded as INFO via local rules (same merge-semantics reason).' } + 'WS-S4-allow-goalstate-only' = @{ Title='Allow only /machine/?comp=goalstate'; Design='goalstate→200; sharedConfig/hosting/certs/extensionsConfig/versions→403.' } + 'WS-S5-wrong-identity' = @{ Title='Allow goalstate but identity does not match'; Design='Even goalstate → 403.' } + 'WS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass (WireServer)'; Design='Encoded variants of /machine/?comp=certificates must all 403; comp=GOALSTATE (case-insensitive) must 200.' } + 'WS-S7-query-param-required' = @{ Title='Query parameter must match (WireServer)'; Design='Only comp=goalstate allowed; matching/extra-param → 200/400, missing/wrong → 403.' } + 'WS-S8-group-only-identity' = @{ Title='Identity match by groupName only (WireServer)'; Design='groupName=Administrators → goalstate 200.' } + 'WS-S9-exepath-identity' = @{ Title='Per-process identity by exePath (WireServer)'; Design='Allow only when exePath=PowerShell.exe.' } + 'WS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed (WireServer)'; Design='All WireServer probes 403.' } +} + +function Get-CatalogEntry { + [CmdletBinding()] + param([string] $TestId) + if (-not $TestId) { return $null } + if ($Catalog.ContainsKey($TestId)) { return $Catalog[$TestId] } + if ($Phase4b.ContainsKey($TestId)) { return $Phase4b[$TestId] } + + # Strip [...] suffix (e.g. E1[C:\...\status.json]) + $base = ($TestId -replace '\[.*?\]', '').Trim() + if ($Catalog.ContainsKey($base)) { return $Catalog[$base] } + + # Strip /probe suffix (e.g. IMDS-S4-allow-one-path/instance_allowed) + if ($TestId.Contains('/')) { + $head = $TestId.Split('/', 2)[0] + if ($Phase4b.ContainsKey($head)) { return $Phase4b[$head] } + if ($Catalog.ContainsKey($head)) { return $Catalog[$head] } + } + return $null +} + +Export-ModuleMember -Function Get-CatalogEntry -Variable Catalog,Phase4b From 373e19fe24288cd81efa4bdc180d8ba4bd3fc190 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 13:37:34 -0700 Subject: [PATCH 10/37] re-saved every .ps1 and .psm1 under windows with a UTF-8 BOM so Windows PowerShell reads them correctly --- pentest/windows/Common.psm1 | 2 +- pentest/windows/Generate-Report.ps1 | 2 +- pentest/windows/Phase2-Listener.ps1 | 2 +- pentest/windows/Phase3-AuthN-AuthZ.ps1 | 2 +- pentest/windows/Phase4-RulesFuzz.ps1 | 2 +- pentest/windows/Phase4b-LocalRules.ps1 | 2 +- pentest/windows/Phase5-FileSystemAudit.ps1 | 2 +- pentest/windows/Run-AllPenTests.ps1 | 2 +- pentest/windows/TestCatalog.psm1 | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index eadaf089..9bdbf155 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -1,4 +1,4 @@ -# Common.psm1 — shared helpers for the Windows pen-test harness. +# Common.psm1 — shared helpers for the Windows pen-test harness. # # Mirrors the Linux harness's record/append-to-findings.tsv contract so the # same Python report generator (pentest/generate_report.py) can render Windows diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 index 37798701..26a672f5 100644 --- a/pentest/windows/Generate-Report.ps1 +++ b/pentest/windows/Generate-Report.ps1 @@ -1,4 +1,4 @@ -# Generate-Report.ps1 — Windows port of pentest/linux/generate_report.py. +# Generate-Report.ps1 — Windows port of pentest/linux/generate_report.py. # # Builds an HTML pen-test report from results\findings.tsv (and the latest # results\url_diff.tsv run, if present). Pure PowerShell — no Python, no diff --git a/pentest/windows/Phase2-Listener.ps1 b/pentest/windows/Phase2-Listener.ps1 index 6bdf00e2..03d28b39 100644 --- a/pentest/windows/Phase2-Listener.ps1 +++ b/pentest/windows/Phase2-Listener.ps1 @@ -1,4 +1,4 @@ -# Phase2-Listener.ps1 — Windows port of pentest/phase2_listener/run.sh. +# Phase2-Listener.ps1 — Windows port of pentest/phase2_listener/run.sh. # # Scenarios A1, A2 (cross-host probe), A3 (malformed HTTP), A4 (CONNECT), # A5 (open-proxy), G1 (connection burst). diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index 45248b56..eb973fa5 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -1,4 +1,4 @@ -# Phase3-AuthN-AuthZ.ps1 — Windows port of pentest/phase3_authn_authz/run.sh. +# Phase3-AuthN-AuthZ.ps1 — Windows port of pentest/phase3_authn_authz/run.sh. # # Scenarios B1, B1b (redirect-active proof), B3, B4, C1, C5-equivalent # (AppContainer / WSL bypass note), C7 (alt IP-form parity). diff --git a/pentest/windows/Phase4-RulesFuzz.ps1 b/pentest/windows/Phase4-RulesFuzz.ps1 index 918d815c..bf8b068f 100644 --- a/pentest/windows/Phase4-RulesFuzz.ps1 +++ b/pentest/windows/Phase4-RulesFuzz.ps1 @@ -1,4 +1,4 @@ -# Phase4-RulesFuzz.ps1 — Windows port of pentest/linux/phase4_rules_fuzz/url_diff.py. +# Phase4-RulesFuzz.ps1 — Windows port of pentest/linux/phase4_rules_fuzz/url_diff.py. # # Sends a matrix of equivalent URLs (different encodings of the same path) to # the GPA-redirected IMDS endpoint and records the response code GPA hands back. diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index dff4ad40..110dada5 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -1,4 +1,4 @@ -# Phase4b-LocalRules.ps1 — Windows port of pentest/linux/phase4b_local_rules/run.py. +# Phase4b-LocalRules.ps1 — Windows port of pentest/linux/phase4b_local_rules/run.py. # # Auto-runs the local-file authorization-rules scenario matrix against the # Windows GuestProxyAgent. diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 index 9577e056..0abb54aa 100644 --- a/pentest/windows/Phase5-FileSystemAudit.ps1 +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -1,4 +1,4 @@ -# Phase5-FileSystemAudit.ps1 — Windows port of pentest/phase5_state_fs/audit.sh. +# Phase5-FileSystemAudit.ps1 — Windows port of pentest/phase5_state_fs/audit.sh. # # Scenarios E1 (ACL audit on key dir, log dir, binary, service config), # F1 (no secret-shaped strings in world-readable status/rules JSON), and a diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 index 2adf2215..2e76d4a0 100644 --- a/pentest/windows/Run-AllPenTests.ps1 +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -1,4 +1,4 @@ -# Run-AllPenTests.ps1 — Windows driver, equivalent of pentest/linux/run_all.sh. +# Run-AllPenTests.ps1 — Windows driver, equivalent of pentest/linux/run_all.sh. # # Usage (from an elevated PowerShell): # powershell -ExecutionPolicy Bypass -File .\Run-AllPenTests.ps1 [options] diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 index f55bb239..1bb17cab 100644 --- a/pentest/windows/TestCatalog.psm1 +++ b/pentest/windows/TestCatalog.psm1 @@ -1,4 +1,4 @@ -# TestCatalog.psm1 — per-test metadata for the Windows pen-test report. +# TestCatalog.psm1 — per-test metadata for the Windows pen-test report. # # Mirrors pentest/linux/test_catalog.py for the Windows scenario set. Used by # Generate-Report.ps1 to inline design notes, automation summary, and repro From a2ad88a46d6836ba9594b22557acc7899953a3c9 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 13:40:42 -0700 Subject: [PATCH 11/37] Fix: renamed the local variable to $findingRows so the imported $Findings path stays intact for the Artifacts section --- pentest/windows/Generate-Report.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 index 26a672f5..129f1ebe 100644 --- a/pentest/windows/Generate-Report.ps1 +++ b/pentest/windows/Generate-Report.ps1 @@ -186,17 +186,20 @@ function expandIn(el, open){ el.querySelectorAll('details.row').forEach(d => d.o # --- main build ------------------------------------------------------------- -$findings = Get-Findings -$urlRows = Get-UrlDiffLatest +# NOTE: do not use $findings here — the variable name collides (case-insensitive) +# with $Findings exported from Common.psm1, which holds the path to findings.tsv +# and is referenced later in the Artifacts section. +$findingRows = Get-Findings +$urlRows = Get-UrlDiffLatest -if (-not $findings) { +if (-not $findingRows) { Write-Error "No findings to report (no rows in $Findings)." exit 1 } $SetupIds = @('CFG', 'PRE') -$setupRows = $findings | Where-Object { $SetupIds -contains $_.Id } -$testRows = $findings | Where-Object { $SetupIds -notcontains $_.Id } +$setupRows = $findingRows | Where-Object { $SetupIds -contains $_.Id } +$testRows = $findingRows | Where-Object { $SetupIds -notcontains $_.Id } $total = $testRows.Count $nPass = ($testRows | Where-Object Status -eq 'PASS').Count From 580f379b01cf5f061cfced1691127ccf3dfbd444 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 13:59:17 -0700 Subject: [PATCH 12/37] Replaced the unreliable runas /trustlevel:0x20000 path with Invoke-AsStandardUser --- pentest/windows/Common.psm1 | 121 +++++++++++++++++++++++++ pentest/windows/Phase3-AuthN-AuthZ.ps1 | 87 ++++++++++++++---- pentest/windows/Run-AllPenTests.ps1 | 59 +++++++----- 3 files changed, 223 insertions(+), 44 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index 9bdbf155..ce40ae8a 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -110,4 +110,125 @@ function Test-Administrator { $p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) } +# --- Temporary standard-user account ---------------------------------------- +# +# Some pen-test scenarios (notably C1) require a probe to be issued from a +# non-elevated, non-Administrator account. When the harness is launched as +# Administrator, we provision a throwaway local user, share the credential +# in module scope so individual phase scripts can pick it up via +# Get-PenTestStandardUserCredential, and remove the account at the end of +# the run via Remove-PenTestStandardUser. The password is generated locally +# with a CSPRNG and never written to disk. + +$script:PenTestStdUser = $null # PSCredential +$script:PenTestStdUserName = $null # string +$script:PenTestStdUserCreated = $false # whether *we* created the account + +function New-PenTestRandomPassword { + param([int] $Length = 24) + # 4 char classes so the new local user satisfies the default Windows + # complexity policy regardless of how the host is configured. + $upper = [char[]]'ABCDEFGHJKLMNPQRSTUVWXYZ' + $lower = [char[]]'abcdefghijkmnopqrstuvwxyz' + $digit = [char[]]'23456789' + $sym = [char[]]'!@#$%^&*()-_=+[]{}' + $all = $upper + $lower + $digit + $sym + + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + function Get-RandomChar([char[]] $Set) { + $buf = New-Object byte[] 4 + $rng.GetBytes($buf) + $u = [BitConverter]::ToUInt32($buf, 0) + $Set[[int]($u % [uint32]$Set.Length)] + } + $chars = New-Object System.Collections.Generic.List[char] + $chars.Add((Get-RandomChar $upper)) | Out-Null + $chars.Add((Get-RandomChar $lower)) | Out-Null + $chars.Add((Get-RandomChar $digit)) | Out-Null + $chars.Add((Get-RandomChar $sym)) | Out-Null + for ($i = $chars.Count; $i -lt $Length; $i++) { + $chars.Add((Get-RandomChar $all)) | Out-Null + } + # Fisher-Yates shuffle so the guaranteed class chars aren't always at the front. + for ($i = $chars.Count - 1; $i -gt 0; $i--) { + $buf = New-Object byte[] 4 + $rng.GetBytes($buf) + $j = [int]([BitConverter]::ToUInt32($buf, 0) % [uint32]($i + 1)) + $tmp = $chars[$i]; $chars[$i] = $chars[$j]; $chars[$j] = $tmp + } + -join $chars + } finally { $rng.Dispose() } +} + +function New-PenTestStandardUser { + [CmdletBinding()] + param([string] $NamePrefix = 'gpapen') + + if (-not (Test-Administrator)) { + Write-Finding PRE INFO "not Administrator; cannot provision standard-user account for C1" + return $null + } + if ($script:PenTestStdUser) { return $script:PenTestStdUser } + if (-not (Get-Command New-LocalUser -ErrorAction SilentlyContinue)) { + Write-Finding PRE INFO "Microsoft.PowerShell.LocalAccounts module unavailable; cannot create standard-user" + return $null + } + + # Username must be <=20 chars; suffix with 8 random hex. + $suffix = ([guid]::NewGuid().ToString('N')).Substring(0, 8) + $name = "$NamePrefix$suffix" + $pwd = New-PenTestRandomPassword + $secure = ConvertTo-SecureString $pwd -AsPlainText -Force + + try { + New-LocalUser -Name $name -Password $secure ` + -FullName 'GPA pen-test ephemeral standard user' ` + -Description 'Created by pentest/windows harness; safe to delete.' ` + -PasswordNeverExpires -UserMayNotChangePassword ` + -AccountNeverExpires -ErrorAction Stop | Out-Null + Add-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop + + $script:PenTestStdUserName = $name + $script:PenTestStdUserCreated = $true + $script:PenTestStdUser = New-Object System.Management.Automation.PSCredential( + "$env:COMPUTERNAME\$name", $secure) + Write-Finding PRE INFO "provisioned ephemeral standard user '$name' (member of Users); password is in-memory only" + return $script:PenTestStdUser + } catch { + Write-Finding PRE INFO "could not create ephemeral standard user: $_" + # Best-effort partial cleanup. + Get-LocalUser -Name $name -ErrorAction SilentlyContinue | Remove-LocalUser -ErrorAction SilentlyContinue + return $null + } +} + +function Get-PenTestStandardUserCredential { $script:PenTestStdUser } + +function Remove-PenTestStandardUser { + if (-not $script:PenTestStdUserCreated) { return } + $name = $script:PenTestStdUserName + if (-not $name) { return } + try { + # Best-effort: kill any leftover processes the user may have, then drop + # the profile dir, group membership, and the account itself. + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.GetOwner().User -eq $name } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Remove-LocalGroupMember -Group 'Users' -Member $name -ErrorAction SilentlyContinue + Remove-LocalUser -Name $name -ErrorAction Stop + $profile = Join-Path $env:SystemDrive "Users\$name" + if (Test-Path $profile) { + Remove-Item -Path $profile -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Finding PRE INFO "removed ephemeral standard user '$name'" + } catch { + Write-Finding PRE INFO "could not fully remove ephemeral standard user '$name': $_ (manual cleanup required: 'net user $name /delete')" + } finally { + $script:PenTestStdUser = $null + $script:PenTestStdUserName = $null + $script:PenTestStdUserCreated = $false + } +} + Export-ModuleMember -Function * -Variable * diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index eb973fa5..c4f097a6 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -110,36 +110,83 @@ if ($r.Code -eq 200) { } # ---------- C1 — non-elevated WireServer call must be denied ----------------- -# Spawn a child PowerShell as the well-known low-privilege "NETWORK SERVICE" -# account isn't simple from PS without psexec; instead we run the probe under -# the current low-IL token. If the harness is being executed as Administrator, -# we fall back to recording INFO with a manual-repro hint. -function Invoke-AsLowIL { +# Run the WireServer probe under a real non-Administrator token. If the harness +# itself is already running as a standard user, we just call directly. When the +# harness is elevated, we use the ephemeral standard-user account provisioned +# by Run-AllPenTests.ps1 (see Common.psm1::New-PenTestStandardUser) and execute +# the probe via Start-Process -Credential, capturing the HTTP status code from +# a temp file. The account is removed by the driver after all phases finish. +function Invoke-AsStandardUser { param([string] $Url, [hashtable] $Headers) - # If the current token is already non-admin, just call. + if (-not (Test-Administrator)) { return (Get-HttpStatus -Url $Url -Headers $Headers).Code } - # Try to use 'runas /trustlevel:0x20000' (basic user) via cmd. - try { - $script = @" -`$ProgressPreference='SilentlyContinue' -try { (Invoke-WebRequest -UseBasicParsing -Uri '$Url' -Headers @{'x-ms-version'='2012-11-30'} -TimeoutSec 8).StatusCode } -catch { if (`$_.Exception.Response) { [int]`$_.Exception.Response.StatusCode } else { 0 } } + + $cred = Get-PenTestStandardUserCredential + if (-not $cred) { return -1 } + + # Build a small child script that performs the probe and writes the status + # code to $outFile. Both $script and $outFile must be readable/writable by + # the standard user, so use a world-accessible TEMP location and grant the + # account explicit read/write on the two files. + $hdrLiteral = ($Headers.GetEnumerator() | ForEach-Object { + "'{0}'='{1}'" -f ($_.Key -replace "'","''"), ($_.Value -replace "'","''") + }) -join ';' + $tmpDir = $env:TEMP + $stamp = [guid]::NewGuid().ToString('N') + $script = Join-Path $tmpDir "gpapen-c1-$stamp.ps1" + $outFile = Join-Path $tmpDir "gpapen-c1-$stamp.out" + $body = @" +`$ProgressPreference = 'SilentlyContinue' +`$code = 0 +try { + `$r = Invoke-WebRequest -UseBasicParsing -Uri '$Url' `` + -Headers @{$hdrLiteral} -TimeoutSec 8 -ErrorAction Stop + `$code = [int]`$r.StatusCode +} catch [System.Net.WebException] { + if (`$_.Exception.Response) { `$code = [int]`$_.Exception.Response.StatusCode } else { `$code = 0 } +} catch { + `$code = 0 +} +Set-Content -Path '$outFile' -Value `$code -Encoding ASCII "@ - $tmp = [IO.Path]::GetTempFileName() + '.ps1' - Set-Content -Path $tmp -Value $script -Encoding UTF8 - $out = & cmd /c "runas /trustlevel:0x20000 ""powershell -NoProfile -ExecutionPolicy Bypass -File $tmp""" 2>&1 - Remove-Item $tmp -ErrorAction SilentlyContinue - # runas /trustlevel cannot return stdout reliably; treat as inconclusive. + Set-Content -Path $script -Value $body -Encoding UTF8 + + # Ensure the standard user can read the script and write the output file. + foreach ($f in @($script, $outFile)) { + if (-not (Test-Path $f)) { New-Item -Path $f -ItemType File -Force | Out-Null } + try { + $acl = Get-Acl $f + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + $cred.UserName, 'Modify', 'Allow') + $acl.AddAccessRule($rule) + Set-Acl -Path $f -AclObject $acl + } catch { } + } + + try { + $proc = Start-Process -FilePath 'powershell.exe' ` + -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) ` + -Credential $cred -WindowStyle Hidden -PassThru -Wait -ErrorAction Stop + if (Test-Path $outFile) { + $code = (Get-Content -Path $outFile -ErrorAction SilentlyContinue | Select-Object -First 1) + if ($code -match '^\d+$') { return [int]$code } + } + return -1 + } catch { + Write-Finding C1 INFO "Start-Process -Credential failed: $_" return -1 - } catch { return -1 } + } finally { + Remove-Item $script -Force -ErrorAction SilentlyContinue + Remove-Item $outFile -Force -ErrorAction SilentlyContinue + } } -$c1code = Invoke-AsLowIL -Url $WireServerUrl -Headers $WireHeaders +$c1code = Invoke-AsStandardUser -Url $WireServerUrl -Headers $WireHeaders switch ($c1code) { {$_ -in 401,403} { Write-Finding C1 PASS "non-elevated WireServer call denied ($c1code)" } 200 { Write-Finding C1 FAIL "non-elevated WireServer call SUCCEEDED ($c1code) — AuthZ bypass" } - -1 { Write-Finding C1 INFO "could not spawn low-IL probe; rerun script from a standard-user PowerShell to exercise C1" } + -1 { Write-Finding C1 INFO "no standard-user available; rerun from elevated PowerShell so the harness can provision one, or rerun this script directly from a standard-user shell" } default { Write-Finding C1 INFO "non-elevated WireServer call code=$c1code (inconclusive)" } } diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 index 2e76d4a0..4effa3b5 100644 --- a/pentest/windows/Run-AllPenTests.ps1 +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -35,34 +35,45 @@ Write-Finding CFG INFO "platform = Windows $((Get-CimInstance Win32_OperatingSys Write-Finding CFG INFO "current identity: user=$env:USERNAME admin=$(Test-Administrator)" Write-Finding CFG INFO "service=$GpaServiceName running=$(Test-ServiceRunning $GpaServiceName) listener=$GpaListener" -$phases = @( - 'Phase2-Listener.ps1', - 'Phase3-AuthN-AuthZ.ps1', - 'Phase4-RulesFuzz.ps1', - 'Phase5-FileSystemAudit.ps1' -) +# Provision an ephemeral standard-user account so phases that require a +# non-elevated probe (C1) have a real, verifiable identity to run as. The +# account is removed in the finally block below regardless of how the run ends +# (success, failure, or Ctrl+C). Only attempted when running as Administrator. +if (Test-Administrator) { [void](New-PenTestStandardUser) } + +try { + $phases = @( + 'Phase2-Listener.ps1', + 'Phase3-AuthN-AuthZ.ps1', + 'Phase4-RulesFuzz.ps1', + 'Phase5-FileSystemAudit.ps1' + ) -foreach ($p in $phases) { - $script = Join-Path $PSScriptRoot $p - Write-Host '' - Write-Host "===== $p =====" -ForegroundColor Cyan - if ($p -eq 'Phase2-Listener.ps1' -and $SkipBurst) { - & $script -SkipBurst - } else { - & $script + foreach ($p in $phases) { + $script = Join-Path $PSScriptRoot $p + Write-Host '' + Write-Host "===== $p =====" -ForegroundColor Cyan + if ($p -eq 'Phase2-Listener.ps1' -and $SkipBurst) { + & $script -SkipBurst + } else { + & $script + } } -} -if ($IncludePhase4b) { - Write-Host '' - Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan - & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget -} + if ($IncludePhase4b) { + Write-Host '' + Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget + } -if (-not $NoReport) { - Write-Host '' - Write-Host '===== Generate-Report.ps1 =====' -ForegroundColor Cyan - & (Join-Path $PSScriptRoot 'Generate-Report.ps1') + if (-not $NoReport) { + Write-Host '' + Write-Host '===== Generate-Report.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Generate-Report.ps1') + } +} +finally { + Remove-PenTestStandardUser } Write-Host '' From 64f48182a1c88e9f3af1bd3f747b8290734e2e1f Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:01:52 -0700 Subject: [PATCH 13/37] Shortened the -Description for New-LocalUser --- pentest/windows/Common.psm1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index ce40ae8a..bcd87f19 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -182,9 +182,10 @@ function New-PenTestStandardUser { $secure = ConvertTo-SecureString $pwd -AsPlainText -Force try { + # New-LocalUser caps -Description at 48 chars. New-LocalUser -Name $name -Password $secure ` -FullName 'GPA pen-test ephemeral standard user' ` - -Description 'Created by pentest/windows harness; safe to delete.' ` + -Description 'GPA pentest harness; safe to delete.' ` -PasswordNeverExpires -UserMayNotChangePassword ` -AccountNeverExpires -ErrorAction Stop | Out-Null Add-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop From 39cce9fbbf9c83751ca82de26553b380129a45dc Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:04:56 -0700 Subject: [PATCH 14/37] =?UTF-8?q?rendered=20HTML=20=E2=80=94=20including?= =?UTF-8?q?=20the=20failures/info/per-phase=20tables=20and=20the=20artifac?= =?UTF-8?q?ts=20section=20=E2=80=94=20only=20reflects=20the=20latest=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pentest/windows/Generate-Report.ps1 | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 index 129f1ebe..e9d30154 100644 --- a/pentest/windows/Generate-Report.ps1 +++ b/pentest/windows/Generate-Report.ps1 @@ -53,6 +53,18 @@ function Get-Findings { Phase = Get-PhaseFor $parts[1] } } + + # Trim stale rows from earlier runs. Run-AllPenTests.ps1 writes a + # `CFG INFO platform = Windows ...` row as the first line of every run; + # we keep only rows from the most recent such marker onward so the report + # reflects the latest run, even when findings.tsv was not truncated. + $startIdx = -1 + for ($i = $rows.Count - 1; $i -ge 0; $i--) { + if ($rows[$i].Id -eq 'CFG' -and $rows[$i].Msg -like 'platform =*') { + $startIdx = $i; break + } + } + if ($startIdx -gt 0) { $rows = $rows[$startIdx..($rows.Count - 1)] } return $rows } From 21394a6710526e1bd4e976ff02f9b7409cb06318 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:11:21 -0700 Subject: [PATCH 15/37] Fixed. The credential, username, and "created" flag now live in $global: scope, --- pentest/windows/Common.psm1 | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index bcd87f19..ef52c150 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -115,14 +115,15 @@ function Test-Administrator { # Some pen-test scenarios (notably C1) require a probe to be issued from a # non-elevated, non-Administrator account. When the harness is launched as # Administrator, we provision a throwaway local user, share the credential -# in module scope so individual phase scripts can pick it up via -# Get-PenTestStandardUserCredential, and remove the account at the end of -# the run via Remove-PenTestStandardUser. The password is generated locally -# with a CSPRNG and never written to disk. +# in *global* scope (not $script:, because phase scripts re-import this +# module with -Force, which would wipe module-scope state) so individual +# phase scripts can pick it up via Get-PenTestStandardUserCredential, and +# remove the account at the end of the run via Remove-PenTestStandardUser. +# The password is generated locally with a CSPRNG and never written to disk. -$script:PenTestStdUser = $null # PSCredential -$script:PenTestStdUserName = $null # string -$script:PenTestStdUserCreated = $false # whether *we* created the account +if ($null -eq $global:PenTestStdUser) { $global:PenTestStdUser = $null } # PSCredential +if ($null -eq $global:PenTestStdUserName) { $global:PenTestStdUserName = $null } # string +if ($null -eq $global:PenTestStdUserCreated) { $global:PenTestStdUserCreated = $false } # whether *we* created the account function New-PenTestRandomPassword { param([int] $Length = 24) @@ -169,7 +170,7 @@ function New-PenTestStandardUser { Write-Finding PRE INFO "not Administrator; cannot provision standard-user account for C1" return $null } - if ($script:PenTestStdUser) { return $script:PenTestStdUser } + if ($global:PenTestStdUser) { return $global:PenTestStdUser } if (-not (Get-Command New-LocalUser -ErrorAction SilentlyContinue)) { Write-Finding PRE INFO "Microsoft.PowerShell.LocalAccounts module unavailable; cannot create standard-user" return $null @@ -190,12 +191,12 @@ function New-PenTestStandardUser { -AccountNeverExpires -ErrorAction Stop | Out-Null Add-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop - $script:PenTestStdUserName = $name - $script:PenTestStdUserCreated = $true - $script:PenTestStdUser = New-Object System.Management.Automation.PSCredential( + $global:PenTestStdUserName = $name + $global:PenTestStdUserCreated = $true + $global:PenTestStdUser = New-Object System.Management.Automation.PSCredential( "$env:COMPUTERNAME\$name", $secure) Write-Finding PRE INFO "provisioned ephemeral standard user '$name' (member of Users); password is in-memory only" - return $script:PenTestStdUser + return $global:PenTestStdUser } catch { Write-Finding PRE INFO "could not create ephemeral standard user: $_" # Best-effort partial cleanup. @@ -204,11 +205,11 @@ function New-PenTestStandardUser { } } -function Get-PenTestStandardUserCredential { $script:PenTestStdUser } +function Get-PenTestStandardUserCredential { $global:PenTestStdUser } function Remove-PenTestStandardUser { - if (-not $script:PenTestStdUserCreated) { return } - $name = $script:PenTestStdUserName + if (-not $global:PenTestStdUserCreated) { return } + $name = $global:PenTestStdUserName if (-not $name) { return } try { # Best-effort: kill any leftover processes the user may have, then drop @@ -226,9 +227,9 @@ function Remove-PenTestStandardUser { } catch { Write-Finding PRE INFO "could not fully remove ephemeral standard user '$name': $_ (manual cleanup required: 'net user $name /delete')" } finally { - $script:PenTestStdUser = $null - $script:PenTestStdUserName = $null - $script:PenTestStdUserCreated = $false + $global:PenTestStdUser = $null + $global:PenTestStdUserName = $null + $global:PenTestStdUserCreated = $false } } From 89b71ad3b0ebd644a226bdc17f043d7096d67573 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:15:30 -0700 Subject: [PATCH 16/37] fix --- pentest/windows/Common.psm1 | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index ef52c150..f7b8a8cc 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -121,9 +121,19 @@ function Test-Administrator { # remove the account at the end of the run via Remove-PenTestStandardUser. # The password is generated locally with a CSPRNG and never written to disk. -if ($null -eq $global:PenTestStdUser) { $global:PenTestStdUser = $null } # PSCredential -if ($null -eq $global:PenTestStdUserName) { $global:PenTestStdUserName = $null } # string -if ($null -eq $global:PenTestStdUserCreated) { $global:PenTestStdUserCreated = $false } # whether *we* created the account +# Initialise global state once. Set-StrictMode forbids reading an unset +# variable, so probe via Test-Path before assigning. +if (-not (Test-Path Variable:Global:PenTestStdUser)) { $global:PenTestStdUser = $null } # PSCredential +if (-not (Test-Path Variable:Global:PenTestStdUserName)) { $global:PenTestStdUserName = $null } # string +if (-not (Test-Path Variable:Global:PenTestStdUserCreated)) { $global:PenTestStdUserCreated = $false } # whether *we* created the account + +function Get-PenTestStdUserState { + [pscustomobject]@{ + Cred = (Get-Variable -Scope Global -Name PenTestStdUser -ValueOnly -ErrorAction SilentlyContinue) + Name = (Get-Variable -Scope Global -Name PenTestStdUserName -ValueOnly -ErrorAction SilentlyContinue) + Created = [bool](Get-Variable -Scope Global -Name PenTestStdUserCreated -ValueOnly -ErrorAction SilentlyContinue) + } +} function New-PenTestRandomPassword { param([int] $Length = 24) @@ -170,7 +180,8 @@ function New-PenTestStandardUser { Write-Finding PRE INFO "not Administrator; cannot provision standard-user account for C1" return $null } - if ($global:PenTestStdUser) { return $global:PenTestStdUser } + $existing = (Get-PenTestStdUserState).Cred + if ($existing) { return $existing } if (-not (Get-Command New-LocalUser -ErrorAction SilentlyContinue)) { Write-Finding PRE INFO "Microsoft.PowerShell.LocalAccounts module unavailable; cannot create standard-user" return $null @@ -205,11 +216,12 @@ function New-PenTestStandardUser { } } -function Get-PenTestStandardUserCredential { $global:PenTestStdUser } +function Get-PenTestStandardUserCredential { (Get-PenTestStdUserState).Cred } function Remove-PenTestStandardUser { - if (-not $global:PenTestStdUserCreated) { return } - $name = $global:PenTestStdUserName + $state = Get-PenTestStdUserState + if (-not $state.Created) { return } + $name = $state.Name if (-not $name) { return } try { # Best-effort: kill any leftover processes the user may have, then drop From 89ffddf9118e52f7c8e4277ad796183d348c580d Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:23:45 -0700 Subject: [PATCH 17/37] fix pass standand-user info --- pentest/windows/Phase3-AuthN-AuthZ.ps1 | 18 ++++++++++++++ pentest/windows/Run-AllPenTests.ps1 | 34 ++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index c4f097a6..8732546a 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -10,9 +10,27 @@ # listener" — is the same, so the probes (B1b, C7) test that invariant # end-to-end without caring how it's implemented. +[CmdletBinding()] +param( + # Path to a CliXml-serialized PSCredential for an ephemeral standard user, + # written by Run-AllPenTests.ps1. Optional — if absent, C1 falls back to + # whatever the current process token is and may record INFO instead of PASS. + [string] $StandardUserCredPath +) + $ErrorActionPreference = 'Continue' Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force +# Hydrate the standard-user credential (if the driver provided one) into the +# global slot that Get-PenTestStandardUserCredential reads. +if ($StandardUserCredPath -and (Test-Path $StandardUserCredPath)) { + try { + $global:PenTestStdUser = Import-Clixml -Path $StandardUserCredPath + } catch { + Write-Finding PRE INFO "could not load standard-user credential from $StandardUserCredPath : $_" + } +} + # ---------- Optional packet capture (P3-cap) --------------------------------- $pktmonStarted = $false if (Get-Command pktmon -ErrorAction SilentlyContinue) { diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 index 4effa3b5..23a556ca 100644 --- a/pentest/windows/Run-AllPenTests.ps1 +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -39,7 +39,20 @@ Write-Finding CFG INFO "service=$GpaServiceName running=$(Test-ServiceRunning $G # non-elevated probe (C1) have a real, verifiable identity to run as. The # account is removed in the finally block below regardless of how the run ends # (success, failure, or Ctrl+C). Only attempted when running as Administrator. -if (Test-Administrator) { [void](New-PenTestStandardUser) } +# +# We persist the PSCredential to a per-run CliXml file so child phase scripts +# can pick it up reliably — in-process module/global state is unreliable +# because each phase script does `Import-Module ... -Force` which can wipe it. +# Export-Clixml encrypts the SecureString with DPAPI scoped to the current +# user, so the file is unreadable by any other account on the box. +$stdUserCredPath = $null +if (Test-Administrator) { + $cred = New-PenTestStandardUser + if ($cred) { + $stdUserCredPath = Join-Path $ResultsDir '.stduser.cred.xml' + $cred | Export-Clixml -Path $stdUserCredPath -Force + } +} try { $phases = @( @@ -53,10 +66,18 @@ try { $script = Join-Path $PSScriptRoot $p Write-Host '' Write-Host "===== $p =====" -ForegroundColor Cyan - if ($p -eq 'Phase2-Listener.ps1' -and $SkipBurst) { - & $script -SkipBurst - } else { - & $script + switch ($p) { + 'Phase2-Listener.ps1' { + if ($SkipBurst) { & $script -SkipBurst } else { & $script } + } + 'Phase3-AuthN-AuthZ.ps1' { + if ($stdUserCredPath) { + & $script -StandardUserCredPath $stdUserCredPath + } else { + & $script + } + } + default { & $script } } } @@ -73,6 +94,9 @@ try { } } finally { + if ($stdUserCredPath -and (Test-Path $stdUserCredPath)) { + Remove-Item $stdUserCredPath -Force -ErrorAction SilentlyContinue + } Remove-PenTestStandardUser } From c8abadcb62a97471036cab3eb59c9ff45f9ac718 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:30:17 -0700 Subject: [PATCH 18/37] update cleanup logic --- pentest/windows/Common.psm1 | 64 ++++++++++++++++++++------ pentest/windows/Phase3-AuthN-AuthZ.ps1 | 53 +++++++++++++++++---- 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index f7b8a8cc..76f9da3d 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -223,26 +223,64 @@ function Remove-PenTestStandardUser { if (-not $state.Created) { return } $name = $state.Name if (-not $name) { return } + + $errors = New-Object System.Collections.Generic.List[string] + + # 1) Best-effort: kill any leftover processes the user may have. Use + # Get-Process -IncludeUserName (PS 5+) since CimInstance Win32_Process + # has no .GetOwner() method (that's WMI-only and was removed in PS 7). + try { + $procs = Get-Process -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.UserName -like "*\$name" } + foreach ($pr in $procs) { + try { Stop-Process -Id $pr.Id -Force -ErrorAction Stop } + catch { $errors.Add("kill PID=$($pr.Id): $_") | Out-Null } + } + } catch { $errors.Add("enumerate processes: $_") | Out-Null } + + # 2) Drop group membership (idempotent; ignore "not a member"). + try { + Remove-LocalGroupMember -Group 'Users' -Member $name -ErrorAction Stop + } catch { + if ($_.Exception.Message -notmatch 'not a member|could not be found') { + $errors.Add("remove from Users: $_") | Out-Null + } + } + + # 3) Remove the local account itself. This is the step that actually + # matters; do not skip it if (1)/(2) failed. + $accountRemoved = $false + try { + if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) { + Remove-LocalUser -Name $name -ErrorAction Stop + } + $accountRemoved = $true + } catch { + $errors.Add("Remove-LocalUser: $_") | Out-Null + } + + # 4) Remove the user profile directory and its Win32_UserProfile entry. try { - # Best-effort: kill any leftover processes the user may have, then drop - # the profile dir, group membership, and the account itself. - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { $_.GetOwner().User -eq $name } | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } - Remove-LocalGroupMember -Group 'Users' -Member $name -ErrorAction SilentlyContinue - Remove-LocalUser -Name $name -ErrorAction Stop $profile = Join-Path $env:SystemDrive "Users\$name" if (Test-Path $profile) { Remove-Item -Path $profile -Recurse -Force -ErrorAction SilentlyContinue } + Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue | + Where-Object { $_.LocalPath -like "*\$name" } | + ForEach-Object { Remove-CimInstance -InputObject $_ -ErrorAction SilentlyContinue } + } catch { $errors.Add("profile cleanup: $_") | Out-Null } + + if ($accountRemoved -and $errors.Count -eq 0) { Write-Finding PRE INFO "removed ephemeral standard user '$name'" - } catch { - Write-Finding PRE INFO "could not fully remove ephemeral standard user '$name': $_ (manual cleanup required: 'net user $name /delete')" - } finally { - $global:PenTestStdUser = $null - $global:PenTestStdUserName = $null - $global:PenTestStdUserCreated = $false + } elseif ($accountRemoved) { + Write-Finding PRE INFO "removed ephemeral standard user '$name' with warnings: $($errors -join '; ')" + } else { + Write-Finding PRE INFO "could not fully remove ephemeral standard user '$name': $($errors -join '; ') (manual cleanup: 'net user $name /delete' and 'rmdir /s /q C:\Users\$name')" } + + $global:PenTestStdUser = $null + $global:PenTestStdUserName = $null + $global:PenTestStdUserCreated = $false } Export-ModuleMember -Function * -Variable * diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index 8732546a..00a8673c 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -134,27 +134,39 @@ if ($r.Code -eq 200) { # by Run-AllPenTests.ps1 (see Common.psm1::New-PenTestStandardUser) and execute # the probe via Start-Process -Credential, capturing the HTTP status code from # a temp file. The account is removed by the driver after all phases finish. +# +# Return codes: +# >=0 HTTP status code (or 0 for "request never reached server") +# -1 could not run probe under standard user (Start-Process failed, etc.) +# -2 no standard-user credential available at all function Invoke-AsStandardUser { param([string] $Url, [hashtable] $Headers) if (-not (Test-Administrator)) { + Write-Finding C1 INFO "harness is already non-elevated; running probe under current token ($env:USERNAME)" return (Get-HttpStatus -Url $Url -Headers $Headers).Code } $cred = Get-PenTestStandardUserCredential - if (-not $cred) { return -1 } + if (-not $cred) { + Write-Finding C1 INFO "no PenTest standard-user credential in module state (was Run-AllPenTests.ps1 the entry point and did New-PenTestStandardUser succeed?)" + return -2 + } + Write-Finding C1 INFO "C1 probe will run as '$($cred.UserName)'" # Build a small child script that performs the probe and writes the status - # code to $outFile. Both $script and $outFile must be readable/writable by - # the standard user, so use a world-accessible TEMP location and grant the - # account explicit read/write on the two files. + # code (and any error) to $outFile / $errFile. Use C:\Windows\Temp because + # %TEMP% on the elevated session resolves to the admin's profile, which the + # standard user cannot read; C:\Windows\Temp is world-writable. $hdrLiteral = ($Headers.GetEnumerator() | ForEach-Object { "'{0}'='{1}'" -f ($_.Key -replace "'","''"), ($_.Value -replace "'","''") }) -join ';' - $tmpDir = $env:TEMP + $tmpDir = Join-Path $env:SystemRoot 'Temp' + if (-not (Test-Path $tmpDir)) { $tmpDir = $env:TEMP } $stamp = [guid]::NewGuid().ToString('N') $script = Join-Path $tmpDir "gpapen-c1-$stamp.ps1" $outFile = Join-Path $tmpDir "gpapen-c1-$stamp.out" + $errFile = Join-Path $tmpDir "gpapen-c1-$stamp.err" $body = @" `$ProgressPreference = 'SilentlyContinue' `$code = 0 @@ -171,8 +183,8 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII "@ Set-Content -Path $script -Value $body -Encoding UTF8 - # Ensure the standard user can read the script and write the output file. - foreach ($f in @($script, $outFile)) { + # Ensure the standard user can read the script and write the output files. + foreach ($f in @($script, $outFile, $errFile)) { if (-not (Test-Path $f)) { New-Item -Path $f -ItemType File -Force | Out-Null } try { $acl = Get-Acl $f @@ -180,16 +192,35 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII $cred.UserName, 'Modify', 'Allow') $acl.AddAccessRule($rule) Set-Acl -Path $f -AclObject $acl - } catch { } + } catch { + Write-Finding C1 INFO "could not grant ACE on $f to $($cred.UserName): $_" + } } + # Pin the working directory to a path the standard user can access. + # Default Start-Process inherits $PWD, which on an admin shell is usually + # the admin's profile and yields "The directory name is invalid" for the + # new token. C:\Windows is read+execute for Users. + $workDir = $env:SystemRoot + Write-Finding C1 INFO "Start-Process: file=powershell.exe workdir=$workDir script=$script out=$outFile" + try { $proc = Start-Process -FilePath 'powershell.exe' ` -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) ` - -Credential $cred -WindowStyle Hidden -PassThru -Wait -ErrorAction Stop + -Credential $cred -WorkingDirectory $workDir ` + -RedirectStandardError $errFile ` + -WindowStyle Hidden -PassThru -Wait -ErrorAction Stop + Write-Finding C1 INFO "child process exited (PID=$($proc.Id), ExitCode=$($proc.ExitCode))" if (Test-Path $outFile) { $code = (Get-Content -Path $outFile -ErrorAction SilentlyContinue | Select-Object -First 1) if ($code -match '^\d+$') { return [int]$code } + Write-Finding C1 INFO "outfile contents not numeric: '$code'" + } else { + Write-Finding C1 INFO "outfile $outFile not produced" + } + if (Test-Path $errFile) { + $errTxt = (Get-Content -Path $errFile -Raw -ErrorAction SilentlyContinue) + if ($errTxt) { Write-Finding C1 INFO "child stderr: $($errTxt.Trim())" } } return -1 } catch { @@ -198,13 +229,15 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII } finally { Remove-Item $script -Force -ErrorAction SilentlyContinue Remove-Item $outFile -Force -ErrorAction SilentlyContinue + Remove-Item $errFile -Force -ErrorAction SilentlyContinue } } $c1code = Invoke-AsStandardUser -Url $WireServerUrl -Headers $WireHeaders switch ($c1code) { {$_ -in 401,403} { Write-Finding C1 PASS "non-elevated WireServer call denied ($c1code)" } 200 { Write-Finding C1 FAIL "non-elevated WireServer call SUCCEEDED ($c1code) — AuthZ bypass" } - -1 { Write-Finding C1 INFO "no standard-user available; rerun from elevated PowerShell so the harness can provision one, or rerun this script directly from a standard-user shell" } + -1 { Write-Finding C1 INFO "could not run C1 probe as standard user (see preceding INFO rows for diagnostics)" } + -2 { Write-Finding C1 INFO "no standard-user credential available; launch via Run-AllPenTests.ps1 from an elevated shell, or run this script from a standard-user shell directly" } default { Write-Finding C1 INFO "non-elevated WireServer call code=$c1code (inconclusive)" } } From dfb3d697d4241c95a1cedb1308968231abf3f9d5 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:40:40 -0700 Subject: [PATCH 19/37] fix --- pentest/windows/Common.psm1 | 64 +++++++++++++++++++++- pentest/windows/Generate-Report.ps1 | 9 ++- pentest/windows/Phase3-AuthN-AuthZ.ps1 | 20 +++---- pentest/windows/Phase5-FileSystemAudit.ps1 | 2 +- 4 files changed, 80 insertions(+), 15 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index 76f9da3d..e750c7b4 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -21,7 +21,49 @@ $script:Phase4bLog = Join-Path $ResultsDir 'phase4b_local_rules.log' # and proxy_agent_setup/src/main.rs). $script:GpaServiceName = 'GuestProxyAgent' $script:GpaInstallRoot = "$env:SystemDrive\WindowsAzure\ProxyAgent" -$script:GpaExe = Join-Path $GpaInstallRoot 'GuestProxyAgent\GuestProxyAgent.exe' + +# Resolve the actual GuestProxyAgent.exe path. The install layout uses a +# versioned package directory (e.g. ...\Package_1.0.38\GuestProxyAgent.exe), so +# the canonical "...\Package\GuestProxyAgent.exe" path doesn't exist on +# real installs. Discover from the service's ImagePath first, then fall back to +# a Package* glob, then to the static legacy path. +function Resolve-GpaExePath { + param([string] $ServiceName, [string] $InstallRoot) + + try { + $svc = Get-CimInstance -ClassName Win32_Service ` + -Filter "Name='$ServiceName'" -ErrorAction Stop + if ($svc -and $svc.PathName) { + # PathName may be quoted and may carry trailing args, e.g. + # "C:\WindowsAzure\ProxyAgent\Package_1.0.38\GuestProxyAgent.exe" --service + $pn = $svc.PathName.Trim() + if ($pn.StartsWith('"')) { + $end = $pn.IndexOf('"', 1) + if ($end -gt 0) { $pn = $pn.Substring(1, $end - 1) } + } else { + $space = $pn.IndexOf(' ') + if ($space -gt 0) { $pn = $pn.Substring(0, $space) } + } + if ($pn -and (Test-Path -LiteralPath $pn)) { return $pn } + } + } catch { } + + # Fallback: newest Package* directory under the install root. + if (Test-Path -LiteralPath $InstallRoot) { + $cand = Get-ChildItem -LiteralPath $InstallRoot -Directory ` + -Filter 'Package*' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + ForEach-Object { Join-Path $_.FullName 'GuestProxyAgent.exe' } | + Where-Object { Test-Path -LiteralPath $_ } | + Select-Object -First 1 + if ($cand) { return $cand } + } + + # Last resort: legacy/expected static path. May not exist; callers handle that. + return (Join-Path $InstallRoot 'Package\GuestProxyAgent.exe') +} + +$script:GpaExe = Resolve-GpaExePath -ServiceName $GpaServiceName -InstallRoot $GpaInstallRoot $script:GpaLogDir = Join-Path $GpaInstallRoot 'Logs' $script:GpaEventDir = Join-Path $GpaInstallRoot 'Events' $script:GpaKeyDir = Join-Path $GpaInstallRoot 'Keys' @@ -52,7 +94,7 @@ function Write-Finding { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Id, - [Parameter(Mandatory)] [ValidateSet('PASS','FAIL','INFO')] [string] $Status, + [Parameter(Mandatory)] [ValidateSet('PASS','FAIL','INFO','DEBUG')] [string] $Status, [Parameter(Mandatory)] [string] $Message ) # Sanitize message: strip TAB and CR/LF so the TSV stays one row per record. @@ -61,10 +103,26 @@ function Write-Finding { $row = "$ts`t$Id`t$Status`t$clean" Add-Content -Path $Findings -Value $row -Encoding UTF8 - $color = switch ($Status) { 'PASS' {'Green'} 'FAIL' {'Red'} default {'Yellow'} } + $color = switch ($Status) { + 'PASS' { 'Green' } + 'FAIL' { 'Red' } + 'DEBUG' { 'DarkGray' } + default { 'Yellow' } + } Write-Host "[$Status] $Id`t$clean" -ForegroundColor $color } +# Write a diagnostic trace row. Persisted to findings.tsv with status=DEBUG so +# the audit trail is complete, but Generate-Report.ps1 filters DEBUG rows out +# of the rendered report so they don't show up as test findings. +function Write-FindingDebug { + param( + [Parameter(Mandatory)] [string] $Id, + [Parameter(Mandatory)] [string] $Message + ) + Write-Finding -Id $Id -Status DEBUG -Message $Message +} + # --- Convenience wrappers ---------------------------------------------------- function Test-ServiceRunning { diff --git a/pentest/windows/Generate-Report.ps1 b/pentest/windows/Generate-Report.ps1 index e9d30154..99766d39 100644 --- a/pentest/windows/Generate-Report.ps1 +++ b/pentest/windows/Generate-Report.ps1 @@ -45,10 +45,14 @@ function Get-Findings { if (-not $line) { continue } $parts = $line -split "`t", 4 while ($parts.Count -lt 4) { $parts += '' } + $status = $parts[2].ToUpper() + # DEBUG rows are diagnostic traces (e.g. C1 spawn details); keep them in + # the TSV for offline inspection but never show them in the report. + if ($status -eq 'DEBUG') { continue } $rows += [pscustomobject]@{ Ts = $parts[0] Id = $parts[1] - Status = $parts[2].ToUpper() + Status = $status Msg = $parts[3] Phase = Get-PhaseFor $parts[1] } @@ -58,6 +62,9 @@ function Get-Findings { # `CFG INFO platform = Windows ...` row as the first line of every run; # we keep only rows from the most recent such marker onward so the report # reflects the latest run, even when findings.tsv was not truncated. + # NOTE: the marker is searched against the *unfiltered* file contents + # because we already stripped DEBUG rows above; the CFG row is INFO so it + # is always present in $rows. $startIdx = -1 for ($i = $rows.Count - 1; $i -ge 0; $i--) { if ($rows[$i].Id -eq 'CFG' -and $rows[$i].Msg -like 'platform =*') { diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index 00a8673c..c4cae670 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -143,16 +143,16 @@ function Invoke-AsStandardUser { param([string] $Url, [hashtable] $Headers) if (-not (Test-Administrator)) { - Write-Finding C1 INFO "harness is already non-elevated; running probe under current token ($env:USERNAME)" + Write-FindingDebug C1 "harness is already non-elevated; running probe under current token ($env:USERNAME)" return (Get-HttpStatus -Url $Url -Headers $Headers).Code } $cred = Get-PenTestStandardUserCredential if (-not $cred) { - Write-Finding C1 INFO "no PenTest standard-user credential in module state (was Run-AllPenTests.ps1 the entry point and did New-PenTestStandardUser succeed?)" + Write-FindingDebug C1 "no PenTest standard-user credential in module state (was Run-AllPenTests.ps1 the entry point and did New-PenTestStandardUser succeed?)" return -2 } - Write-Finding C1 INFO "C1 probe will run as '$($cred.UserName)'" + Write-FindingDebug C1 "C1 probe will run as '$($cred.UserName)'" # Build a small child script that performs the probe and writes the status # code (and any error) to $outFile / $errFile. Use C:\Windows\Temp because @@ -193,7 +193,7 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII $acl.AddAccessRule($rule) Set-Acl -Path $f -AclObject $acl } catch { - Write-Finding C1 INFO "could not grant ACE on $f to $($cred.UserName): $_" + Write-FindingDebug C1 "could not grant ACE on $f to $($cred.UserName): $_" } } @@ -202,7 +202,7 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII # the admin's profile and yields "The directory name is invalid" for the # new token. C:\Windows is read+execute for Users. $workDir = $env:SystemRoot - Write-Finding C1 INFO "Start-Process: file=powershell.exe workdir=$workDir script=$script out=$outFile" + Write-FindingDebug C1 "Start-Process: file=powershell.exe workdir=$workDir script=$script out=$outFile" try { $proc = Start-Process -FilePath 'powershell.exe' ` @@ -210,21 +210,21 @@ Set-Content -Path '$outFile' -Value `$code -Encoding ASCII -Credential $cred -WorkingDirectory $workDir ` -RedirectStandardError $errFile ` -WindowStyle Hidden -PassThru -Wait -ErrorAction Stop - Write-Finding C1 INFO "child process exited (PID=$($proc.Id), ExitCode=$($proc.ExitCode))" + Write-FindingDebug C1 "child process exited (PID=$($proc.Id), ExitCode=$($proc.ExitCode))" if (Test-Path $outFile) { $code = (Get-Content -Path $outFile -ErrorAction SilentlyContinue | Select-Object -First 1) if ($code -match '^\d+$') { return [int]$code } - Write-Finding C1 INFO "outfile contents not numeric: '$code'" + Write-FindingDebug C1 "outfile contents not numeric: '$code'" } else { - Write-Finding C1 INFO "outfile $outFile not produced" + Write-FindingDebug C1 "outfile $outFile not produced" } if (Test-Path $errFile) { $errTxt = (Get-Content -Path $errFile -Raw -ErrorAction SilentlyContinue) - if ($errTxt) { Write-Finding C1 INFO "child stderr: $($errTxt.Trim())" } + if ($errTxt) { Write-FindingDebug C1 "child stderr: $($errTxt.Trim())" } } return -1 } catch { - Write-Finding C1 INFO "Start-Process -Credential failed: $_" + Write-FindingDebug C1 "Start-Process -Credential failed: $_" return -1 } finally { Remove-Item $script -Force -ErrorAction SilentlyContinue diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 index 0abb54aa..9fecefb2 100644 --- a/pentest/windows/Phase5-FileSystemAudit.ps1 +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -27,7 +27,7 @@ function Test-AclTight { [switch] $SecretsOnly # if set, even READ access from non-admins is a FAIL ) if (-not (Test-Path $Path)) { - Write-Finding "E1[$Path]" INFO "missing" + Write-Finding "E1[$Path]" INFO "path does not exist on disk; nothing to audit (GPA may not be installed at the expected location)" return } try { $acl = Get-Acl $Path } catch { From cc4454f8673e0c9757d5fa10f6c5fd2c2b65669c Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 14 May 2026 14:50:11 -0700 Subject: [PATCH 20/37] run 4b tests --- pentest/windows/Phase5-FileSystemAudit.ps1 | 16 ++++++++++++---- pentest/windows/Run-AllPenTests.ps1 | 19 +++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 index 9fecefb2..b623ba27 100644 --- a/pentest/windows/Phase5-FileSystemAudit.ps1 +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -85,11 +85,19 @@ foreach ($f in $candidates) { # --- WFP / service snapshots (informational, analogous to bpftool) ---------- $svc = Get-Service -Name $GpaServiceName -ErrorAction SilentlyContinue -if ($svc) { - $svcInfo = "$($svc.Name) status=$($svc.Status) startType=$($svc.StartType)" - Write-Finding P5-svc INFO "GuestProxyAgent service: $svcInfo" -} else { +if (-not $svc) { Write-Finding P5-svc FAIL "GuestProxyAgent service not installed" +} else { + $svcInfo = "$($svc.Name) status=$($svc.Status) startType=$($svc.StartType)" + if ($svc.Status -eq 'Running' -and $svc.StartType -eq 'Automatic') { + Write-Finding P5-svc PASS "GuestProxyAgent service: $svcInfo" + } elseif ($svc.Status -ne 'Running') { + Write-Finding P5-svc FAIL "GuestProxyAgent service not running: $svcInfo" + } else { + # Running but StartType != Automatic (Manual / Disabled-but-running) - + # operational anomaly worth surfacing without failing the suite. + Write-Finding P5-svc INFO "GuestProxyAgent service running but StartType is not Automatic: $svcInfo" + } } if (Get-Command netsh -ErrorAction SilentlyContinue) { diff --git a/pentest/windows/Run-AllPenTests.ps1 b/pentest/windows/Run-AllPenTests.ps1 index 23a556ca..d00282a3 100644 --- a/pentest/windows/Run-AllPenTests.ps1 +++ b/pentest/windows/Run-AllPenTests.ps1 @@ -3,16 +3,16 @@ # Usage (from an elevated PowerShell): # powershell -ExecutionPolicy Bypass -File .\Run-AllPenTests.ps1 [options] # -# By default runs phases 2, 3, 4 (URL diff), 5, then renders the HTML report. -# Phase 4b (local-file rules) is OPT-IN with -IncludePhase4b because it -# mutates %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ and requires -# useLocalFileRules=true on the fabric. +# By default runs phases 2, 3, 4 (URL diff), 4b (local-file rules), 5, then +# renders the HTML report. Phase 4b mutates +# %SystemDrive%\WindowsAzure\ProxyAgent\Rules\ and only produces meaningful +# PASS/FAIL when the fabric has provisioned the agent with +# useLocalFileRules=true; otherwise scenarios short-circuit as SKIP/INFO. [CmdletBinding()] param( [switch] $TruncateFindings, [switch] $SkipBurst, - [switch] $IncludePhase4b, [ValidateSet('imds', 'wireserver', 'both')] [string] $Phase4bTarget = 'both', [switch] $NoReport @@ -25,6 +25,7 @@ if (-not (Test-Administrator)) { Write-Warning 'Not running as Administrator. E1 ACL audit, pktmon capture, and Phase 4b will be limited or skipped.' } + if ($TruncateFindings) { Set-Content -Path $Findings -Value '' -Encoding UTF8 Write-Host "Truncated $Findings" @@ -81,11 +82,9 @@ try { } } - if ($IncludePhase4b) { - Write-Host '' - Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan - & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget - } + Write-Host '' + Write-Host '===== Phase4b-LocalRules.ps1 =====' -ForegroundColor Cyan + & (Join-Path $PSScriptRoot 'Phase4b-LocalRules.ps1') -Target $Phase4bTarget if (-not $NoReport) { Write-Host '' From b527ab069e9b85525aaf3c6a8dbeff6e699c87c9 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 19 May 2026 16:39:34 +0000 Subject: [PATCH 21/37] json_read_from_file to skip the BOM bytes before deserialize the content. --- proxy_agent_shared/src/misc_helpers.rs | 61 +++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/proxy_agent_shared/src/misc_helpers.rs b/proxy_agent_shared/src/misc_helpers.rs index 9d121c0d..38ed5e18 100644 --- a/proxy_agent_shared/src/misc_helpers.rs +++ b/proxy_agent_shared/src/misc_helpers.rs @@ -249,8 +249,18 @@ pub fn json_read_from_file(file_path: &Path) -> Result where T: DeserializeOwned, { - let file = File::open(file_path)?; - let obj: T = serde_json::from_reader(file)?; + // Read the whole file to bytes so we can transparently skip an optional + // UTF-8 BOM (EF BB BF). serde_json does not strip a BOM and would otherwise + // fail the parse with "expected value at line 1 column 1" for any file + // produced by editors / tools that default to BOM-prefixed UTF-8 (e.g. + // Windows PowerShell 5.1's `Set-Content -Encoding UTF8`, Notepad, VS Code's + // "UTF-8 with BOM"). + let bytes = fs::read(file_path)?; + let payload = match bytes.as_slice() { + [0xEF, 0xBB, 0xBF, rest @ ..] => rest, + rest => rest, + }; + let obj: T = serde_json::from_slice(payload)?; Ok(obj) } @@ -587,6 +597,53 @@ mod tests { _ = fs::remove_dir_all(&temp_test_path); } + #[test] + fn json_read_from_file_skips_utf8_bom_test() { + #[derive(Serialize, Deserialize, PartialEq, Debug)] + struct Small { + name: String, + value: u32, + } + + let mut temp_test_path = env::temp_dir(); + temp_test_path.push("json_read_from_file_skips_utf8_bom_test"); + _ = fs::remove_dir_all(&temp_test_path); + super::try_create_folder(&temp_test_path).unwrap(); + + let body = r#"{"name":"hello","value":42}"#; + let expected = Small { + name: "hello".to_string(), + value: 42, + }; + + // 1. BOM-less file parses (regression guard for existing behavior). + let no_bom = temp_test_path.join("no_bom.json"); + fs::write(&no_bom, body.as_bytes()).unwrap(); + assert_eq!( + super::json_read_from_file::(&no_bom).unwrap(), + expected + ); + + // 2. UTF-8 BOM-prefixed file parses (the actual fix). + let with_bom = temp_test_path.join("with_bom.json"); + let mut bytes = Vec::with_capacity(3 + body.len()); + bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); + bytes.extend_from_slice(body.as_bytes()); + fs::write(&with_bom, &bytes).unwrap(); + assert_eq!( + super::json_read_from_file::(&with_bom).unwrap(), + expected + ); + + // 3. A bare BOM (no payload) still surfaces as a parse error rather + // than being silently treated as an empty document. + let bom_only = temp_test_path.join("bom_only.json"); + fs::write(&bom_only, &[0xEF, 0xBB, 0xBF]).unwrap(); + assert!(super::json_read_from_file::(&bom_only).is_err()); + + _ = fs::remove_dir_all(&temp_test_path); + } + #[test] fn path_to_string_test() { let path = "path_to_string_test"; From baedf101ab0f472b1fd3385d87dbb322ce98b829 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 19 May 2026 10:06:06 -0700 Subject: [PATCH 22/37] update audit mode test --- pentest/linux/DESIGN.md | 4 +- pentest/linux/phase4b_local_rules/run.py | 56 +++++++++++++++++------- pentest/linux/test_catalog.py | 16 +++---- pentest/windows/Phase4b-LocalRules.ps1 | 27 +++++++++--- pentest/windows/TestCatalog.psm1 | 4 +- 5 files changed, 74 insertions(+), 33 deletions(-) diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index 87ad6def..5f8a7f22 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -153,7 +153,7 @@ If either pre-condition fails, flip the flag / mode from your control plane (or |----|------------|--------------------------|-------------------| | `IMDS-S1-disabled-allow` | `mode=disabled`, `defaultAccess=allow`, no rules | `/metadata/instance` → 200, `/metadata/identity/oauth2/token` → 200 (control) | **Control / smoke test.** Establishes that the harness, the listener, the eBPF redirect and IMDS itself are all healthy *before* we start denying. If S1 fails, every later "deny worked" result is suspect because we haven't proven we can ever get a 200. | | `IMDS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | `/metadata/instance` → 403, `/metadata/versions` → 403 | **Default-deny invariant.** With enforce mode and no allow rules, *everything* must be blocked. A FAIL here means the engine fails-open on missing rules — the highest-severity AuthZ regression possible. | -| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules | `/metadata/instance` → **200** (audit-only) | **Audit-mode separation.** Confirms `mode=audit` LOGS denials but does not BLOCK — critical for safe rule rollouts where operators want to observe impact before flipping to enforce. (Currently recorded as INFO from local rules: the merge logic in `local_rules.rs` honors `mode` only from the remote rule, so audit mode must be tested via a fabric/mock-fabric rule.) | +| `IMDS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | `/metadata/instance` → **403**, `/metadata/versions` → **403** | **Regression guard for local-`mode` isolation.** `LocalAuthorizationRulesFile` deliberately has no `mode` field, and `merge_authorization_item()` keeps the remote rule's mode. Since PRE has confirmed the live remote mode is `enforce`, writing `mode=audit` locally must be silently dropped and all probes must 403. A 200 here means somebody wired local `mode` into the merge logic — audit-mode pass-through becomes attacker-controllable from any process that can write to the Rules dir. | | `IMDS-S4-allow-one-path` | `enforce` + `deny`; allow `/metadata/instance` for current identity | instance → 200, token → 403, versions → 403 | **Path-scoping precision.** Allowing one path must not leak access to siblings. Specifically guards against the `oauth2/token` endpoint (which mints managed-identity tokens) being reachable when only `/metadata/instance` was authorized. | | `IMDS-S5-wrong-identity` | Same allow but bound to non-existent user | instance → 403 | **Identity is required.** A path-only allow must not match if the identity binding fails. Catches engines that short-circuit on path match and forget to AND with identity — a privilege-escalation if any in-guest user can reach an allow-listed path. | | `IMDS-S6-encoding-bypass` | Allow `/metadata/instance` only | `/metadata/identity/oauth2%2Ftoken`, `%2f`, `%252F`, `%3F`, `./../` → all **403** | **Canonicalization of the request URL.** The classic AuthZ-bypass family from the SSRF/WAF world applied to GPA: percent-encoded slashes, double-encoding, and dot-segments must not let a request "look like" `/metadata/instance` to the matcher while actually reaching `/metadata/identity/oauth2/token`. (Dot-segment probes accept 403 OR 404 — both prove the bypass attempt did not reach a permitted endpoint.) | @@ -179,7 +179,7 @@ WireServer is privileged-by-default at the proxy layer (`runAsElevated` required |----|------------|--------------------------|-------------------| | `WS-S1-disabled-allow` | `disabled`, `allow`, no rules | goalstate → 200, versions → 200 (control) | **Control / smoke test for the privileged path.** WireServer requires elevated identity by default, so this also confirms the test process is correctly recognized as root — prerequisite for every later WS scenario. | | `WS-S2-enforce-deny-empty` | `enforce` + `deny`, no rules | goalstate → 403, versions → 403, sharedConfig → 403 | **Default-deny on the high-value target.** WireServer hands out goal-state, certificates and extension config; an empty-rules fail-open here directly leaks VM secrets. Highest-severity gate in the WS suite. | -| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules | goalstate → **200** (audit-only) | **Audit-mode separation for WS** (same purpose as IMDS-S3, recorded as INFO via local rules for the same merge-semantics reason). Audit on WS is operationally critical because operators need to dry-run rules before risking guest-agent breakage. | +| `WS-S3-audit-deny-empty` | `audit` + `deny`, no rules (mode set in LOCAL file) | goalstate → **403**, versions → **403** | **Regression guard for local-`mode` isolation on WS** (same purpose as IMDS-S3). The WireServer path is the more sensitive of the two — honoring a local `audit` would silently pass goalstate / certificates / extensionsConfig with deny-but-allow semantics. | | `WS-S4-allow-goalstate-only` | Allow `/machine/` with `comp=goalstate` only | goalstate → 200; sharedConfig / hostingenvironmentconfig / certificates / extensionsConfig / versions → 403 | **Comp-scoped path matching.** All WS endpoints share the same path (`/machine/`) and differ only by query `comp=`. Confirms the engine's combined path+query matching is precise enough to allow `goalstate` while blocking the much more sensitive `certificates`/`extensionsConfig`. | | `WS-S5-wrong-identity` | Allow goalstate but bound to non-matching user | goalstate → 403 | **Identity-required on WS.** Same purpose as IMDS-S5 but on the privileged target: an identity mismatch must turn an otherwise-matching path+query rule into a deny. | | `WS-S6-encoding-bypass` | Allow only `goalstate` | `/machine%2F?comp=certificates`, `/machine/%3Fcomp=certificates`, `./../`, `//machine///`, `comp=Certificates` (case mismatch) → all 403; `comp=GOALSTATE` (case-insensitive value) → 200 (verifies the lowercase normalization is applied symmetrically). | **Canonicalization on WS.** Mirrors IMDS-S6 plus two WS-specific concerns: (1) extra slashes (`//machine///`) must not bypass the `/machine/` prefix check, (2) comp-value comparison must be case-insensitive **on both sides** — if normalization is one-sided, an attacker writes `comp=Certificates` to evade an allow that was authored as `comp=certificates`, or vice versa. | diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index b840eac3..fe33c689 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -295,17 +295,29 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # S3: audit mode — NOT TESTABLE from local-rules. - # The local-rules merge logic in proxy_agent/src/key_keeper/local_rules.rs - # only honors `defaultAccess` and `rules` from the local file; `mode` is - # taken entirely from the remote (fabric-delivered) AuthorizationItem. - # Since the live remote mode is `enforce`, any `mode=audit` set in a - # local rules file is silently dropped, so this scenario can't be - # exercised here. Recorded as INFO for traceability. - record(f"{pfx}-S3-audit-deny-empty", "INFO", - "audit-mode behavior is not testable via local rules: " - "merge_authorization_item() takes `mode` from remote rules only " - "(see proxy_agent/src/key_keeper/local_rules.rs).") + # S3: regression guard — the agent MUST IGNORE `mode` written into the + # local rules file. The merge logic in proxy_agent/src/key_keeper/local_rules.rs + # takes `mode` from the remote rule only; `LocalAuthorizationRulesFile` + # has no `mode` field, so serde silently drops it. + # PRE has confirmed the effective remote mode is `enforce`, so writing + # mode=audit + defaultAccess=deny + empty rules must still 403. A 200 here + # would mean the agent started honoring the local `mode` (audit-mode + # pass-through) — a security regression worth a high-sev finding. + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="local mode=audit must be IGNORED; remote enforce + local deny → 403", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-s3", + "rules": {}, + }, + probes=[ + Probe("instance_denied", "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) # S4: explicit allow for /metadata/instance, deny everything else scenarios.append(Scenario( @@ -553,11 +565,23 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # WS-S3: audit only — NOT TESTABLE from local-rules (see IMDS-S3 note). - record(f"{pfx}-S3-audit-deny-empty", "INFO", - "audit-mode behavior is not testable via local rules: " - "merge_authorization_item() takes `mode` from remote rules only " - "(see proxy_agent/src/key_keeper/local_rules.rs).") + # WS-S3: regression guard — the agent MUST IGNORE `mode` from the local + # rules file. Same contract as IMDS-S3. + scenarios.append(Scenario( + sid=f"{pfx}-S3-audit-deny-empty", + target=target, + description="local mode=audit must be IGNORED; remote enforce + local deny → 403", + rules={ + "defaultAccess": "deny", + "mode": "audit", + "id": "pentest-ws-s3", + "rules": {}, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) # WS-S4: only allow goalstate; everything else 403 scenarios.append(Scenario( diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index d8f00a4d..23f6d07d 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -218,10 +218,10 @@ class TestInfo(TypedDict, total=False): "automation": "Writes `mode=enforce, defaultAccess=deny, rules={}`; expects every probe → 403.", }, "IMDS-S3-audit-deny-empty": { - "title": "Audit mode passes traffic through (logs deny, returns 200)", - "design": "When `mode=audit`, deny decisions should be LOGGED but the request must succeed.", - "automation": "Writes `mode=audit, defaultAccess=deny`; expects `/metadata/instance` → 200.", - "fix": "Audit mode currently returned 403 — the engine is treating audit identical to enforce. In `proxy_agent/src/proxy/proxy_authorizer.rs` (and authorization_rules.rs), when the configured `mode == 'audit'` log the decision and return Authorize::Allowed instead of Forbidden. Add a unit test exercising both audit-deny and audit-allow paths.", + "title": "Local `mode` field must be ignored (regression guard)", + "design": "`LocalAuthorizationRulesFile` deliberately has no `mode` field, and `merge_authorization_item` keeps the remote rule's mode. This scenario writes `mode=audit, defaultAccess=deny, rules={}` to IMDS_Rules.json. PRE confirms the remote mode is `enforce`, so probes must 403. A 200 would mean the agent started honoring `mode` from the local file (audit-mode pass-through) — a security regression.", + "automation": "Writes `mode=audit, defaultAccess=deny, rules={}`; expects every probe → 403.", + "fix": "If this scenario flips to FAIL, someone added a `mode` field to `LocalAuthorizationRulesFile` or wired local `mode` into `merge_authorization_item`. Revert that change — audit mode must only be set by the remote (fabric-delivered) AuthorizationItem.", }, "IMDS-S4-allow-one-path": { "title": "Allow exactly one path via privilege/role/identity/assignment", @@ -274,10 +274,10 @@ class TestInfo(TypedDict, total=False): "automation": "Writes mode=enforce, defaultAccess=deny, no rules.", }, "WS-S3-audit-deny-empty": { - "title": "Audit mode passes through (WireServer)", - "design": "Goalstate must return 200 even though defaultAccess=deny, because mode=audit.", - "automation": "Writes mode=audit + defaultAccess=deny.", - "fix": "Same audit-mode bug as IMDS-S3 — fix in `proxy_authorizer.rs` to log-and-allow when mode=audit.", + "title": "Local `mode` field must be ignored (WireServer regression guard)", + "design": "Same contract as IMDS-S3: writes `mode=audit, defaultAccess=deny, rules={}` to WireServer_Rules.json. PRE confirms remote mode=`enforce`, so probes must 403. A 200 would mean the agent honored `mode` from the local file.", + "automation": "Writes `mode=audit, defaultAccess=deny, rules={}`; expects goalstate → 403 and versions → 403.", + "fix": "Same as IMDS-S3.", }, "WS-S4-allow-goalstate-only": { "title": "Allow only /machine/?comp=goalstate", diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index 110dada5..97eb3055 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -191,9 +191,19 @@ function Build-ImdsScenarios { [Probe]::new('versions', '/metadata/versions', 403) ) - # S3 — not testable from local rules (mode is honored only from remote). - Write-Finding "$pfx-S3-audit-deny-empty" INFO ('audit-mode behavior is not testable via local rules: ' + - 'merge_authorization_item() takes mode from remote rules only (see proxy_agent/src/key_keeper/local_rules.rs).') + # S3 — regression test: the agent MUST ignore 'mode' written into the + # local rules file. PRE has already confirmed the remote mode is enforce, + # so writing mode=audit + defaultAccess=deny + empty rules must still 403. + # A 200 here would mean the agent honored the local mode (audit-mode + # pass-through) — a security regression in merge_authorization_item / + # LocalAuthorizationRulesFile (see proxy_agent/src/key_keeper/local_rules.rs). + $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName imds ` + -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` + -Rules @{ defaultAccess='deny'; mode='audit'; id='pentest-s3'; rules=@{} } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S4 $scn += New-Scenario -Sid "$pfx-S4-allow-one-path" -TargetName imds ` @@ -338,8 +348,15 @@ function Build-WireServerScenarios { [Probe]::new('shared_denied', $SHARED, 403) ) - Write-Finding "$pfx-S3-audit-deny-empty" INFO ('audit-mode behavior is not testable via local rules: ' + - 'merge_authorization_item() takes mode from remote rules only (see proxy_agent/src/key_keeper/local_rules.rs).') + # S3 — regression test: the agent MUST ignore 'mode' written into the + # local rules file. Same contract as IMDS-S3. + $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName wireserver ` + -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` + -Rules @{ defaultAccess='deny'; mode='audit'; id='pentest-ws-s3'; rules=@{} } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) $scn += New-Scenario -Sid "$pfx-S4-allow-goalstate-only" -TargetName wireserver ` -Description 'enforce + deny; allow only /machine/ with comp=goalstate' ` diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 index 1bb17cab..55e74c9b 100644 --- a/pentest/windows/TestCatalog.psm1 +++ b/pentest/windows/TestCatalog.psm1 @@ -189,7 +189,7 @@ $script:Catalog = @{ $script:Phase4b = @{ 'IMDS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow'; Design='Baseline. Every IMDS probe must return 200.' } 'IMDS-S2-enforce-deny-empty' = @{ Title='Fail-closed when enforce+deny with no allow rules'; Design='All IMDS calls must return 403 — proves enforcement engages.' } - 'IMDS-S3-audit-deny-empty' = @{ Title='Audit mode passes traffic through (logs deny, returns 200)'; Design='Recorded as INFO from local rules: merge_authorization_item() honors `mode` only from the remote rule.' } + 'IMDS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to IMDS_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent started honoring `mode` from the local file — security regression in LocalAuthorizationRulesFile / merge_authorization_item.' } 'IMDS-S4-allow-one-path' = @{ Title='Allow exactly one path via privilege/role/identity/assignment'; Design='instance(200), token(403), versions(403).' } 'IMDS-S5-wrong-identity' = @{ Title='Allow rule bound to non-matching identity'; Design='Same allow rule but identity userName=nosuchuser_xyz. Must 403.' } 'IMDS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass attempts'; Design="Allow only /metadata/instance. Probes try %2F/%2f/%252F/dot-segments to reach token. ALL must 403 (or 404 for dot-segments)." } @@ -199,7 +199,7 @@ $script:Phase4b = @{ 'IMDS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed'; Design='Every IMDS probe must 403 — never silently fall back to allow.' } 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } - 'WS-S3-audit-deny-empty' = @{ Title='Audit mode passes through (WireServer)'; Design='Recorded as INFO via local rules (same merge-semantics reason).' } + 'WS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (WireServer regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to WireServer_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent honored `mode` from the local file.' } 'WS-S4-allow-goalstate-only' = @{ Title='Allow only /machine/?comp=goalstate'; Design='goalstate→200; sharedConfig/hosting/certs/extensionsConfig/versions→403.' } 'WS-S5-wrong-identity' = @{ Title='Allow goalstate but identity does not match'; Design='Even goalstate → 403.' } 'WS-S6-encoding-bypass' = @{ Title='Encoding/path-traversal bypass (WireServer)'; Design='Encoded variants of /machine/?comp=certificates must all 403; comp=GOALSTATE (case-insensitive) must 200.' } From 4b58830173b7184a08abb3f31fb47daff4b5c7e0 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 19 May 2026 18:03:04 +0000 Subject: [PATCH 23/37] fix formatting --- proxy_agent_shared/src/misc_helpers.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy_agent_shared/src/misc_helpers.rs b/proxy_agent_shared/src/misc_helpers.rs index 38ed5e18..151b9e55 100644 --- a/proxy_agent_shared/src/misc_helpers.rs +++ b/proxy_agent_shared/src/misc_helpers.rs @@ -254,7 +254,7 @@ where // fail the parse with "expected value at line 1 column 1" for any file // produced by editors / tools that default to BOM-prefixed UTF-8 (e.g. // Windows PowerShell 5.1's `Set-Content -Encoding UTF8`, Notepad, VS Code's - // "UTF-8 with BOM"). + // "UTF-8 with BOM"). let bytes = fs::read(file_path)?; let payload = match bytes.as_slice() { [0xEF, 0xBB, 0xBF, rest @ ..] => rest, From fe2f3ea3fcd6ed3b22f591213056118bea577ae5 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 19 May 2026 11:30:09 -0700 Subject: [PATCH 24/37] add more pen tests --- pentest/linux/DESIGN.md | 11 + pentest/linux/lib/common.sh | 1 + pentest/linux/phase4b_local_rules/run.py | 233 ++++++++++++++++++++- pentest/linux/phase5_state_fs/audit.sh | 28 +++ pentest/linux/test_catalog.py | 58 +++++ pentest/windows/DESIGN.md | 14 +- pentest/windows/Phase4b-LocalRules.ps1 | 180 +++++++++++++++- pentest/windows/Phase5-FileSystemAudit.ps1 | 17 +- pentest/windows/TestCatalog.psm1 | 18 ++ 9 files changed, 541 insertions(+), 19 deletions(-) diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index 5f8a7f22..29603eec 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -90,6 +90,7 @@ Target: [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs | F1 | Read `/var/log/azure-proxy-agent/status.json`, `AuthorizationRules_*.json`, `ProxyAgent.Connection.log` as non-root. | If readable, ensure no secrets (key material, signatures) are leaked. | The status/log surface is intentionally observable for diagnostics. The risk is data leakage — if the signing key, raw signatures, or token bodies appear in those files, any local user becomes equivalent to root for fabric AuthN. Confirms the redaction policy is consistently applied. | | F2 | Log injection — use a process with newline/control chars in cmdline (`prctl(PR_SET_NAME)` or `argv[0]` containing `\n{...fake JSON...}`). | Logs sanitize / escape; cannot forge audit entries. | If an attacker can inject newlines into the audit log, they can fabricate "PASS" entries that hide their real activity, or break SIEM parsers. Tests that all caller-controlled strings are escaped before being written. | | F3 | Symlink/race on the log directory rotation. | No arbitrary file overwrite as root. | Same threat family as E2 but specific to log rotation. A non-root attacker who can swap the log file with a symlink to `/etc/passwd` between rotation and write would get an arbitrary-file-overwrite as root. | +| F4 | Mode/owner/group audit on `/var/lib/azure-proxy-agent/rules` (the dir and every file under it). | Owned by `root:root`; not world-writable; not group-writable by any non-root group. | Local rules are admin-controlled **authZ INPUT**, not secrets. The risk is integrity, not confidentiality: a non-root attacker who can WRITE a rules file can ship a doc with `useLocalFileRules` content (or, if the BOM/duplicate-key/dangling-ref guards in S11/S12/S18 ever regress, an effectively allow-all doc) and short-circuit remote enforcement contracts at the next refresh. Read perms are intentionally not flagged because the agent persists the merged ruleset to `/var/log/azure-proxy-agent/AuthorizationRules_*.json` anyway. | ### G. Resource / DoS | ID | Test | Expected | Purpose / Why this matters | @@ -161,6 +162,11 @@ If either pre-condition fails, flip the flag / mode from your control plane (or | `IMDS-S8-group-only-identity` | Identity match by `groupName` only | instance → 200 | **None == wildcard semantics.** Identity fields that are unset must act as wildcards (match-anything), and at least one set field must AND-match. Guards against accidental over-restriction when operators write a minimal identity (group-only is a common operational pattern). | | `IMDS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` invocation → 200 | **Per-process identity provider works.** Verifies the kernel-side audit map actually populates `claims.processFullPath` so `exePath` rules are enforceable. If both halves return the same code, identity attribution is broken on this kernel — a regression that would let any process impersonate any other. | | `IMDS-S10-malformed-json` | `IMDS_Rules.json` is invalid JSON | All probes → 403 (fail-closed) | **Fail-closed on rule-parse error.** A corrupted local rules file must NOT result in "no rules → default-allow" or in the agent crashing. Confirms the loader builds a deny-all sentinel ruleset on parse failure rather than silently reverting to an unrestricted state. | +| `IMDS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | All probes → 403 | **BOM-trim regression guard.** `misc_helpers::json_read_from_file` strips a leading BOM before handing bytes to `serde_json::from_str`. A 200 here would mean the BOM-fix regressed, parse failed, and the agent silently fell back to no-rules / allow. | +| `IMDS-S12-duplicate-keys` | Raw JSON with `"defaultAccess":"allow"` then `"defaultAccess":"deny"` in the same object | All probes → 403 | **Duplicate-key last-wins.** Per RFC 8259 / serde_json semantics the second value wins. A 200 would mean either first-wins (silent escalation if a future schema rewrite adds a stricter override) or that the parser raised an error and the agent treated "unparseable" as "no rules → allow". | +| `IMDS-S17-name-collision-shielded` | Local rules whose privilege/role/identity names ALREADY start with `LocalFileRules_` | instance → 200, versions → 403 | **Prefixer idempotency.** `prefix_local_rule_names` must rewrite both definitions AND cross-references consistently even when the admin pre-includes the prefix. 403 on instance would mean the prefixer broke the role assignment (e.g. renamed the definition but not the reference), turning a documented operator pattern into an outage. | +| `IMDS-S18-dangling-references` | Role whose `privileges` array names a privilege that is not defined | All probes → 403 | **Validator coverage.** `validate_local_rules_file` must reject; agent fail-closes. A 200 would mean the validator skipped the dangling-ref check and the role applied with an empty / undefined privilege list — a hint that the matcher's empty-rule semantics could be coerced into allow. | +| `IMDS-S19-identity-homoglyph` | Identity `userName` uses Cyrillic А (U+0410) in place of ASCII A; probe runs as the real ASCII user | All probes → 403 | **Byte-exact identity comparison.** The matcher must NOT apply Unicode normalization to identity strings. A 200 would mean NFC/NFKC normalization is happening somewhere in identity comparison and an attacker who can write the rules file could allow-list a homoglyph that the OS still resolves to the real user — silent privilege escalation. | #### WireServer scenarios (target `wireserver`) @@ -187,6 +193,11 @@ WireServer is privileged-by-default at the proxy layer (`runAsElevated` required | `WS-S8-group-only-identity` | Identity match by `groupName` only | goalstate → 200 | **Wildcard semantics on WS.** Same purpose as IMDS-S8: confirms the operator-friendly minimal-identity pattern works on the privileged target. | | `WS-S9-exepath-identity` | Identity restricts to `exePath=/usr/bin/curl` | python caller → 403, real `curl` → 200 | **Per-process identity on WS.** Verifies `exePath` differentiation works for the privileged target — important because operators commonly want to limit WS access to the WALinuxAgent binary specifically, not "any root process". | | `WS-S10-malformed-json` | `WireServer_Rules.json` is invalid JSON | goalstate → 403, versions → 403 (fail-closed) | **Fail-closed for WS rules.** Same purpose as IMDS-S10; failure here is even more severe because a parse-error fail-open on WS exposes certificates and extension config. | +| `WS-S11-utf8-bom` | Valid deny doc written with an explicit UTF-8 BOM (EF BB BF) prefix | goalstate → 403, versions → 403 | **BOM-trim regression guard on WS.** Same contract as IMDS-S11; consequence of regression is more severe (cert/extension exposure). | +| `WS-S12-duplicate-keys` | Raw JSON with duplicate `defaultAccess` keys (allow then deny) | goalstate → 403, versions → 403 | **Last-wins on WS.** Same contract as IMDS-S12. | +| `WS-S17-name-collision-shielded` | Local rules whose names already start with `LocalFileRules_`, allow-listing `/machine/` | goalstate → 200 | **Prefixer idempotency on WS.** Same contract as IMDS-S17 against the more sensitive target. | +| `WS-S18-dangling-references` | Role with a dangling privilege reference | goalstate → 403, versions → 403 | **Validator coverage on WS.** Same contract as IMDS-S18. | +| `WS-S19-identity-homoglyph` | Identity `userName` substitutes Cyrillic о (U+043E) for ASCII 'o'; probe runs as the real ASCII user | goalstate → 403, versions → 403 | **Byte-exact identity comparison on WS.** Same contract as IMDS-S19. Privilege-escalation impact is direct because WS endpoints expose certificates and extension config. | #### Invocation diff --git a/pentest/linux/lib/common.sh b/pentest/linux/lib/common.sh index df43aa7a..83cb7f6c 100644 --- a/pentest/linux/lib/common.sh +++ b/pentest/linux/lib/common.sh @@ -20,6 +20,7 @@ HOSTGA_PORT="32526" GPA_LOG_DIR="/var/log/azure-proxy-agent" GPA_KEY_DIR="/var/lib/azure-proxy-agent/keys" +GPA_RULES_DIR="/var/lib/azure-proxy-agent/rules" color() { # $1=color $2=text case "$1" in diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index fe33c689..94308c53 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -253,6 +253,7 @@ class Scenario: rules: Any # dict (will be json.dumps'd) or raw str probes: list[Probe] = field(default_factory=list) raw: bool = False # write rules verbatim (for malformed-JSON test) + bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: @@ -511,6 +512,125 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) + # ------------------------------------------------------------------ + # S11..S19 — local rules file as adversarial admin input + # ------------------------------------------------------------------ + + # S11: file written with an explicit UTF-8 BOM (EF BB BF). Regression + # guard for the misc_helpers::json_read_from_file BOM fix. + scenarios.append(Scenario( + sid=f"{pfx}-S11-utf8-bom", + target=target, + description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", + rules={"defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s11", "rules": {}}, + bom=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S12: duplicate "defaultAccess" keys (allow then deny); serde_json + # last-wins per RFC 8259 — the agent must deny. + scenarios.append(Scenario( + sid=f"{pfx}-S12-duplicate-keys", + target=target, + description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", + rules='{"defaultAccess":"allow","defaultAccess":"deny",' + '"id":"pentest-s12","rules":{}}', + raw=True, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S17: local rule names that ALREADY contain the LocalFileRules_ prefix. + # Validates that prefix_local_rule_names is idempotent / consistent. + s17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} + scenarios.append(Scenario( + sid=f"{pfx}-S17-name-collision-shielded", + target=target, + description="local rule names already containing LocalFileRules_ prefix still bind correctly", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s17", + "rules": { + "privileges": [{"name": "LocalFileRules_p_instance", + "path": "/metadata/instance"}], + "roles": [{"name": "LocalFileRules_r_instance", + "privileges": ["LocalFileRules_p_instance"]}], + "identities": [s17_identity], + "roleAssignments": [{"role": "LocalFileRules_r_instance", + "identities": [s17_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_allowed", + "/metadata/instance?api-version=2021-02-01", 200), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S18: dangling role -> privilege reference → validator rejects → fail-closed. + scenarios.append(Scenario( + sid=f"{pfx}-S18-dangling-references", + target=target, + description="role references non-existent privilege → validator rejects → 403", + rules={ + "defaultAccess": "allow", "mode": "enforce", + "id": "pentest-s18", + "rules": { + "privileges": [{"name": "p_real", "path": "/metadata/"}], + "roles": [{"name": "r_broken", + "privileges": ["p_ghost"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_broken", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + + # S19: identity userName uses Cyrillic homoglyphs (о = U+043E) in place + # of ASCII 'o'. Matcher must use byte-exact compare — current user 'root' + # must NOT match 'rооt'. + homo_user = (identity["userName"][0] + + ("\u043e" * (len(identity["userName"]) - 2)) + + identity["userName"][-1]) \ + if len(identity["userName"]) >= 3 else "r\u043e\u043et" + homo_identity = {**identity, "name": "homoUser", "userName": homo_user} + scenarios.append(Scenario( + sid=f"{pfx}-S19-identity-homoglyph", + target=target, + description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-s19", + "rules": { + "privileges": [{"name": "p_instance", + "path": "/metadata/instance"}], + "roles": [{"name": "r_instance", + "privileges": ["p_instance"]}], + "identities": [homo_identity], + "roleAssignments": [{"role": "r_instance", + "identities": [homo_identity["name"]]}], + }, + }, + probes=[ + Probe("instance_denied", + "/metadata/instance?api-version=2021-02-01", 403), + Probe("versions_denied", "/metadata/versions", 403), + ], + )) + return scenarios @@ -780,6 +900,106 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) + # ------------------------------------------------------------------ + # S11..S19 — mirror of IMDS adversarial-admin-input tests + # ------------------------------------------------------------------ + + scenarios.append(Scenario( + sid=f"{pfx}-S11-utf8-bom", + target=target, + description="rules file prefixed with a UTF-8 BOM must still parse (deny → 403)", + rules={"defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s11", "rules": {}}, + bom=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + scenarios.append(Scenario( + sid=f"{pfx}-S12-duplicate-keys", + target=target, + description="duplicate defaultAccess key (allow,deny) must resolve last-wins → 403", + rules='{"defaultAccess":"allow","defaultAccess":"deny",' + '"id":"pentest-ws-s12","rules":{}}', + raw=True, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + ws17_identity = {**identity, "name": "LocalFileRules_idCurrentUser"} + scenarios.append(Scenario( + sid=f"{pfx}-S17-name-collision-shielded", + target=target, + description="local rule names already containing LocalFileRules_ prefix still bind correctly", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s17", + "rules": { + "privileges": [{"name": "LocalFileRules_p_machine", + "path": "/machine/"}], + "roles": [{"name": "LocalFileRules_r_machine", + "privileges": ["LocalFileRules_p_machine"]}], + "identities": [ws17_identity], + "roleAssignments": [{"role": "LocalFileRules_r_machine", + "identities": [ws17_identity["name"]]}], + }, + }, + probes=[Probe("goalstate_allowed", GOALSTATE, 200)], + )) + + scenarios.append(Scenario( + sid=f"{pfx}-S18-dangling-references", + target=target, + description="role references non-existent privilege → validator rejects → 403", + rules={ + "defaultAccess": "allow", "mode": "enforce", + "id": "pentest-ws-s18", + "rules": { + "privileges": [{"name": "p_real", "path": "/machine/"}], + "roles": [{"name": "r_broken", + "privileges": ["p_ghost"]}], + "identities": [identity], + "roleAssignments": [{"role": "r_broken", + "identities": [identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + + ws_homo_user = (identity["userName"][0] + + ("\u043e" * (len(identity["userName"]) - 2)) + + identity["userName"][-1]) \ + if len(identity["userName"]) >= 3 else "r\u043e\u043et" + ws_homo_identity = {**identity, "name": "homoUser", "userName": ws_homo_user} + scenarios.append(Scenario( + sid=f"{pfx}-S19-identity-homoglyph", + target=target, + description="Cyrillic homoglyph in userName must NOT match ASCII current user → 403", + rules={ + "defaultAccess": "deny", "mode": "enforce", + "id": "pentest-ws-s19", + "rules": { + "privileges": [{"name": "p_machine", "path": "/machine/"}], + "roles": [{"name": "r_machine", + "privileges": ["p_machine"]}], + "identities": [ws_homo_identity], + "roleAssignments": [{"role": "r_machine", + "identities": [ws_homo_identity["name"]]}], + }, + }, + probes=[ + Probe("goalstate_denied", GOALSTATE, 403), + Probe("versions_denied", VERSIONS, 403), + ], + )) + return scenarios @@ -806,7 +1026,18 @@ def wait_for_refresh(poll_s: int) -> None: def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: print(f"\n=== {sc.sid}: {sc.description}") if sc.raw: - write_raw(sc.target.rules_file, sc.rules) + text = sc.rules if isinstance(sc.rules, str) else json.dumps(sc.rules) + else: + text = json.dumps(sc.rules, indent=2) + if sc.bom: + # Deterministic explicit BOM, regardless of platform default encoding. + sc.target.rules_file.parent.mkdir(mode=0o700, exist_ok=True) + tmp = sc.target.rules_file.with_suffix(sc.target.rules_file.suffix + ".tmp") + tmp.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8")) + os.chmod(tmp, 0o600) + os.replace(tmp, sc.target.rules_file) + elif sc.raw: + write_raw(sc.target.rules_file, text) else: write_rules(sc.target.rules_file, sc.rules) wait_for_refresh(poll_s) diff --git a/pentest/linux/phase5_state_fs/audit.sh b/pentest/linux/phase5_state_fs/audit.sh index d3852403..1f251cb4 100755 --- a/pentest/linux/phase5_state_fs/audit.sh +++ b/pentest/linux/phase5_state_fs/audit.sh @@ -36,6 +36,34 @@ if [[ -d "$GPA_KEY_DIR" ]]; then done < <(sudo find "$GPA_KEY_DIR" -maxdepth 2 -type f 2>/dev/null) fi +# F4 — Local rules dir is admin-controlled authZ INPUT. Non-root WRITE on this +# dir (or any rules file) lets an unprivileged user inject useLocalFileRules +# content and bypass remote enforcement contracts. Fail on world/group writable +# or non-root owner. Read perms are not flagged — rules are policy, not secrets. +check_rules_acl() { # $1=path + local p="$1" + if ! sudo test -e "$p"; then record "F4[$p]" INFO "missing"; return; fi + local mode own grp + mode=$(sudo stat -c '%a' "$p") + own=$(sudo stat -c '%U' "$p") + grp=$(sudo stat -c '%G' "$p") + if [[ "$own" != "root" ]]; then + record "F4[$p]" FAIL "owner=$own (expected root) mode=$mode group=$grp" + elif (( (10#$mode % 10) >= 2 )); then + record "F4[$p]" FAIL "world-writable mode=$mode owner=$own group=$grp" + elif (( ((10#$mode / 10) % 10) >= 2 )) && [[ "$grp" != "root" ]]; then + record "F4[$p]" FAIL "group-writable by non-root group: mode=$mode group=$grp" + else + record "F4[$p]" PASS "mode=$mode owner=$own group=$grp" + fi +} +check_rules_acl "$GPA_RULES_DIR" +if [[ -d "$GPA_RULES_DIR" ]]; then + while IFS= read -r f; do + check_rules_acl "$f" + done < <(sudo find "$GPA_RULES_DIR" -maxdepth 2 -type f 2>/dev/null) +fi + # F1 — secret leakage in world-readable status / rules for f in "$GPA_LOG_DIR/status.json" "$GPA_LOG_DIR"/AuthorizationRules_*.json; do [[ -f "$f" ]] || continue diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index 23f6d07d..70524c7f 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -171,6 +171,14 @@ class TestInfo(TypedDict, total=False): "Quick mitigation: `sudo chmod 600 /var/lib/azure-proxy-agent/keys/*`." ), }, + "F4": { + "title": "Rules dir mode/owner — admin-only WRITE on local rules input", + "design": "The Rules dir (and every IMDS_Rules.json / WireServer_Rules.json under it) is admin-controlled authZ INPUT — not a secret, but a non-root WRITE there lets an unprivileged user inject useLocalFileRules content and short-circuit remote enforcement contracts. FAIL on world-writable, group-writable by a non-root group, or non-root owner. Read perms are not flagged (rules are policy, not secrets).", + "automation": "`sudo stat -c '%a %U %G'` on the rules dir and every regular file underneath; reports F4[path] PASS/FAIL.", + "repro_script": "bash pentest/linux/phase5_state_fs/audit.sh", + "repro_manual": "sudo stat -c '%a %U %G %n' /var/lib/azure-proxy-agent/rules\nsudo find /var/lib/azure-proxy-agent/rules -type f -exec stat -c '%a %U %G %n' {} \\;", + "fix": "Install the rules dir as `install -d -m 0755 -o root -g root /var/lib/azure-proxy-agent/rules` (or 0700 for tighter posture) and ensure the agent re-applies `chmod 0644 root:root` (or 0600) on every rules file it loads. Reject (or refuse to honor) any rules file that is not owned by root.", + }, "F1": { "title": "Status / rules files contain no secrets", "design": "World-readable status.json and AuthorizationRules_*.json must NOT contain key material, signatures, tokens, or HMACs.", @@ -261,6 +269,31 @@ class TestInfo(TypedDict, total=False): "design": "If IMDS_Rules.json is invalid, every IMDS probe must 403 — never silently fall back to allow.", "automation": "Writes the literal string `{ this is not valid json` and expects 403 on every probe.", }, + "IMDS-S11-utf8-bom": { + "title": "UTF-8 BOM tolerated when parsing rules", + "design": "Writes IMDS_Rules.json prefixed with EF BB BF + a valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim. A 200 here would mean the BOM-fix regressed and the agent silently fell back to no-rules / allow.", + "automation": "Prepends b'\\xef\\xbb\\xbf' to the JSON bytes before atomic-rename, then asserts 403 on every IMDS probe.", + }, + "IMDS-S12-duplicate-keys": { + "title": "Duplicate JSON keys resolve last-wins", + "design": "Raw JSON contains two `defaultAccess` entries (allow then deny). serde_json must honor RFC 8259 last-wins — a 200 would mean first-wins (silent escalation) or that the rule loaded as allow-by-default.", + "automation": "Writes the raw string verbatim and asserts 403.", + }, + "IMDS-S17-name-collision-shielded": { + "title": "Local rule names already containing LocalFileRules_ prefix", + "design": "Validates that `prefix_local_rule_names` rewrites both definitions AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds and grants 200 on /metadata/instance. 403 would mean the prefixer mishandled the doubled prefix and broke the role assignment.", + "automation": "All names in the local file already start with `LocalFileRules_`; probes expect 200 on /metadata/instance and 403 on /metadata/versions.", + }, + "IMDS-S18-dangling-references": { + "title": "Dangling role→privilege reference must reject", + "design": "`validate_local_rules_file` must reject; agent fail-closes to defaultAccess=deny. 200 would mean dangling-reference validation regressed.", + "automation": "Writes a role whose `privileges` array references a non-existent privilege name; expects 403 on every IMDS probe.", + }, + "IMDS-S19-identity-homoglyph": { + "title": "Cyrillic homoglyph in userName must NOT match ASCII user", + "design": "Identity `userName` uses Cyrillic о (U+043E) in place of ASCII 'o'. The matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ.", + "automation": "Rewrites the harness identity's userName with Cyrillic о substitutions; expects 403.", + }, # ---------------- WireServer ---------------- "WS-S1-disabled-allow": { @@ -321,6 +354,31 @@ class TestInfo(TypedDict, total=False): "design": "All WireServer probes 403.", "automation": "Writes invalid JSON literal as WireServer_Rules.json.", }, + "WS-S11-utf8-bom": { + "title": "UTF-8 BOM tolerated when parsing rules (WireServer)", + "design": "Same contract as IMDS-S11 against WireServer.", + "automation": "Prepends b'\\xef\\xbb\\xbf' to the WireServer_Rules.json bytes; expects 403.", + }, + "WS-S12-duplicate-keys": { + "title": "Duplicate JSON keys resolve last-wins (WireServer)", + "design": "Same contract as IMDS-S12 against WireServer.", + "automation": "Writes raw JSON with duplicate `defaultAccess` keys; expects 403.", + }, + "WS-S17-name-collision-shielded": { + "title": "Local rule names already containing LocalFileRules_ prefix (WireServer)", + "design": "Same contract as IMDS-S17 against /machine/. 200 on goalstate confirms the prefixer is idempotent.", + "automation": "All names in the local file already start with `LocalFileRules_`.", + }, + "WS-S18-dangling-references": { + "title": "Dangling role→privilege reference must reject (WireServer)", + "design": "Validator must reject; agent fail-closes.", + "automation": "Writes a role referencing a non-existent privilege; expects 403.", + }, + "WS-S19-identity-homoglyph": { + "title": "Cyrillic homoglyph in userName must NOT match (WireServer)", + "design": "Same contract as IMDS-S19 against /machine/.", + "automation": "Rewrites the harness identity's userName with Cyrillic о substitutions; expects 403.", + }, } diff --git a/pentest/windows/DESIGN.md b/pentest/windows/DESIGN.md index 97311514..5105c555 100644 --- a/pentest/windows/DESIGN.md +++ b/pentest/windows/DESIGN.md @@ -85,11 +85,12 @@ column differs. See [../linux/DESIGN.md](../linux/DESIGN.md) for the full ### D / 4b. AuthZ rule engine — local-file rules Phase 4b ([Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1)) is the -PowerShell port of the Linux Phase 4b harness and runs the **same 20 -scenarios** (`IMDS-S1`…`S10`, `WS-S1`…`S10`). Pre-flight, scenario -construction, refresh-wait, probe matrix, and triage rules are -identical. See the Linux design doc's -[Phase 4b section](../linux/DESIGN.md) for the per-scenario rationale. +PowerShell port of the Linux Phase 4b harness and runs the **same 30 +scenarios** (`IMDS-S1`…`S10` + `IMDS-S11`/`S12`/`S17`/`S18`/`S19`, and +the matching `WS-` set). Pre-flight, scenario construction, +refresh-wait, probe matrix, and triage rules are identical. See the +Linux design doc's [Phase 4b section](../linux/DESIGN.md) for the +per-scenario rationale. Windows-specific deltas in Phase 4b: @@ -115,6 +116,7 @@ Windows-specific deltas in Phase 4b: |----|-------------------------------|----------| | E1 | `Get-Acl` on `…\Keys` (with `-SecretsOnly`), `…\Logs`, `…\GuestProxyAgent\GuestProxyAgent.exe`, install root, and per-file under Keys. | No `Write`/`Modify`/`FullControl`/`Delete`/`ChangePermissions`/`TakeOwnership` ACE for principals outside `{NT AUTHORITY\SYSTEM, BUILTIN\Administrators, NT SERVICE\TrustedInstaller, COMPUTERNAME\Administrator}`. Secrets paths additionally FAIL on any `Read` from non-admin principals. | | F1 | Regex `"(key|secret|signature|hmac|token)"\s*:\s*"[^"]+"` against `status.json` + `AuthorizationRules_*.json` under `…\Logs`. | No match. | +| F4 | `Get-Acl` on `…\Rules` (NOT `-SecretsOnly` — rules are policy, not secrets; only `Write`/`Modify`/`FullControl`/`Delete`/`ChangePermissions`/`TakeOwnership` are flagged). Local rules are admin-controlled authZ INPUT: any non-admin WRITE here lets an unprivileged user inject `useLocalFileRules` content and short-circuit remote enforcement. | Write-class ACEs restricted to the same admin/SYSTEM principal set as E1. | | P5-svc | `Get-Service GuestProxyAgent`. | INFO with status / start type. | | P5-wfp | `netsh wfp show filters file=results\wfp_filters_windows.txt`. | Saved snapshot for offline review (Windows analog of Linux `bpftool prog show`). | @@ -143,7 +145,7 @@ Windows-specific deltas in Phase 4b: appendix. ### Phase 4b — local-file rules -- IMDS S1–S10 + WS S1–S10 → [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1). +- IMDS S1–S10 + IMDS S11/S12/S17/S18/S19 + WS S1–S10 + WS S11/S12/S17/S18/S19 → [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1). - **Pre-conditions** (auto-checked, fail-fast `PRE FAIL`): 1. `useLocalFileRules-true` substring present in the latest `AuthorizationRules_*.json`. diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index 97eb3055..1f2da459 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -155,16 +155,17 @@ class Scenario { [object] $Rules # hashtable or raw string [Probe[]] $Probes [bool] $Raw = $false + [bool] $Bom = $false # prepend a UTF-8 BOM (EF BB BF) to the written file } function New-Scenario { param( [string] $Sid, [string] $TargetName, [string] $Description, - [object] $Rules, [Probe[]] $Probes, [switch] $Raw + [object] $Rules, [Probe[]] $Probes, [switch] $Raw, [switch] $Bom ) $s = [Scenario]::new() $s.Sid = $Sid; $s.TargetName = $TargetName; $s.Description = $Description - $s.Rules = $Rules; $s.Probes = $Probes; $s.Raw = [bool]$Raw + $s.Rules = $Rules; $s.Probes = $Probes; $s.Raw = [bool]$Raw; $s.Bom = [bool]$Bom return $s } @@ -316,6 +317,92 @@ function Build-ImdsScenarios { [Probe]::new('versions_denied', '/metadata/versions', 403) ) + # ------------------------------------------------------------------ + # S11..S19 — local rules file as adversarial admin input + # ------------------------------------------------------------------ + + # S11: file written with an explicit UTF-8 BOM (EF BB BF). Regression + # guard for the misc_helpers::json_read_from_file BOM fix — the agent + # must trim the BOM before parsing; resulting valid rules deny all. + $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName imds ` + -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` + -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-s11'; rules=@{} } ` + -Bom ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S12: duplicate "defaultAccess" keys (allow then deny). serde_json takes + # last-wins per RFC 8259 guidance; the agent must therefore deny. + $scn += New-Scenario -Sid "$pfx-S12-duplicate-keys" -TargetName imds ` + -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` + -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-s12","rules":{}}' -Raw ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S17: local rule names that ALREADY start with the LocalFileRules_ prefix. + # Validates that prefix_local_rule_names is idempotent / safe (it rewrites + # both definitions AND references consistently), so the rule still binds. + $s17Identity = $Identity.Clone(); $s17Identity.name = 'LocalFileRules_idCurrentUser' + $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName imds ` + -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s17' + rules=@{ + privileges = @( @{ name='LocalFileRules_p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='LocalFileRules_r_instance'; privileges=@('LocalFileRules_p_instance') } ) + identities = @( $s17Identity ) + roleAssignments = @( @{ role='LocalFileRules_r_instance'; identities=@($s17Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S18: dangling role -> privilege reference. validate_local_rules_file + # must reject; agent fail-closes to defaultAccess=deny. + $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName imds ` + -Description 'role references non-existent privilege → validator rejects → 403' ` + -Rules @{ + defaultAccess='allow'; mode='enforce'; id='pentest-s18' + rules=@{ + privileges = @( @{ name='p_real'; path='/metadata/' } ) + roles = @( @{ name='r_broken'; privileges=@('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_broken'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + + # S19: identity userName uses a Cyrillic homoglyph (А = U+0410) instead + # of ASCII A. Matcher must compare bytes (not Unicode-normalized) so the + # current user "Administrator" does not match "Аdministrator". + $homoIdentity = $Identity.Clone() + $homoIdentity.name = 'homoUser' + $homoIdentity.userName = ([char]0x0410) + ($Identity.userName.Substring(1)) # А + dministrator + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName imds ` + -Description 'Cyrillic-А homoglyph in userName must NOT match ASCII current user → 403' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s19' + rules=@{ + privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) + identities = @( $homoIdentity ) + roleAssignments = @( @{ role='r_instance'; identities=@($homoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + return $scn } @@ -465,6 +552,76 @@ function Build-WireServerScenarios { [Probe]::new('versions_denied', $VERSIONS, 403) ) + # ------------------------------------------------------------------ + # S11..S19 — mirror of IMDS adversarial-admin-input tests + # ------------------------------------------------------------------ + + $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName wireserver ` + -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` + -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-ws-s11'; rules=@{} } ` + -Bom ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $scn += New-Scenario -Sid "$pfx-S12-duplicate-keys" -TargetName wireserver ` + -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` + -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-ws-s12","rules":{}}' -Raw ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $ws17Identity = $Identity.Clone(); $ws17Identity.name = 'LocalFileRules_idCurrentUser' + $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName wireserver ` + -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s17' + rules=@{ + privileges = @( @{ name='LocalFileRules_p_machine'; path='/machine/' } ) + roles = @( @{ name='LocalFileRules_r_machine'; privileges=@('LocalFileRules_p_machine') } ) + identities = @( $ws17Identity ) + roleAssignments = @( @{ role='LocalFileRules_r_machine'; identities=@($ws17Identity.name) } ) + } + } ` + -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) + + $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName wireserver ` + -Description 'role references non-existent privilege → validator rejects → 403' ` + -Rules @{ + defaultAccess='allow'; mode='enforce'; id='pentest-ws-s18' + rules=@{ + privileges = @( @{ name='p_real'; path='/machine/' } ) + roles = @( @{ name='r_broken'; privileges=@('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role='r_broken'; identities=@($Identity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + + $wsHomoIdentity = $Identity.Clone() + $wsHomoIdentity.name = 'homoUser' + $wsHomoIdentity.userName = ([char]0x0410) + ($Identity.userName.Substring(1)) + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName wireserver ` + -Description 'Cyrillic-А homoglyph in userName must NOT match ASCII current user → 403' ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s19' + rules=@{ + privileges = @( @{ name='p_machine'; path='/machine/' } ) + roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) + identities = @( $wsHomoIdentity ) + roleAssignments = @( @{ role='r_machine'; identities=@($wsHomoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + return $scn } @@ -515,13 +672,22 @@ function Restore-RulesDir { } function Write-Rules { - param([string] $Path, [object] $Doc, [bool] $Raw) + param([string] $Path, [object] $Doc, [bool] $Raw, [bool] $Bom) $tmp = "$Path.tmp" if ($Raw) { - Set-Content -Path $tmp -Value $Doc -NoNewline -Encoding UTF8 + $text = [string]$Doc + } else { + $text = $Doc | ConvertTo-Json -Depth 10 + } + if ($Bom) { + # Bypass Set-Content so the file gets EXACTLY one UTF-8 BOM + # regardless of which PowerShell version is running (PS 5.1 adds a BOM + # by default; PS 7+ does not). Tests that need an explicit BOM must + # be deterministic across both. + $bytes = [byte[]] @(0xEF, 0xBB, 0xBF) + [System.Text.Encoding]::UTF8.GetBytes($text) + [System.IO.File]::WriteAllBytes($tmp, $bytes) } else { - $json = $Doc | ConvertTo-Json -Depth 10 - Set-Content -Path $tmp -Value $json -NoNewline -Encoding UTF8 + Set-Content -Path $tmp -Value $text -NoNewline -Encoding UTF8 } Move-Item -Path $tmp -Destination $Path -Force } @@ -552,7 +718,7 @@ function Invoke-Scenario { Write-Host "" Write-Host ("=== {0}: {1}" -f $Scenario.Sid, $Scenario.Description) $rulesPath = if ($Scenario.TargetName -eq 'imds') { $ImdsRulesFile } else { $WsRulesFile } - Write-Rules -Path $rulesPath -Doc $Scenario.Rules -Raw $Scenario.Raw + Write-Rules -Path $rulesPath -Doc $Scenario.Rules -Raw $Scenario.Raw -Bom $Scenario.Bom Wait-ForRefresh $passes = 0; $fails = 0 diff --git a/pentest/windows/Phase5-FileSystemAudit.ps1 b/pentest/windows/Phase5-FileSystemAudit.ps1 index b623ba27..6617d895 100644 --- a/pentest/windows/Phase5-FileSystemAudit.ps1 +++ b/pentest/windows/Phase5-FileSystemAudit.ps1 @@ -24,14 +24,15 @@ $ALLOWED_PRINCIPALS = @( function Test-AclTight { param( [string] $Path, - [switch] $SecretsOnly # if set, even READ access from non-admins is a FAIL + [switch] $SecretsOnly, # if set, even READ access from non-admins is a FAIL + [string] $Tag = 'E1' # finding ID prefix; allows E2 for rules-dir audit ) if (-not (Test-Path $Path)) { - Write-Finding "E1[$Path]" INFO "path does not exist on disk; nothing to audit (GPA may not be installed at the expected location)" + Write-Finding "$Tag[$Path]" INFO "path does not exist on disk; nothing to audit (GPA may not be installed at the expected location)" return } try { $acl = Get-Acl $Path } catch { - Write-Finding "E1[$Path]" INFO "could not read ACL: $_"; return + Write-Finding "$Tag[$Path]" INFO "could not read ACL: $_"; return } $bad = @() foreach ($ace in $acl.Access) { @@ -47,9 +48,9 @@ function Test-AclTight { } } if ($bad) { - Write-Finding "E1[$Path]" FAIL ("ACL too permissive: " + ($bad -join '; ')) + Write-Finding "$Tag[$Path]" FAIL ("ACL too permissive: " + ($bad -join '; ')) } else { - Write-Finding "E1[$Path]" PASS "ACL restricted to admin/SYSTEM principals" + Write-Finding "$Tag[$Path]" PASS "ACL restricted to admin/SYSTEM principals" } } @@ -58,6 +59,12 @@ Test-AclTight $GpaLogDir Test-AclTight $GpaExe Test-AclTight $GpaInstallRoot +# F4 — Rules dir is admin-controlled authZ INPUT. We don't care about read perms +# (rules are public-ish authZ policy, not secrets), but any non-admin write would +# let a non-privileged user inject useLocalFileRules content and bypass remote +# enforcement contracts. Audit write-class permissions specifically. +Test-AclTight $GpaRulesDir -Tag 'F4' + # Per-key file ACL (mirrors the Linux per-file 0600 check). if (Test-Path $GpaKeyDir) { Get-ChildItem -Path $GpaKeyDir -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 index 55e74c9b..bb41e2c6 100644 --- a/pentest/windows/TestCatalog.psm1 +++ b/pentest/windows/TestCatalog.psm1 @@ -148,6 +148,14 @@ $script:Catalog = @{ ReproManual = "Get-Acl `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Keys | Format-List" Fix = "Force the canonical DACL on install (SDDL: O:SYG:SYD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)) and re-apply on service start." } + 'F4' = @{ + Title = 'Rules dir DACL — admin-only WRITE on local rules input' + Design = "The Rules dir (and every IMDS_Rules.json / WireServer_Rules.json under it) is admin-controlled authZ INPUT — not a secret, but a non-admin WRITE there lets an unprivileged user inject useLocalFileRules content and short-circuit remote enforcement contracts. Audit Write/Modify/FullControl: must be SYSTEM / BUILTIN\\Administrators / TrustedInstaller only. Read access is intentionally not flagged (rules are policy, not secrets)." + Automation = "Get-Acl + Test-AclTight -Tag F4 on `$GpaRulesDir." + ReproScript = '.\pentest\windows\Phase5-FileSystemAudit.ps1' + ReproManual = "Get-Acl `$env:SystemDrive\\WindowsAzure\\ProxyAgent\\Rules | Format-List" + Fix = "Force admin-only WRITE on install (SDDL: O:BAG:BAD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FR;;;BU)) and re-apply on service start." + } 'F1' = @{ Title = 'Status / rules files contain no secrets' Design = "Observable files (status.json, AuthorizationRules_*.json) under the Logs dir must NOT contain key material, signatures, tokens, or HMACs." @@ -197,6 +205,11 @@ $script:Phase4b = @{ 'IMDS-S8-group-only-identity' = @{ Title='Identity match by groupName only'; Design='Identity has only groupName. Must allow.' } 'IMDS-S9-exepath-identity' = @{ Title='Per-process identity by exePath'; Design='Allow only when exePath=PowerShell.exe (Windows variant). PowerShell caller → 200; arbitrary exe → 403.' } 'IMDS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed'; Design='Every IMDS probe must 403 — never silently fall back to allow.' } + 'IMDS-S11-utf8-bom' = @{ Title='UTF-8 BOM tolerated when parsing rules'; Design='Writes IMDS_Rules.json prefixed with EF BB BF + valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim. 200 here would mean BOM-fix has regressed and the agent fell back to no-rules / allow.' } + 'IMDS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins'; Design='Writes `{"defaultAccess":"allow","defaultAccess":"deny"}`. serde_json must take last-wins per RFC 8259 — 200 would mean first-wins (silent escalation) or rule loaded as allow-by-default.' } + 'IMDS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix'; Design='Tests that `prefix_local_rule_names` rewrites both names AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds. 403 on /metadata/instance would mean the prefixer mishandled the doubled prefix.' } + 'IMDS-S18-dangling-references'= @{ Title='Dangling role→privilege reference must reject'; Design='Validator must reject; agent fail-closes to defaultAccess=deny. 200 means dangling-ref validation regressed.' } + 'IMDS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match ASCII user'; Design='Identity userName uses Cyrillic А (U+0410) in place of ASCII A. Matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ.' } 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } 'WS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (WireServer regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to WireServer_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent honored `mode` from the local file.' } @@ -207,6 +220,11 @@ $script:Phase4b = @{ 'WS-S8-group-only-identity' = @{ Title='Identity match by groupName only (WireServer)'; Design='groupName=Administrators → goalstate 200.' } 'WS-S9-exepath-identity' = @{ Title='Per-process identity by exePath (WireServer)'; Design='Allow only when exePath=PowerShell.exe.' } 'WS-S10-malformed-json' = @{ Title='Malformed rules JSON → fail-closed (WireServer)'; Design='All WireServer probes 403.' } + 'WS-S11-utf8-bom' = @{ Title='UTF-8 BOM tolerated when parsing rules (WireServer)'; Design='Writes WireServer_Rules.json prefixed with EF BB BF + valid deny doc. Regression guard for `misc_helpers::json_read_from_file` BOM trim.' } + 'WS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins (WireServer)'; Design='Same contract as IMDS-S12 against WireServer.' } + 'WS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix (WireServer)'; Design='Same contract as IMDS-S17 against /machine/.' } + 'WS-S18-dangling-references' = @{ Title='Dangling role→privilege reference must reject (WireServer)'; Design='Validator must reject; agent fail-closes.' } + 'WS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match (WireServer)'; Design='Same contract as IMDS-S19 against /machine/.' } } function Get-CatalogEntry { From d7432ee6659a7be65e90310b5900f72662a4a0e3 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 19 May 2026 20:12:33 +0000 Subject: [PATCH 25/37] update rull_all.sh --- pentest/linux/run_all.sh | 113 +++++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/pentest/linux/run_all.sh b/pentest/linux/run_all.sh index 198de3fb..40e643b3 100755 --- a/pentest/linux/run_all.sh +++ b/pentest/linux/run_all.sh @@ -1,17 +1,110 @@ #!/usr/bin/env bash -# Convenience driver. Runs phases 2, 3, 4 (read-only / safe). -# Phase 5 fault-injection is intentionally NOT invoked here. +# Driver for the GPA Linux pen-test harness. +# Mirrors the UX of pentest/windows/Run-AllPenTests.ps1. +# +# Default (no args): clears findings.tsv, runs ALL phases +# (2 listener, 3 AuthN/AuthZ, 4 URL diff, 4b local-file rules, 5 FS audit), +# then renders results/report.html. +# +# Phase 4b mutates /var/lib/azure-proxy-agent/rules and requires +# useLocalFileRules=true on the fabric (~13 min); skip with --skip-phase4b +# if running on a fabric that does not allow it. +# +# Usage: +# sudo ./pentest/linux/run_all.sh # ALL + report (truncate) +# sudo ./pentest/linux/run_all.sh --skip-phase4b # safe/read-only phases only +# sudo ./pentest/linux/run_all.sh --no-truncate # append to findings.tsv +# sudo ./pentest/linux/run_all.sh --phase4b-target imds # 4b: IMDS only +# sudo ./pentest/linux/run_all.sh --no-report # skip the HTML render +# ./pentest/linux/run_all.sh --help set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" +RESULTS_DIR="$HERE/results" +FINDINGS="$RESULTS_DIR/findings.tsv" -bash "$HERE/phase2_listener/run.sh" -echo -bash "$HERE/phase3_authn_authz/run.sh" -echo -python3 "$HERE/phase4_rules_fuzz/url_diff.py" -echo -bash "$HERE/phase5_state_fs/audit.sh" +TRUNCATE=1 +RUN_4B=1 +P4B_TARGET="both" +RUN_REPORT=1 +RUN_PHASE2=1 +RUN_PHASE3=1 +RUN_PHASE4=1 +RUN_PHASE5=1 + +usage() { + sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' + cat <&2; usage; exit 2 ;; + esac + shift +done + +mkdir -p "$RESULTS_DIR" + +if [[ "$TRUNCATE" -eq 1 ]]; then + : > "$FINDINGS" + echo "Truncated $FINDINGS" +fi + +banner() { echo; echo "===== $* ====="; } + +if [[ "$RUN_PHASE2" -eq 1 ]]; then + banner "Phase 2 — listener" + bash "$HERE/phase2_listener/run.sh" +fi + +if [[ "$RUN_PHASE3" -eq 1 ]]; then + banner "Phase 3 — AuthN / AuthZ" + bash "$HERE/phase3_authn_authz/run.sh" +fi + +if [[ "$RUN_PHASE4" -eq 1 ]]; then + banner "Phase 4 — URL / encoding diff" + python3 "$HERE/phase4_rules_fuzz/url_diff.py" +fi + +if [[ "$RUN_4B" -eq 1 ]]; then + banner "Phase 4b — local-file rules (target=$P4B_TARGET)" + python3 "$HERE/phase4b_local_rules/run.py" --target "$P4B_TARGET" +fi + +if [[ "$RUN_PHASE5" -eq 1 ]]; then + banner "Phase 5 — filesystem audit" + bash "$HERE/phase5_state_fs/audit.sh" +fi + +if [[ "$RUN_REPORT" -eq 1 ]]; then + banner "Generate report" + python3 "$HERE/generate_report.py" +fi echo -echo "All safe phases done. Findings: $HERE/results/findings.tsv" +echo "Done. Findings: $FINDINGS" +[[ "$RUN_REPORT" -eq 1 ]] && echo "Report: $RESULTS_DIR/report.html" +exit 0 From 7ee8f898ea875aaf01b0168c9b588e01cf060b59 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 19 May 2026 14:23:10 -0700 Subject: [PATCH 26/37] Update the docs --- pentest/linux/DESIGN.md | 5 +-- pentest/linux/README.md | 66 +++++++++++++++++++++++++++++++++------ pentest/windows/DESIGN.md | 10 +++--- pentest/windows/README.md | 15 +++++---- 4 files changed, 74 insertions(+), 22 deletions(-) diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index 29603eec..f9fe506c 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -217,7 +217,8 @@ Findings are appended to `pentest/linux/results/findings.tsv`; a per-run console - `*S9` failures (where `python_caller_denied` returns 200 or `curl_caller_allowed` returns 403) indicate the per-process identity provider is broken on this kernel. ### Phase 5 — State / FS / persistence -- E1–E5, F2–F3, H1–H2. Requires sudo and at least one service restart — do this last. +- E1, F1, F4 (read-only audit) → [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh). E1 covers keys/log/binary/unit modes+ownership; F1 grep-scans observable JSON for secret-shaped strings; F4 audits the local-rules dir (admin-only WRITE) since it is privileged authZ INPUT. +- E2–E5, F2–F3, H1–H2 are destructive (symlink races, disk-full, kill-and-watch, key downgrade, log injection) and are intentionally NOT in `audit.sh` — run them manually, and only on a snapshotted VM. ### Phase 6 — Resilience / DoS - G1–G4. Watch `systemd-cgtop`, `journalctl -fu azure-proxy-agent`, and `bpftool prog show` for prog leaks across restarts. @@ -231,7 +232,7 @@ Findings are appended to `pentest/linux/results/findings.tsv`; a per-run console 4. **Per-phase tables** — full PASS/FAIL/INFO breakdown per phase. 5. **Phase 4 URL/encoding differential** — last `url_diff.tsv`. 6. **Cheat sheet & artifacts** — raw paths to pcaps, bpftool snapshots, etc. -- Driver: [pentest/linux/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 5 in order; phase 4b ([pentest/linux/phase4b_local_rules/run.py](phase4b_local_rules/run.py)) is invoked separately because it requires `sudo` and writes to `/var/lib/azure-proxy-agent/rules/`. Standard cycle: `> pentest/linux/results/findings.tsv && bash pentest/linux/run_all.sh && sudo python3 pentest/linux/phase4b_local_rules/run.py && python3 pentest/linux/generate_report.py`. +- Driver: [pentest/linux/run_all.sh](run_all.sh) runs phases 2 / 3 / 4 / 4b / 5 in order and then renders the HTML report. Phase 4b mutates `/var/lib/azure-proxy-agent/rules/` and can be scoped with `--phase4b-target {both,imds,wireserver}` or skipped with `--skip-phase4b`; the standard cycle is `sudo bash pentest/linux/run_all.sh`. --- diff --git a/pentest/linux/README.md b/pentest/linux/README.md index 33a7df75..c7457dc3 100644 --- a/pentest/linux/README.md +++ b/pentest/linux/README.md @@ -2,15 +2,24 @@ Local pen-test scaffolding for the Guest Proxy Agent running on this VM. +Mirror of the Windows harness under [../windows/](../windows/), with the +same scenario IDs and the same `findings.tsv` schema; design rationale +lives in [DESIGN.md](DESIGN.md). + ## Layout -- `lib/common.sh` — shared helpers (endpoints, logging, result recording). -- `phase2_listener/` — localhost listener / DoS / smuggling probes (Scenarios A, G). -- `phase3_authn_authz/` — AuthN/AuthZ matrix (Scenarios B, C). -- `phase4_rules_fuzz/` — URL/encoding differentials against the rule engine (Scenario D). -- `phase5_state_fs/` — filesystem permissions, key-keeper, log injection (Scenarios E, F). -- `run_all.sh` — convenience driver; runs phases 2 & 3 by default. -- `results/` — populated at runtime: `findings.tsv`, captured pcaps, raw output. +| File | Purpose | +|------|---------| +| [lib/common.sh](lib/common.sh) | Shared helpers: endpoints, paths, `record` (TSV writer), HTTP helpers, root check. | +| [test_catalog.py](test_catalog.py) | Per-test metadata (title / design / automation / repro / fix) consumed by the report. | +| [phase2_listener/run.sh](phase2_listener/run.sh) | A1, A1b, A2, A3, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | +| [phase3_authn_authz/run.sh](phase3_authn_authz/run.sh) | P3-cap (tcpdump), B1a, B1b (kernel-side `ss -tnp` proof), B3, B4, C1 (low-IL probe via `nobody`), C5 (`unshare -Cr`), C7 (alt-IP-form parity). | +| [phase4_rules_fuzz/url_diff.py](phase4_rules_fuzz/url_diff.py) | URL / encoding differential (11 variants of the IMDS token URL). | +| [phase4b_local_rules/run.py](phase4b_local_rules/run.py) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). Mutates `/var/lib/azure-proxy-agent/rules/`. | +| [phase5_state_fs/audit.sh](phase5_state_fs/audit.sh) | E1 (keys/log/binary/unit mode+owner), F1 (no secrets in observable JSON), F4 (Rules dir mode+owner — admin-only WRITE), bpftool snapshots. | +| [generate_report.py](generate_report.py) | Renders `results/report.html` from `results/findings.tsv` (+ latest `url_diff.tsv`). | +| [run_all.sh](run_all.sh) | Driver. Runs phases 2 / 3 / 4 / 4b / 5 + report by default. Flags: `--no-truncate`, `--skip-phase{2,3,4,4b,5}`, `--phase4b-target {both,imds,wireserver}`, `--no-report`. | +| `results/` | Populated at runtime: `findings.tsv`, `url_diff.tsv`, `report.html`, per-phase pcaps / bpftool snapshots, `phase4b_local_rules.log`. | ## Prereqs @@ -18,17 +27,52 @@ Local pen-test scaffolding for the Guest Proxy Agent running on this VM. sudo apt install -y nmap tcpdump bpftool radamsa python3-scapy wrk jq strace curl ``` +Python ≥ 3.8 (stdlib only — no `pip install` required for the harness or +the report generator). + The harness assumes: - `azure-proxy-agent.service` is active. - Listener is at `127.0.0.1:3080`. - Fabric endpoints `169.254.169.254:80`, `168.63.129.16:{80,32526}` are reachable. +- For **Phase 4b**: the fabric has delivered an `AuthorizationRules_*.json` + whose decoded JSON contains `useLocalFileRules:true` AND its effective + `mode` is `Enforce` for the chosen target(s). Phase 4b's `PRE` row will + fast-fail if either pre-condition is missing. +- For **Phase 5 F4**: the Rules dir (`/var/lib/azure-proxy-agent/rules`) + exists on disk so its mode/owner can be audited. + +## Run + +```bash +# Default — phases 2, 3, 4, 4b, 5 + HTML report (truncates findings.tsv first): +sudo ./pentest/linux/run_all.sh + +# Append instead of truncating: +sudo ./pentest/linux/run_all.sh --no-truncate + +# Skip the mutating Phase 4b on a production VM: +sudo ./pentest/linux/run_all.sh --skip-phase4b + +# Phase 4b alone, IMDS only: +sudo python3 pentest/linux/phase4b_local_rules/run.py --target imds + +# Re-render the report from the existing findings.tsv: +python3 pentest/linux/generate_report.py +``` ## Safety - Phase 2 sends only loopback traffic. - Phase 3 sends real requests to the Azure fabric — keep volumes low. -- Snapshot the VM (or back up `/var/lib/azure-proxy-agent` and `/var/log/azure-proxy-agent`) before Phase 5. -- Do **not** run Phase 5 fault-injection tests on a production VM. +- Phase 4b *writes* to `/var/lib/azure-proxy-agent/rules/` and bounces the + agent's view of the local rule file. Backups of any pre-existing rules + files are restored on exit (including `Ctrl+C` / crash); skip with + `--skip-phase4b` if running on a fabric that doesn't allow local rules. +- Snapshot the VM (or back up `/var/lib/azure-proxy-agent` and + `/var/log/azure-proxy-agent`) before Phase 5. +- Do **not** run Phase 5 fault-injection tests (E2 symlink, E3 disk-full, + E4 kill, E5 key downgrade) on a production VM; `audit.sh` itself is + read-only and safe. ## Result format @@ -38,4 +82,6 @@ Each test appends one TSV row to `results/findings.tsv`: \t\t\t ``` -A `FAIL` means the GPA invariant was violated (potential finding) and warrants triage. +A `FAIL` means the GPA invariant was violated (potential finding) and +warrants triage. Designs, repro steps, and suggested fixes are inlined +per row in the HTML report. diff --git a/pentest/windows/DESIGN.md b/pentest/windows/DESIGN.md index 5105c555..9ce0d4d8 100644 --- a/pentest/windows/DESIGN.md +++ b/pentest/windows/DESIGN.md @@ -152,10 +152,11 @@ Windows-specific deltas in Phase 4b: 2. Effective remote `mode` is `Enforce` for the chosen target(s). - Backups of any pre-existing `IMDS_Rules.json` / `WireServer_Rules.json` are restored on exit (including on `Ctrl+C` / crash). -- Opt-in from the driver: `-IncludePhase4b`. +- Run by the driver unconditionally; the target matrix can be narrowed + with `-Phase4bTarget {imds|wireserver|both}` (default `both`). ### Phase 5 — Filesystem / state audit -- E1, F1, P5-svc, P5-wfp → [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1). +- E1, F1, F4, P5-svc, P5-wfp → [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1). ### Phase 7 — Triage & report - All harness records are appended (TSV) to @@ -173,8 +174,9 @@ Windows-specific deltas in Phase 4b: 4. **Per-phase tables**. 5. **Phase 4 URL/encoding differential** — latest `url_diff.tsv`. 6. **Cheat sheet & artifacts**. -- Driver: [Run-AllPenTests.ps1](Run-AllPenTests.ps1) runs phases 2 / 3 / 4 / 5 - by default; phase 4b is opt-in via `-IncludePhase4b`. +- Driver: [Run-AllPenTests.ps1](Run-AllPenTests.ps1) runs phases 2 / 3 / 4 / 4b / 5 + in order, then renders the HTML report. Phase 4b can be scoped with + `-Phase4bTarget {imds|wireserver|both}`; skip the report with `-NoReport`. --- diff --git a/pentest/windows/README.md b/pentest/windows/README.md index d2cd6c85..9627e930 100644 --- a/pentest/windows/README.md +++ b/pentest/windows/README.md @@ -16,10 +16,10 @@ scenario IDs and the same `findings.tsv` schema; design rationale lives in | [Phase2-Listener.ps1](Phase2-Listener.ps1) | A1, A1b, A2, A3, A3-survive, A4 (CONNECT → 405), A5 (open-proxy), G1 (burst). | | [Phase3-AuthN-AuthZ.ps1](Phase3-AuthN-AuthZ.ps1) | P3-cap (pktmon), B1a, B1b (kernel-side WFP-redirect proof), B3 (key ACL), B4 (forged signature), C1 (low-IL probe), C5 (Containers note), C7 (alt-IP-form parity). | | [Phase4-RulesFuzz.ps1](Phase4-RulesFuzz.ps1) | URL / encoding differential (11 variants of the IMDS token URL). | -| [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1) | Local-file authorization-rules matrix: IMDS S1–S10 + WS S1–S10. | -| [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1) | E1 (ACL audit), F1 (no secrets in observable JSON), P5-svc, P5-wfp (`netsh wfp show filters`). | +| [Phase4b-LocalRules.ps1](Phase4b-LocalRules.ps1) | Local-file authorization-rules matrix: IMDS S1–S10 + S11/S12/S17/S18/S19 and WS S1–S10 + S11/S12/S17/S18/S19 (30 scenarios). | +| [Phase5-FileSystemAudit.ps1](Phase5-FileSystemAudit.ps1) | E1 (Keys/Logs/exe ACL audit), F1 (no secrets in observable JSON), F4 (Rules dir DACL — admin-only WRITE), P5-svc, P5-wfp (`netsh wfp show filters`). | | [Generate-Report.ps1](Generate-Report.ps1) | Renders `results\report.html` from `results\findings.tsv` (+ latest `url_diff.tsv`). Pure PowerShell. | -| [Run-AllPenTests.ps1](Run-AllPenTests.ps1) | Driver. Runs phases 2 / 3 / 4 / 5 + report by default; `-IncludePhase4b` to also run Phase 4b. | +| [Run-AllPenTests.ps1](Run-AllPenTests.ps1) | Driver. Runs phases 2 / 3 / 4 / 4b / 5 + report by default. Flags: `-TruncateFindings`, `-SkipBurst`, `-Phase4bTarget {imds|wireserver|both}`, `-NoReport`. | | `results/` | Populated at runtime: `findings.tsv`, `url_diff.tsv`, `report.html`, pktmon `.etl` captures, `wfp_filters_windows.txt`. | ## Prereqs @@ -36,11 +36,12 @@ equivalent of `pentest/linux/generate_report.py`. ## Run ```powershell -# All default phases (2, 3, 4, 5) + report: +# Default — phases 2, 3, 4, 4b, 5 + HTML report (Phase 4b mutates the Rules +# dir, so the test VM must be one where useLocalFileRules is honored): powershell -ExecutionPolicy Bypass -File .\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings -# Include Phase 4b (opt-in; mutates the Rules dir, requires useLocalFileRules=true): -.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings -IncludePhase4b +# Phase 4b: only the IMDS half (skips the WireServer mutations): +.\pentest\windows\Run-AllPenTests.ps1 -TruncateFindings -Phase4bTarget imds # Phase 4b alone, IMDS only: .\pentest\windows\Phase4b-LocalRules.ps1 -Target imds @@ -53,6 +54,8 @@ powershell -ExecutionPolicy Bypass -File .\pentest\windows\Run-AllPenTests.ps1 - `> findings.tsv` on Linux). Omit it to append. `-SkipBurst` skips the Phase 2 G1 burst (useful on production VMs where the 200-connect spike would trigger alerts). +`-Phase4bTarget` chooses which Phase 4b target matrix to write (`both`, +`imds`, or `wireserver`; default `both`). `-NoReport` skips the final `Generate-Report.ps1` invocation. ## Result format From c0a62fb138a4b4fe6e0bc78c7b1904648623dfa8 Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 28 May 2026 13:19:55 -0700 Subject: [PATCH 27/37] Fix windows automation scripts --- pentest/windows/Phase3-AuthN-AuthZ.ps1 | 2 +- pentest/windows/Phase4b-LocalRules.ps1 | 152 ++++++++++++++++++------- pentest/windows/TestCatalog.psm1 | 4 +- 3 files changed, 116 insertions(+), 42 deletions(-) diff --git a/pentest/windows/Phase3-AuthN-AuthZ.ps1 b/pentest/windows/Phase3-AuthN-AuthZ.ps1 index c4cae670..9f7cb494 100644 --- a/pentest/windows/Phase3-AuthN-AuthZ.ps1 +++ b/pentest/windows/Phase3-AuthN-AuthZ.ps1 @@ -35,7 +35,7 @@ if ($StandardUserCredPath -and (Test-Path $StandardUserCredPath)) { $pktmonStarted = $false if (Get-Command pktmon -ErrorAction SilentlyContinue) { try { - $cap = Join-Path $ResultsDir ("phase3-windows-{0}.etl" -f (Get-Date -Format yyyyMMddTHHmmssZ)) + $cap = Join-Path $ResultsDir ("phase3-windows-{0}.etl" -f (Get-Date).ToUniversalTime().ToString("yyyyMMdd'T'HHmmss'Z'")) & pktmon filter remove | Out-Null & pktmon filter add -i $ImdsIp | Out-Null & pktmon filter add -i $WireServerIp | Out-Null diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index 1f2da459..c3e39caf 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -136,6 +136,58 @@ $Identity = @{ Write-Finding CFG INFO "current identity: user=$($Identity.userName) group=$($Identity.groupName)" Write-Finding CFG INFO "targets: $Target" +# Latin → Cyrillic visual-homoglyph table used by the S19 scenarios. Pairs +# only characters that render (in common fonts) as visually indistinguishable +# from their Latin counterparts — that is exactly the confusable-character +# attack class the test is meant to exercise. Keep this list conservative; +# adding non-confusable pairs would dilute the meaning of the test. +$script:HomoglyphMap = @{ + 'a'=[char]0x0430; 'A'=[char]0x0410 + 'c'=[char]0x0441; 'C'=[char]0x0421 + 'e'=[char]0x0435; 'E'=[char]0x0415 + 'o'=[char]0x043E; 'O'=[char]0x041E + 'p'=[char]0x0440; 'P'=[char]0x0420 + 'x'=[char]0x0445; 'X'=[char]0x0425 + 'y'=[char]0x0443; 'Y'=[char]0x0423 + 'B'=[char]0x0412; 'H'=[char]0x041D + 'K'=[char]0x041A; 'M'=[char]0x041C + 'T'=[char]0x0422 +} + +# Returns a copy of $UserName with the first character that has a Cyrillic +# look-alike replaced by its homoglyph (plus the position swapped, for the +# log message). Returns $null if no character in $UserName has a homoglyph — +# in that case the caller should skip the S19 scenario because there's no +# way to construct a meaningful visual-confusion test against this user. +function Get-HomoglyphUserName { + param([string] $UserName) + if (-not $UserName) { return $null } + for ($i = 0; $i -lt $UserName.Length; $i++) { + $ch = $UserName[$i].ToString() + if ($script:HomoglyphMap.ContainsKey($ch)) { + $homo = $script:HomoglyphMap[$ch] + $swapped = $UserName.Substring(0,$i) + $homo + $UserName.Substring($i+1) + return [pscustomobject]@{ + UserName = $swapped + Original = $ch + Replaced = $homo + Index = $i + } + } + } + return $null +} + +$script:HomoglyphInfo = Get-HomoglyphUserName -UserName $Identity.userName +if ($script:HomoglyphInfo) { + Write-Finding CFG INFO ("S19 homoglyph: '{0}' → '{1}' (swapped '{2}' at idx {3} for U+{4:X4})" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName, + $script:HomoglyphInfo.Original, $script:HomoglyphInfo.Index, + [int]$script:HomoglyphInfo.Replaced) +} else { + Write-Finding CFG INFO ("S19 homoglyph: no Latin character in '{0}' has a Cyrillic look-alike in our table; S19 scenarios will be skipped" -f $Identity.userName) +} + # --- scenario data ---------------------------------------------------------- class Probe { @@ -381,27 +433,41 @@ function Build-ImdsScenarios { [Probe]::new('versions_denied', '/metadata/versions', 403) ) - # S19: identity userName uses a Cyrillic homoglyph (А = U+0410) instead - # of ASCII A. Matcher must compare bytes (not Unicode-normalized) so the - # current user "Administrator" does not match "Аdministrator". - $homoIdentity = $Identity.Clone() - $homoIdentity.name = 'homoUser' - $homoIdentity.userName = ([char]0x0410) + ($Identity.userName.Substring(1)) # А + dministrator - $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName imds ` - -Description 'Cyrillic-А homoglyph in userName must NOT match ASCII current user → 403' ` - -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s19' - rules=@{ - privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) - identities = @( $homoIdentity ) - roleAssignments = @( @{ role='r_instance'; identities=@($homoIdentity.name) } ) - } - } ` - -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + # S19: identity userName uses a Cyrillic homoglyph in place of one of the + # current user's ASCII characters (e.g. "Аdministrator" with a Cyrillic А, + # or "аzureuser" with a Cyrillic а). The two strings render identically + # in most fonts but are NOT byte-equal. The matcher must compare bytes + # (not Unicode-normalized / NFKC forms), so the current user must NOT + # match the rule → 403. A 200 would mean the matcher is applying Unicode + # normalization in identity comparison, which is a confusable-character + # bypass. + # + # If the current user's name has no character with a Cyrillic look-alike + # in our table (e.g. all digits, or already non-Latin), there is no + # meaningful way to construct this test — skip with an INFO row. + if ($script:HomoglyphInfo) { + $homoIdentity = $Identity.Clone() + $homoIdentity.name = 'homoUser' + $homoIdentity.userName = $script:HomoglyphInfo.UserName + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName imds ` + -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName) ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-s19' + rules=@{ + privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) + roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) + identities = @( $homoIdentity ) + roleAssignments = @( @{ role='r_instance'; identities=@($homoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + } else { + Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) + } return $scn } @@ -603,24 +669,32 @@ function Build-WireServerScenarios { [Probe]::new('versions_denied', $VERSIONS, 403) ) - $wsHomoIdentity = $Identity.Clone() - $wsHomoIdentity.name = 'homoUser' - $wsHomoIdentity.userName = ([char]0x0410) + ($Identity.userName.Substring(1)) - $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName wireserver ` - -Description 'Cyrillic-А homoglyph in userName must NOT match ASCII current user → 403' ` - -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s19' - rules=@{ - privileges = @( @{ name='p_machine'; path='/machine/' } ) - roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) - identities = @( $wsHomoIdentity ) - roleAssignments = @( @{ role='r_machine'; identities=@($wsHomoIdentity.name) } ) - } - } ` - -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + # S19: see IMDS-S19. Cyrillic homoglyph in one of the current user's + # characters; matcher must compare bytes → must NOT match → 403. Skip + # when the current user has no character with a Cyrillic look-alike. + if ($script:HomoglyphInfo) { + $wsHomoIdentity = $Identity.Clone() + $wsHomoIdentity.name = 'homoUser' + $wsHomoIdentity.userName = $script:HomoglyphInfo.UserName + $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName wireserver ` + -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` + $Identity.userName, $script:HomoglyphInfo.UserName) ` + -Rules @{ + defaultAccess='deny'; mode='enforce'; id='pentest-ws-s19' + rules=@{ + privileges = @( @{ name='p_machine'; path='/machine/' } ) + roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) + identities = @( $wsHomoIdentity ) + roleAssignments = @( @{ role='r_machine'; identities=@($wsHomoIdentity.name) } ) + } + } ` + -Probes @( + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + } else { + Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) + } return $scn } diff --git a/pentest/windows/TestCatalog.psm1 b/pentest/windows/TestCatalog.psm1 index bb41e2c6..a67e430f 100644 --- a/pentest/windows/TestCatalog.psm1 +++ b/pentest/windows/TestCatalog.psm1 @@ -209,7 +209,7 @@ $script:Phase4b = @{ 'IMDS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins'; Design='Writes `{"defaultAccess":"allow","defaultAccess":"deny"}`. serde_json must take last-wins per RFC 8259 — 200 would mean first-wins (silent escalation) or rule loaded as allow-by-default.' } 'IMDS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix'; Design='Tests that `prefix_local_rule_names` rewrites both names AND cross-references consistently even when the admin pre-includes the prefix — so the rule still binds. 403 on /metadata/instance would mean the prefixer mishandled the doubled prefix.' } 'IMDS-S18-dangling-references'= @{ Title='Dangling role→privilege reference must reject'; Design='Validator must reject; agent fail-closes to defaultAccess=deny. 200 means dangling-ref validation regressed.' } - 'IMDS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match ASCII user'; Design='Identity userName uses Cyrillic А (U+0410) in place of ASCII A. Matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ.' } + 'IMDS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match ASCII user'; Design='Identity userName uses a Cyrillic look-alike (e.g. а=U+0430, е=U+0435, о=U+043E, …) in place of one character of the current user''s ASCII name. The two strings render identically in most fonts but are NOT byte-equal. Matcher must compare bytes, not Unicode-normalized forms — 200 would mean Unicode normalization is happening in identity comparison and could be used to bypass authZ. Skipped (INFO) when the current user has no character with a Cyrillic look-alike in the harness table.' } 'WS-S1-disabled-allow' = @{ Title='Control: mode=disabled + allow (WireServer)'; Design='Baseline 200s for goalstate and versions.' } 'WS-S2-enforce-deny-empty' = @{ Title='Fail-closed enforce+deny (WireServer)'; Design='All WireServer calls 403.' } 'WS-S3-audit-deny-empty' = @{ Title='Local `mode` field must be ignored (WireServer regression guard)'; Design='Writes mode=audit + defaultAccess=deny + empty rules to WireServer_Rules.json. PRE confirms remote mode=enforce, so probes must 403. A 200 would mean the agent honored `mode` from the local file.' } @@ -224,7 +224,7 @@ $script:Phase4b = @{ 'WS-S12-duplicate-keys' = @{ Title='Duplicate JSON keys resolve last-wins (WireServer)'; Design='Same contract as IMDS-S12 against WireServer.' } 'WS-S17-name-collision-shielded' = @{ Title='Local rule names already containing LocalFileRules_ prefix (WireServer)'; Design='Same contract as IMDS-S17 against /machine/.' } 'WS-S18-dangling-references' = @{ Title='Dangling role→privilege reference must reject (WireServer)'; Design='Validator must reject; agent fail-closes.' } - 'WS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match (WireServer)'; Design='Same contract as IMDS-S19 against /machine/.' } + 'WS-S19-identity-homoglyph' = @{ Title='Cyrillic homoglyph in userName must NOT match (WireServer)'; Design='Same contract as IMDS-S19 against /machine/. Skipped (INFO) when the current user has no character with a Cyrillic look-alike in the harness table.' } } function Get-CatalogEntry { From 198b2d01304e27b8fa44d57487af7cf302e9871f Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 28 May 2026 13:32:01 -0700 Subject: [PATCH 28/37] fix --- pentest/windows/Common.psm1 | 6 +- pentest/windows/Phase4b-LocalRules.ps1 | 574 +++++++++++++------------ 2 files changed, 297 insertions(+), 283 deletions(-) diff --git a/pentest/windows/Common.psm1 b/pentest/windows/Common.psm1 index e750c7b4..3b630900 100644 --- a/pentest/windows/Common.psm1 +++ b/pentest/windows/Common.psm1 @@ -319,9 +319,9 @@ function Remove-PenTestStandardUser { # 4) Remove the user profile directory and its Win32_UserProfile entry. try { - $profile = Join-Path $env:SystemDrive "Users\$name" - if (Test-Path $profile) { - Remove-Item -Path $profile -Recurse -Force -ErrorAction SilentlyContinue + $profilePath = Join-Path $env:SystemDrive "Users\$name" + if (Test-Path $profilePath) { + Remove-Item -Path $profilePath -Recurse -Force -ErrorAction SilentlyContinue } Get-CimInstance Win32_UserProfile -ErrorAction SilentlyContinue | Where-Object { $_.LocalPath -like "*\$name" } | diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index c3e39caf..bd4606c3 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -27,9 +27,9 @@ Import-Module (Join-Path $PSScriptRoot 'Common.psm1') -Force # --- environment ------------------------------------------------------------- $ImdsRulesFile = Join-Path $GpaRulesDir 'IMDS_Rules.json' -$WsRulesFile = Join-Path $GpaRulesDir 'WireServer_Rules.json' -$PollFallback = 15 -$SlackSeconds = 5 +$WsRulesFile = Join-Path $GpaRulesDir 'WireServer_Rules.json' +$PollFallback = 15 +$SlackSeconds = 5 if (-not (Test-Administrator)) { Write-Finding PRE FAIL 'Phase 4b must be run elevated (the Rules dir is admin/SYSTEM-only).' @@ -39,7 +39,8 @@ if (-not (Test-Administrator)) { if (-not (Test-Path $GpaRulesDir)) { try { New-Item -ItemType Directory -Path $GpaRulesDir -Force -ErrorAction Stop | Out-Null - } catch { + } + catch { Write-Finding PRE FAIL "Rules dir $GpaRulesDir does not exist and could not be created: $_" exit 2 } @@ -54,7 +55,8 @@ if (-not $PollSeconds -or $PollSeconds -le 0) { if ($j.pollKeyStatusIntervalInSeconds) { $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds } - } catch { } + } + catch { } } if (-not $PollSeconds) { # Try the source-tree config as a last resort. @@ -65,7 +67,8 @@ if (-not $PollSeconds -or $PollSeconds -le 0) { if ($j.pollKeyStatusIntervalInSeconds) { $PollSeconds = [int]$j.pollKeyStatusIntervalInSeconds } - } catch { } + } + catch { } } } if (-not $PollSeconds) { $PollSeconds = $PollFallback } @@ -77,7 +80,7 @@ Write-Finding CFG INFO "poll interval = ${PollSeconds}s" function Get-LatestAuthRulesSnapshot { Get-ChildItem -Path $GpaLogDir -Filter 'AuthorizationRules_*.json' -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Sort-Object LastWriteTime -Descending | Select-Object -First 1 } function Assert-UseLocalFileRules { @@ -103,7 +106,8 @@ function Assert-UseLocalFileRules { if (-not $m -and $j.inputRules -and $j.inputRules.$t) { $m = $j.inputRules.$t.mode } if ($m) { $modes[$t] = $m } } - } catch { } + } + catch { } $nonEnforce = @{} foreach ($k in $modes.Keys) { if ($modes[$k].ToLower() -ne 'enforce') { $nonEnforce[$k] = $modes[$k] } @@ -142,16 +146,16 @@ Write-Finding CFG INFO "targets: $Target" # attack class the test is meant to exercise. Keep this list conservative; # adding non-confusable pairs would dilute the meaning of the test. $script:HomoglyphMap = @{ - 'a'=[char]0x0430; 'A'=[char]0x0410 - 'c'=[char]0x0441; 'C'=[char]0x0421 - 'e'=[char]0x0435; 'E'=[char]0x0415 - 'o'=[char]0x043E; 'O'=[char]0x041E - 'p'=[char]0x0440; 'P'=[char]0x0420 - 'x'=[char]0x0445; 'X'=[char]0x0425 - 'y'=[char]0x0443; 'Y'=[char]0x0423 - 'B'=[char]0x0412; 'H'=[char]0x041D - 'K'=[char]0x041A; 'M'=[char]0x041C - 'T'=[char]0x0422 + 'a' = [char]0x0430; 'A' = [char]0x0410 + 'c' = [char]0x0441; 'C' = [char]0x0421 + 'e' = [char]0x0435; 'E' = [char]0x0415 + 'o' = [char]0x043E; 'O' = [char]0x041E + 'p' = [char]0x0440; 'P' = [char]0x0420 + 'x' = [char]0x0445; 'X' = [char]0x0425 + 'y' = [char]0x0443; 'Y' = [char]0x0423 + 'B' = [char]0x0412; 'H' = [char]0x041D + 'K' = [char]0x041A; 'M' = [char]0x041C + 'T' = [char]0x0422 } # Returns a copy of $UserName with the first character that has a Cyrillic @@ -166,7 +170,7 @@ function Get-HomoglyphUserName { $ch = $UserName[$i].ToString() if ($script:HomoglyphMap.ContainsKey($ch)) { $homo = $script:HomoglyphMap[$ch] - $swapped = $UserName.Substring(0,$i) + $homo + $UserName.Substring($i+1) + $swapped = $UserName.Substring(0, $i) + $homo + $UserName.Substring($i + 1) return [pscustomobject]@{ UserName = $swapped Original = $ch @@ -181,10 +185,11 @@ function Get-HomoglyphUserName { $script:HomoglyphInfo = Get-HomoglyphUserName -UserName $Identity.userName if ($script:HomoglyphInfo) { Write-Finding CFG INFO ("S19 homoglyph: '{0}' → '{1}' (swapped '{2}' at idx {3} for U+{4:X4})" -f ` - $Identity.userName, $script:HomoglyphInfo.UserName, + $Identity.userName, $script:HomoglyphInfo.UserName, $script:HomoglyphInfo.Original, $script:HomoglyphInfo.Index, [int]$script:HomoglyphInfo.Replaced) -} else { +} +else { Write-Finding CFG INFO ("S19 homoglyph: no Latin character in '{0}' has a Cyrillic look-alike in our table; S19 scenarios will be skipped" -f $Identity.userName) } @@ -229,20 +234,20 @@ function Build-ImdsScenarios { # S1 $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName imds ` -Description 'mode=disabled, defaultAccess=allow → no enforcement, baseline 200s' ` - -Rules @{ defaultAccess='allow'; mode='disabled'; id='pentest-s1'; rules=@{} } ` + -Rules @{ defaultAccess = 'allow'; mode = 'disabled'; id = 'pentest-s1'; rules = @{} } ` -Probes @( - [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 200), - [Probe]::new('token', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 200) - ) + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 200) + ) # S2 $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName imds ` -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` - -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-s2'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s2'; rules = @{} } ` -Probes @( - [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions', '/metadata/versions', 403) - ) + [Probe]::new('instance', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions', '/metadata/versions', 403) + ) # S3 — regression test: the agent MUST ignore 'mode' written into the # local rules file. PRE has already confirmed the remote mode is enforce, @@ -252,95 +257,95 @@ function Build-ImdsScenarios { # LocalAuthorizationRulesFile (see proxy_agent/src/key_keeper/local_rules.rs). $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName imds ` -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` - -Rules @{ defaultAccess='deny'; mode='audit'; id='pentest-s3'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'audit'; id = 'pentest-s3'; rules = @{} } ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S4 $scn += New-Scenario -Sid "$pfx-S4-allow-one-path" -TargetName imds ` -Description 'enforce + deny, allow only /metadata/instance for current identity' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s4' - rules=@{ - privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_instance'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s4' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), - [Probe]::new('token_denied', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('token_denied', '/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S5 - $bogus = $Identity.Clone(); $bogus.name='nobodyId'; $bogus.userName='nosuchuser_xyz' + $bogus = $Identity.Clone(); $bogus.name = 'nobodyId'; $bogus.userName = 'nosuchuser_xyz' $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName imds ` -Description 'enforce + deny, allow path /metadata/instance for a non-matching user' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s5' - rules=@{ - privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) - identities = @( $bogus ) - roleAssignments = @( @{ role='r_instance'; identities=@($bogus.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s5' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($bogus.name) } ) + } + } ` -Probes @( [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403) ) # S6 $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName imds ` -Description 'enforce + deny; allow /metadata/instance; test %2F-encoded slash variants of token path' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s6' - rules=@{ - privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_instance'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s6' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('token_uppercase_pct', '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), - [Probe]::new('token_lowercase_pct', '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), - [Probe]::new('token_double_encoded', '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), - [Probe]::new('token_question_encoded', '/metadata/identity/oauth2/token%3Fapi-version=2018-02-01&resource=https://management.azure.com/', 403), - [Probe]::new('token_dot_segments', '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', @(403, 404)) - ) + [Probe]::new('token_uppercase_pct', '/metadata/identity/oauth2%2Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_lowercase_pct', '/metadata/identity/oauth2%2ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_double_encoded', '/metadata/identity/oauth2%252Ftoken?api-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_question_encoded', '/metadata/identity/oauth2/token%3Fapi-version=2018-02-01&resource=https://management.azure.com/', 403), + [Probe]::new('token_dot_segments', '/metadata/./identity/../identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/', @(403, 404)) + ) # S7 $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName imds ` -Description 'enforce + deny; allow /metadata/instance ONLY with api-version=2021-02-01' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s7' - rules=@{ - privileges = @( @{ name='p_instance_q'; path='/metadata/instance'; queryParameters=@{ 'api-version'='2021-02-01' } } ) - roles = @( @{ name='r_q'; privileges=@('p_instance_q') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_q'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s7' + rules = @{ + privileges = @( @{ name = 'p_instance_q'; path = '/metadata/instance'; queryParameters = @{ 'api-version' = '2021-02-01' } } ) + roles = @( @{ name = 'r_q'; privileges = @('p_instance_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_q'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('matching_version', '/metadata/instance?api-version=2021-02-01', 200), - [Probe]::new('missing_version', '/metadata/instance', 403), - [Probe]::new('wrong_version', '/metadata/instance?api-version=2017-04-02', 403) - ) + [Probe]::new('matching_version', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('missing_version', '/metadata/instance', 403), + [Probe]::new('wrong_version', '/metadata/instance?api-version=2017-04-02', 403) + ) # S8 $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName imds ` -Description 'enforce + deny; identity matches by groupName only' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s8' - rules=@{ - privileges = @( @{ name='p_any'; path='/metadata/' } ) - roles = @( @{ name='r_any'; privileges=@('p_any') } ) - identities = @( @{ name='adminGroup'; groupName=$Identity.groupName } ) - roleAssignments = @( @{ role='r_any'; identities=@('adminGroup') } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s8' + rules = @{ + privileges = @( @{ name = 'p_any'; path = '/metadata/' } ) + roles = @( @{ name = 'r_any'; privileges = @('p_any') } ) + identities = @( @{ name = 'adminGroup'; groupName = $Identity.groupName } ) + roleAssignments = @( @{ role = 'r_any'; identities = @('adminGroup') } ) + } + } ` -Probes @( [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) # S9 — caller is PowerShell; allow only that exePath. Probe sent via this @@ -350,14 +355,14 @@ function Build-ImdsScenarios { $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName imds ` -Description "enforce + deny; identity restricts to exePath=$psExe" ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s9' - rules=@{ - privileges = @( @{ name='p_any'; path='/metadata/' } ) - roles = @( @{ name='r_any'; privileges=@('p_any') } ) - identities = @( @{ name='psOnly'; exePath=$psExe } ) - roleAssignments = @( @{ role='r_any'; identities=@('psOnly') } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s9' + rules = @{ + privileges = @( @{ name = 'p_any'; path = '/metadata/' } ) + roles = @( @{ name = 'r_any'; privileges = @('p_any') } ) + identities = @( @{ name = 'psOnly'; exePath = $psExe } ) + roleAssignments = @( @{ role = 'r_any'; identities = @('psOnly') } ) + } + } ` -Probes @( [Probe]::new('powershell_caller_allowed', '/metadata/instance?api-version=2021-02-01', 200) ) # S10 — malformed JSON @@ -365,9 +370,9 @@ function Build-ImdsScenarios { -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` -Rules '{ this is not valid json' -Raw ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # ------------------------------------------------------------------ # S11..S19 — local rules file as adversarial admin input @@ -378,12 +383,12 @@ function Build-ImdsScenarios { # must trim the BOM before parsing; resulting valid rules deny all. $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName imds ` -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` - -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-s11'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s11'; rules = @{} } ` -Bom ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S12: duplicate "defaultAccess" keys (allow then deny). serde_json takes # last-wins per RFC 8259 guidance; the agent must therefore deny. @@ -391,9 +396,9 @@ function Build-ImdsScenarios { -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-s12","rules":{}}' -Raw ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S17: local rule names that ALREADY start with the LocalFileRules_ prefix. # Validates that prefix_local_rule_names is idempotent / safe (it rewrites @@ -402,36 +407,36 @@ function Build-ImdsScenarios { $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName imds ` -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s17' - rules=@{ - privileges = @( @{ name='LocalFileRules_p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='LocalFileRules_r_instance'; privileges=@('LocalFileRules_p_instance') } ) - identities = @( $s17Identity ) - roleAssignments = @( @{ role='LocalFileRules_r_instance'; identities=@($s17Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s17' + rules = @{ + privileges = @( @{ name = 'LocalFileRules_p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'LocalFileRules_r_instance'; privileges = @('LocalFileRules_p_instance') } ) + identities = @( $s17Identity ) + roleAssignments = @( @{ role = 'LocalFileRules_r_instance'; identities = @($s17Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_allowed', '/metadata/instance?api-version=2021-02-01', 200), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S18: dangling role -> privilege reference. validate_local_rules_file # must reject; agent fail-closes to defaultAccess=deny. $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName imds ` -Description 'role references non-existent privilege → validator rejects → 403' ` -Rules @{ - defaultAccess='allow'; mode='enforce'; id='pentest-s18' - rules=@{ - privileges = @( @{ name='p_real'; path='/metadata/' } ) - roles = @( @{ name='r_broken'; privileges=@('p_ghost') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_broken'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'allow'; mode = 'enforce'; id = 'pentest-s18' + rules = @{ + privileges = @( @{ name = 'p_real'; path = '/metadata/' } ) + roles = @( @{ name = 'r_broken'; privileges = @('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_broken'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) # S19: identity userName uses a Cyrillic homoglyph in place of one of the # current user's ASCII characters (e.g. "Аdministrator" with a Cyrillic А, @@ -447,25 +452,26 @@ function Build-ImdsScenarios { # meaningful way to construct this test — skip with an INFO row. if ($script:HomoglyphInfo) { $homoIdentity = $Identity.Clone() - $homoIdentity.name = 'homoUser' + $homoIdentity.name = 'homoUser' $homoIdentity.userName = $script:HomoglyphInfo.UserName $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName imds ` -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` $Identity.userName, $script:HomoglyphInfo.UserName) ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-s19' - rules=@{ - privileges = @( @{ name='p_instance'; path='/metadata/instance' } ) - roles = @( @{ name='r_instance'; privileges=@('p_instance') } ) - identities = @( $homoIdentity ) - roleAssignments = @( @{ role='r_instance'; identities=@($homoIdentity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-s19' + rules = @{ + privileges = @( @{ name = 'p_instance'; path = '/metadata/instance' } ) + roles = @( @{ name = 'r_instance'; privileges = @('p_instance') } ) + identities = @( $homoIdentity ) + roleAssignments = @( @{ role = 'r_instance'; identities = @($homoIdentity.name) } ) + } + } ` -Probes @( - [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), - [Probe]::new('versions_denied', '/metadata/versions', 403) - ) - } else { + [Probe]::new('instance_denied', '/metadata/instance?api-version=2021-02-01', 403), + [Probe]::new('versions_denied', '/metadata/versions', 403) + ) + } + else { Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) } @@ -478,145 +484,145 @@ function Build-WireServerScenarios { $scn = @() $GOALSTATE = '/machine/?comp=goalstate' - $SHARED = '/machine/?comp=sharedConfig' - $HOSTING = '/machine/?comp=hostingenvironmentconfig' - $CERTS = '/machine/?comp=certificates' + $SHARED = '/machine/?comp=sharedConfig' + $HOSTING = '/machine/?comp=hostingenvironmentconfig' + $CERTS = '/machine/?comp=certificates' $EXTCONFIG = '/machine/?comp=extensionsConfig' - $VERSIONS = '/?comp=versions' + $VERSIONS = '/?comp=versions' $scn += New-Scenario -Sid "$pfx-S1-disabled-allow" -TargetName wireserver ` -Description 'mode=disabled, defaultAccess=allow → baseline 200s' ` - -Rules @{ defaultAccess='allow'; mode='disabled'; id='pentest-ws-s1'; rules=@{} } ` + -Rules @{ defaultAccess = 'allow'; mode = 'disabled'; id = 'pentest-ws-s1'; rules = @{} } ` -Probes @( - [Probe]::new('goalstate', $GOALSTATE, 200), - [Probe]::new('versions', $VERSIONS, 200) - ) + [Probe]::new('goalstate', $GOALSTATE, 200), + [Probe]::new('versions', $VERSIONS, 200) + ) $scn += New-Scenario -Sid "$pfx-S2-enforce-deny-empty" -TargetName wireserver ` -Description 'mode=enforce, defaultAccess=deny, no rules → all 403' ` - -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-ws-s2'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s2'; rules = @{} } ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403), - [Probe]::new('shared_denied', $SHARED, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403), + [Probe]::new('shared_denied', $SHARED, 403) + ) # S3 — regression test: the agent MUST ignore 'mode' written into the # local rules file. Same contract as IMDS-S3. $scn += New-Scenario -Sid "$pfx-S3-audit-deny-empty" -TargetName wireserver ` -Description 'local mode=audit must be ignored; remote enforce + local deny → 403' ` - -Rules @{ defaultAccess='deny'; mode='audit'; id='pentest-ws-s3'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'audit'; id = 'pentest-ws-s3'; rules = @{} } ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) $scn += New-Scenario -Sid "$pfx-S4-allow-goalstate-only" -TargetName wireserver ` -Description 'enforce + deny; allow only /machine/ with comp=goalstate' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s4' - rules=@{ - privileges = @( @{ name='p_goalstate'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) - roles = @( @{ name='r_goalstate'; privileges=@('p_goalstate') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_goalstate'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s4' + rules = @{ + privileges = @( @{ name = 'p_goalstate'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_goalstate'; privileges = @('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_goalstate'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('goalstate_allowed', $GOALSTATE, 200), - [Probe]::new('shared_denied', $SHARED, 403), - [Probe]::new('hosting_denied', $HOSTING, 403), - [Probe]::new('certs_denied', $CERTS, 403), - [Probe]::new('extconfig_denied', $EXTCONFIG, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_allowed', $GOALSTATE, 200), + [Probe]::new('shared_denied', $SHARED, 403), + [Probe]::new('hosting_denied', $HOSTING, 403), + [Probe]::new('certs_denied', $CERTS, 403), + [Probe]::new('extconfig_denied', $EXTCONFIG, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) - $bogus = $Identity.Clone(); $bogus.name='nobodyId'; $bogus.userName='nosuchuser_xyz' + $bogus = $Identity.Clone(); $bogus.name = 'nobodyId'; $bogus.userName = 'nosuchuser_xyz' $scn += New-Scenario -Sid "$pfx-S5-wrong-identity" -TargetName wireserver ` -Description 'enforce + deny; allow goalstate but bound to non-matching user' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s5' - rules=@{ - privileges = @( @{ name='p_machine'; path='/machine/' } ) - roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) - identities = @( $bogus ) - roleAssignments = @( @{ role='r_machine'; identities=@($bogus.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s5' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( $bogus ) + roleAssignments = @( @{ role = 'r_machine'; identities = @($bogus.name) } ) + } + } ` -Probes @( [Probe]::new('goalstate_denied', $GOALSTATE, 403) ) $scn += New-Scenario -Sid "$pfx-S6-encoding-bypass" -TargetName wireserver ` -Description 'enforce + deny; allow only goalstate; test encoded/path-tricks for /machine/?comp=certificates' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s6' - rules=@{ - privileges = @( @{ name='p_goalstate'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) - roles = @( @{ name='r_goalstate'; privileges=@('p_goalstate') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_goalstate'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s6' + rules = @{ + privileges = @( @{ name = 'p_goalstate'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_goalstate'; privileges = @('p_goalstate') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_goalstate'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('certs_uppercase_pct', '/machine%2F?comp=certificates', 403), - [Probe]::new('certs_question_encoded', '/machine/%3Fcomp=certificates', 403), - [Probe]::new('certs_dot_segments', '/machine/./../machine/?comp=certificates', @(403, 404)), - [Probe]::new('certs_extra_slashes', '//machine///?comp=certificates', 403), - [Probe]::new('certs_value_case', '/machine/?comp=Certificates', 403), - [Probe]::new('goalstate_value_case_should_match', '/machine/?comp=GOALSTATE', 200) - ) + [Probe]::new('certs_uppercase_pct', '/machine%2F?comp=certificates', 403), + [Probe]::new('certs_question_encoded', '/machine/%3Fcomp=certificates', 403), + [Probe]::new('certs_dot_segments', '/machine/./../machine/?comp=certificates', @(403, 404)), + [Probe]::new('certs_extra_slashes', '//machine///?comp=certificates', 403), + [Probe]::new('certs_value_case', '/machine/?comp=Certificates', 403), + [Probe]::new('goalstate_value_case_should_match', '/machine/?comp=GOALSTATE', 200) + ) $scn += New-Scenario -Sid "$pfx-S7-query-param-required" -TargetName wireserver ` -Description 'enforce + deny; allow /machine/ ONLY with comp=goalstate' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s7' - rules=@{ - privileges = @( @{ name='p_goalstate_q'; path='/machine/'; queryParameters=@{ comp='goalstate' } } ) - roles = @( @{ name='r_q'; privileges=@('p_goalstate_q') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_q'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s7' + rules = @{ + privileges = @( @{ name = 'p_goalstate_q'; path = '/machine/'; queryParameters = @{ comp = 'goalstate' } } ) + roles = @( @{ name = 'r_q'; privileges = @('p_goalstate_q') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_q'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('matching_comp', $GOALSTATE, 200), - [Probe]::new('missing_comp', '/machine/', 403), - [Probe]::new('wrong_comp', '/machine/?comp=hostingenvironmentconfig', 403), - [Probe]::new('extra_param_ok', '/machine/?comp=goalstate&incarnation=1', @(200, 400)) - ) + [Probe]::new('matching_comp', $GOALSTATE, 200), + [Probe]::new('missing_comp', '/machine/', 403), + [Probe]::new('wrong_comp', '/machine/?comp=hostingenvironmentconfig', 403), + [Probe]::new('extra_param_ok', '/machine/?comp=goalstate&incarnation=1', @(200, 400)) + ) $scn += New-Scenario -Sid "$pfx-S8-group-only-identity" -TargetName wireserver ` -Description 'enforce + deny; identity matches by groupName only' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s8' - rules=@{ - privileges = @( @{ name='p_machine'; path='/machine/' } ) - roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) - identities = @( @{ name='adminGroup'; groupName=$Identity.groupName } ) - roleAssignments = @( @{ role='r_machine'; identities=@('adminGroup') } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s8' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( @{ name = 'adminGroup'; groupName = $Identity.groupName } ) + roleAssignments = @( @{ role = 'r_machine'; identities = @('adminGroup') } ) + } + } ` -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) $psExe = (Get-Process -Id $PID).Path $scn += New-Scenario -Sid "$pfx-S9-exepath-identity" -TargetName wireserver ` -Description "enforce + deny; identity restricts to exePath=$psExe" ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s9' - rules=@{ - privileges = @( @{ name='p_machine'; path='/machine/' } ) - roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) - identities = @( @{ name='psOnly'; exePath=$psExe } ) - roleAssignments = @( @{ role='r_machine'; identities=@('psOnly') } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s9' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( @{ name = 'psOnly'; exePath = $psExe } ) + roleAssignments = @( @{ role = 'r_machine'; identities = @('psOnly') } ) + } + } ` -Probes @( [Probe]::new('powershell_caller_allowed', $GOALSTATE, 200) ) $scn += New-Scenario -Sid "$pfx-S10-malformed-json" -TargetName wireserver ` -Description 'malformed local rules JSON → agent must fail-closed (all 403)' ` -Rules '{ this is not valid json' -Raw ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) # ------------------------------------------------------------------ # S11..S19 — mirror of IMDS adversarial-admin-input tests @@ -624,75 +630,76 @@ function Build-WireServerScenarios { $scn += New-Scenario -Sid "$pfx-S11-utf8-bom" -TargetName wireserver ` -Description 'rules file prefixed with a UTF-8 BOM must still parse (deny → 403)' ` - -Rules @{ defaultAccess='deny'; mode='enforce'; id='pentest-ws-s11'; rules=@{} } ` + -Rules @{ defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s11'; rules = @{} } ` -Bom ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) $scn += New-Scenario -Sid "$pfx-S12-duplicate-keys" -TargetName wireserver ` -Description 'duplicate defaultAccess key (allow,deny) must resolve last-wins → 403' ` -Rules '{"defaultAccess":"allow","defaultAccess":"deny","id":"pentest-ws-s12","rules":{}}' -Raw ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) $ws17Identity = $Identity.Clone(); $ws17Identity.name = 'LocalFileRules_idCurrentUser' $scn += New-Scenario -Sid "$pfx-S17-name-collision-shielded" -TargetName wireserver ` -Description 'local rule names already containing LocalFileRules_ prefix still bind correctly' ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s17' - rules=@{ - privileges = @( @{ name='LocalFileRules_p_machine'; path='/machine/' } ) - roles = @( @{ name='LocalFileRules_r_machine'; privileges=@('LocalFileRules_p_machine') } ) - identities = @( $ws17Identity ) - roleAssignments = @( @{ role='LocalFileRules_r_machine'; identities=@($ws17Identity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s17' + rules = @{ + privileges = @( @{ name = 'LocalFileRules_p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'LocalFileRules_r_machine'; privileges = @('LocalFileRules_p_machine') } ) + identities = @( $ws17Identity ) + roleAssignments = @( @{ role = 'LocalFileRules_r_machine'; identities = @($ws17Identity.name) } ) + } + } ` -Probes @( [Probe]::new('goalstate_allowed', $GOALSTATE, 200) ) $scn += New-Scenario -Sid "$pfx-S18-dangling-references" -TargetName wireserver ` -Description 'role references non-existent privilege → validator rejects → 403' ` -Rules @{ - defaultAccess='allow'; mode='enforce'; id='pentest-ws-s18' - rules=@{ - privileges = @( @{ name='p_real'; path='/machine/' } ) - roles = @( @{ name='r_broken'; privileges=@('p_ghost') } ) - identities = @( $Identity ) - roleAssignments = @( @{ role='r_broken'; identities=@($Identity.name) } ) - } - } ` + defaultAccess = 'allow'; mode = 'enforce'; id = 'pentest-ws-s18' + rules = @{ + privileges = @( @{ name = 'p_real'; path = '/machine/' } ) + roles = @( @{ name = 'r_broken'; privileges = @('p_ghost') } ) + identities = @( $Identity ) + roleAssignments = @( @{ role = 'r_broken'; identities = @($Identity.name) } ) + } + } ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) # S19: see IMDS-S19. Cyrillic homoglyph in one of the current user's # characters; matcher must compare bytes → must NOT match → 403. Skip # when the current user has no character with a Cyrillic look-alike. if ($script:HomoglyphInfo) { $wsHomoIdentity = $Identity.Clone() - $wsHomoIdentity.name = 'homoUser' + $wsHomoIdentity.name = 'homoUser' $wsHomoIdentity.userName = $script:HomoglyphInfo.UserName $scn += New-Scenario -Sid "$pfx-S19-identity-homoglyph" -TargetName wireserver ` -Description ("Cyrillic homoglyph in userName ('{0}' vs '{1}') must NOT match → 403" -f ` $Identity.userName, $script:HomoglyphInfo.UserName) ` -Rules @{ - defaultAccess='deny'; mode='enforce'; id='pentest-ws-s19' - rules=@{ - privileges = @( @{ name='p_machine'; path='/machine/' } ) - roles = @( @{ name='r_machine'; privileges=@('p_machine') } ) - identities = @( $wsHomoIdentity ) - roleAssignments = @( @{ role='r_machine'; identities=@($wsHomoIdentity.name) } ) - } - } ` + defaultAccess = 'deny'; mode = 'enforce'; id = 'pentest-ws-s19' + rules = @{ + privileges = @( @{ name = 'p_machine'; path = '/machine/' } ) + roles = @( @{ name = 'r_machine'; privileges = @('p_machine') } ) + identities = @( $wsHomoIdentity ) + roleAssignments = @( @{ role = 'r_machine'; identities = @($wsHomoIdentity.name) } ) + } + } ` -Probes @( - [Probe]::new('goalstate_denied', $GOALSTATE, 403), - [Probe]::new('versions_denied', $VERSIONS, 403) - ) - } else { + [Probe]::new('goalstate_denied', $GOALSTATE, 403), + [Probe]::new('versions_denied', $VERSIONS, 403) + ) + } + else { Write-Finding "$pfx-S19-identity-homoglyph" INFO ("skipped: '{0}' has no character with a Cyrillic visual look-alike in our table" -f $Identity.userName) } @@ -715,10 +722,12 @@ function Send-Probe { $code = [int]$resp.StatusCode $resp.Close() return $code - } catch [System.Net.WebException] { + } + catch [System.Net.WebException] { if ($_.Exception.Response) { return [int]$_.Exception.Response.StatusCode } return -1 - } catch { return -1 } + } + catch { return -1 } } # --- file management -------------------------------------------------------- @@ -727,7 +736,7 @@ function Backup-RulesDir { $bak = @{} foreach ($p in @($ImdsRulesFile, $WsRulesFile)) { if (Test-Path $p) { - $b = "$p.pentest-bak.$([int][double]::Parse((Get-Date -UFormat %s)))" + $b = "$p.pentest-bak.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" Copy-Item -Path $p -Destination $b -Force $bak[$p] = $b } @@ -750,7 +759,8 @@ function Write-Rules { $tmp = "$Path.tmp" if ($Raw) { $text = [string]$Doc - } else { + } + else { $text = $Doc | ConvertTo-Json -Depth 10 } if ($Bom) { @@ -760,7 +770,8 @@ function Write-Rules { # be deterministic across both. $bytes = [byte[]] @(0xEF, 0xBB, 0xBF) + [System.Text.Encoding]::UTF8.GetBytes($text) [System.IO.File]::WriteAllBytes($tmp, $bytes) - } else { + } + else { Set-Content -Path $tmp -Value $text -NoNewline -Encoding UTF8 } Move-Item -Path $tmp -Destination $Path -Force @@ -779,12 +790,13 @@ function Invoke-CurlExe { $curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source if (-not $curl) { return -1 } $hostHeader = if ($TargetName -eq 'imds') { $ImdsIp } else { $WireServerIp } - $hdr = if ($TargetName -eq 'imds') { 'Metadata: true' } else { 'x-ms-version: 2012-11-30' } + $hdr = if ($TargetName -eq 'imds') { 'Metadata: true' } else { 'x-ms-version: 2012-11-30' } $url = "http://${hostHeader}${Path}" try { $out = & $curl -sS -o NUL -w '%{http_code}' --max-time 8 -H $hdr $url 2>$null return [int]$out - } catch { return -1 } + } + catch { return -1 } } function Invoke-Scenario { @@ -801,7 +813,7 @@ function Invoke-Scenario { $ok = if ($p.Expected -is [array]) { $p.Expected -contains $actual } else { $actual -eq $p.Expected } $expectedStr = if ($p.Expected -is [array]) { '[' + ($p.Expected -join ',') + ']' } else { "$($p.Expected)" } $shortPath = if ($p.Path.Length -gt 80) { $p.Path.Substring(0, 80) } else { $p.Path } - Write-Finding "$($Scenario.Sid)/$($p.Name)" ($(if ($ok) {'PASS'} else {'FAIL'})) ` + Write-Finding "$($Scenario.Sid)/$($p.Name)" ($(if ($ok) { 'PASS' } else { 'FAIL' })) ` "expected=$expectedStr actual=$actual path=$shortPath" if ($ok) { $passes++ } else { $fails++ } } @@ -812,7 +824,7 @@ function Invoke-Scenario { $defaultPath = if ($Scenario.TargetName -eq 'imds') { '/metadata/instance?api-version=2021-02-01' } else { '/machine/?comp=goalstate' } $actual = Invoke-CurlExe $Scenario.TargetName $defaultPath $ok = ($actual -eq 403) - Write-Finding "$($Scenario.Sid)/curl_caller_denied" ($(if ($ok) {'PASS'} else {'FAIL'})) ` + Write-Finding "$($Scenario.Sid)/curl_caller_denied" ($(if ($ok) { 'PASS' } else { 'FAIL' })) ` "expected=403 actual=$actual (curl.exe invocation — different exePath)" if ($ok) { $passes++ } else { $fails++ } } @@ -822,7 +834,7 @@ function Invoke-Scenario { function Build-AllScenarios { $all = @() - if ($Target -in @('imds', 'both')) { $all += Build-ImdsScenarios -Identity $Identity } + if ($Target -in @('imds', 'both')) { $all += Build-ImdsScenarios -Identity $Identity } if ($Target -in @('wireserver', 'both')) { $all += Build-WireServerScenarios -Identity $Identity } if ($Scenarios) { $all = $all | Where-Object { $Scenarios -contains $_.Sid } } return $all @@ -838,12 +850,14 @@ try { try { $r = Invoke-Scenario $s $totalP += $r[0]; $totalF += $r[1] - } catch { + } + catch { Write-Finding $s.Sid FAIL "scenario crashed: $_" $totalF++ } } -} finally { +} +finally { Restore-RulesDir -Backups $backups } From 6775969c82d847cd378a9a67d5dbf9b4594e7aee Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Thu, 28 May 2026 13:35:38 -0700 Subject: [PATCH 29/37] fix --- pentest/windows/Phase4b-LocalRules.ps1 | 28 +++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/pentest/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index bd4606c3..ea101470 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -145,18 +145,22 @@ Write-Finding CFG INFO "targets: $Target" # from their Latin counterparts — that is exactly the confusable-character # attack class the test is meant to exercise. Keep this list conservative; # adding non-confusable pairs would dilute the meaning of the test. -$script:HomoglyphMap = @{ - 'a' = [char]0x0430; 'A' = [char]0x0410 - 'c' = [char]0x0441; 'C' = [char]0x0421 - 'e' = [char]0x0435; 'E' = [char]0x0415 - 'o' = [char]0x043E; 'O' = [char]0x041E - 'p' = [char]0x0440; 'P' = [char]0x0420 - 'x' = [char]0x0445; 'X' = [char]0x0425 - 'y' = [char]0x0443; 'Y' = [char]0x0423 - 'B' = [char]0x0412; 'H' = [char]0x041D - 'K' = [char]0x041A; 'M' = [char]0x041C - 'T' = [char]0x0422 -} +# +# Hashtable LITERALS in PowerShell are case-insensitive, so 'a' and 'A' +# collide at parse time. Build the table imperatively with an Ordinal +# (case-sensitive) comparer instead. +$script:HomoglyphMap = [System.Collections.Hashtable]::new( + [System.StringComparer]::Ordinal) +$script:HomoglyphMap['a'] = [char]0x0430; $script:HomoglyphMap['A'] = [char]0x0410 +$script:HomoglyphMap['c'] = [char]0x0441; $script:HomoglyphMap['C'] = [char]0x0421 +$script:HomoglyphMap['e'] = [char]0x0435; $script:HomoglyphMap['E'] = [char]0x0415 +$script:HomoglyphMap['o'] = [char]0x043E; $script:HomoglyphMap['O'] = [char]0x041E +$script:HomoglyphMap['p'] = [char]0x0440; $script:HomoglyphMap['P'] = [char]0x0420 +$script:HomoglyphMap['x'] = [char]0x0445; $script:HomoglyphMap['X'] = [char]0x0425 +$script:HomoglyphMap['y'] = [char]0x0443; $script:HomoglyphMap['Y'] = [char]0x0423 +$script:HomoglyphMap['B'] = [char]0x0412; $script:HomoglyphMap['H'] = [char]0x041D +$script:HomoglyphMap['K'] = [char]0x041A; $script:HomoglyphMap['M'] = [char]0x041C +$script:HomoglyphMap['T'] = [char]0x0422 # Returns a copy of $UserName with the first character that has a Cyrillic # look-alike replaced by its homoglyph (plus the position swapped, for the From c01da34204a925c83a41c2b79bcc997928c2258e Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 2 Jun 2026 18:28:55 +0000 Subject: [PATCH 30/37] update linux scripts --- pentest/linux/DESIGN.md | 6 +++--- pentest/linux/phase4b_local_rules/run.py | 2 +- pentest/linux/run_all.sh | 4 ++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index f9fe506c..85d6dcb5 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -13,10 +13,10 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM - TCP listener `127.0.0.1:3080` (the proxy itself). - eBPF redirect program attached at the cgroup root. - The HMAC signing path (`x-ms-azure-signature`) injected by GPA on authorized requests. -- AuthZ engine in [proxy_authorizer.rs](../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../proxy_agent/src/proxy/authorization_rules.rs). -- Key-keeper / latch state in `/var/lib/azure-proxy-agent/keys` and config in [GuestProxyAgent.linux.json](../proxy_agent/config/GuestProxyAgent.linux.json). +- AuthZ engine in [proxy_authorizer.rs](../../proxy_agent/src/proxy/proxy_authorizer.rs) and [authorization_rules.rs](../../proxy_agent/src/proxy/authorization_rules.rs). +- Key-keeper / latch state in `/var/lib/azure-proxy-agent/keys` and config in [GuestProxyAgent.linux.json](../../proxy_agent/config/GuestProxyAgent.linux.json). - Provisioning, status, and log surfaces under `/var/log/azure-proxy-agent`. -- The handler/service binaries from the extension ([proxy_agent_extension/](../proxy_agent_extension/), [proxy_agent_setup/](../proxy_agent_setup/)). +- The handler/service binaries from the extension ([proxy_agent_extension/](../../proxy_agent_extension/), [proxy_agent_setup/](../../proxy_agent_setup/)). ### Out of scope - WireServer/IMDS server-side responses. diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 94308c53..7c111ce8 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -3,7 +3,7 @@ Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. PRE-REQ: - The HostGAPlugin / fabric must already deliver a ruleId with + The WS / fabric must already deliver a ruleId with useLocalFileRules=true (so the agent merges the files in /var/lib/azure-proxy-agent/rules/ instead of ignoring them). The script verifies this at startup by reading status.json and the latest diff --git a/pentest/linux/run_all.sh b/pentest/linux/run_all.sh index 40e643b3..6886f8e6 100755 --- a/pentest/linux/run_all.sh +++ b/pentest/linux/run_all.sh @@ -18,7 +18,11 @@ # sudo ./pentest/linux/run_all.sh --no-report # skip the HTML render # ./pentest/linux/run_all.sh --help +# Note: -e (exit on any error) is intentionally not set here, +# so individual command failures won't abort the script — each phase runs even if a previous one fails. +# That fits this harness, since you want all pen-test phases to execute and report findings independently. set -uo pipefail + HERE="$(cd "$(dirname "$0")" && pwd)" RESULTS_DIR="$HERE/results" FINDINGS="$RESULTS_DIR/findings.tsv" From b2aee16099386bcb450d62bf34b83cf6b0bdfd82 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 2 Jun 2026 18:34:41 +0000 Subject: [PATCH 31/37] update the fix --- proxy_agent/src/proxy_agent_status.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/proxy_agent/src/proxy_agent_status.rs b/proxy_agent/src/proxy_agent_status.rs index 20a43834..5bed94db 100644 --- a/proxy_agent/src/proxy_agent_status.rs +++ b/proxy_agent/src/proxy_agent_status.rs @@ -292,16 +292,6 @@ impl ProxyAgentStatusTask { async fn write_aggregate_status_to_file(&self, status: GuestProxyAgentAggregateStatus) { let full_file_path = self.status_dir.join("status.json"); if let Err(e) = misc_helpers::json_write_to_file_async(&status, &full_file_path).await { - #[cfg(not(windows))] - { - proxy_agent_shared::linux::set_file_permissions(&full_file_path, 0o640) - .unwrap_or_else(|e| { - logger::write_error(format!( - "Failed to set status.json file permission to 640 with error: {e}" - )); - }); - } - self.update_agent_status_message(format!( "Error writing aggregate status to status file: {e}" )) From 58451b28ed9efde923954d40c591c9af8b3d92e1 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 2 Jun 2026 19:06:26 +0000 Subject: [PATCH 32/37] fix spelling. --- .github/actions/spelling/expect.txt | 128 +++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 2dc59a05..70a64f58 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,6 +1,8 @@ aab AAFFBB aarch +abcdefghijkmnopqrstuvwxyz +ABCDEFGHJKLMNPQRSTUVWXYZ abe addrpair advapi @@ -12,11 +14,15 @@ autocrlf aya AZUREPUBLICCLOUD azuretools +azureuser backcompat +backdoored +bak bierner binpath binskim bitflag +boofuzz bpf bpftool btf @@ -28,10 +34,16 @@ buildroot buildshell byos cacheline +callouts cbl ccbdee ccbf +cgroups +cgroupv +cgtop +chokepoint cicd +Cim cimv cla closehandle @@ -40,21 +52,27 @@ codeofconduct codeql collectguestlogs commandline +COMPUTERNAME comspec consoleloggerparameters +coredumpctl covrec CPlat cplusplus cpptools crpteste CRYPTOAPI +CSPRNG csum customout customoutput cvd +CVEs czf DABC +DACL daddr +dafbe datacenter davidanson DDCE @@ -64,32 +82,48 @@ DEBHELPER debian Debpf defattr +dentity deploymentid +desync +desyncs +detbox +detlbl +dettitle +detval devcontainer +diffed +diffs distros dllmain +dministrator dnf dockerenv dodce dodce dotnet doxygen +DPAPI dport dtolnay +dumps'd Dvm EAccess +eaeef EAF ebpf ebpfapi EBPFCORE +eef egor ele ent entriesread +esac EStorage etest etestoutputs etestsharedstorage +etl EToken etw EUID @@ -98,9 +132,20 @@ evt exampledatadiskname exampleosdiskname examplevmname +exepath +exfil +EXTCONFIG +extconfig exthandlers +fafbfc +Fapi +Fbar fde +fea +ffcecb +ffebe fff +ffff FFFF FFFFFFFF fffi @@ -115,6 +160,7 @@ fsprogs fstorage fstype ftoken +fuzzer fwlink Fzpeng gaplugin @@ -122,16 +168,23 @@ getifaddrs goalstate gpa gpalinuxdev +gpapen gpawindev guestproxyagentmsis guiddef handleapi +hdr hklm hlist HMAC +HMACs +homoglyph hostga +hostingenvironmentconfig httpwg +httpx Iaa +ICredentials idstepsrun IEnumerable ieq @@ -145,6 +198,7 @@ immediateruncommandservice intellectualproperty Intelli intellij +INVALIDMETHOD INVM Ioctl iusr @@ -152,6 +206,7 @@ jetbrains jqlang JOBOBJECT jobsjob +journalctl joutvhu JScript keyonly @@ -160,6 +215,7 @@ kotlin kprobe ktime kusto +lbl lgrui libbpf libbpfcc @@ -174,6 +230,7 @@ lsa ltsc luid macikgo +maxdepth mcr MEMORYSTATUSEX metabuild @@ -181,58 +238,95 @@ MFC microsoftcblmariner microsoftlosangeles microsoftwindowsdesktop +misconfig +MMdd mmm mnt msasn +msc msp msrc multilib +ncurl netapi netcoreapp netebpfext nethook +netns +netsh Newtonsoft +nftables nic nifs nmake +nmap nocapture NOCONFIRMATION +nodet NOERRORUI NONINFRINGEMENT +nosuchuser +notcontains notjson +notlike norestart +npidof +NSG +nsudo ntdll NTSTATUS -onscreen +ntimeout +OICI +onscreen onebranch openprocess oneshot opencode opensource +osinfo +PAI parseable +passwordless peekable +pcap +PCAP +pcapng +pcaps +PEERCRED +pentest +PENTEST PERCPU pgpkey pgrep pidof +PIDs +pipefail pkgversion +pktmon pls portaddr portpair postinst pprev prandom +prctl predef +prefixer +Prefixer prefmaxlen preprovisioned +Prereqs printk PROCESSINFOCLASS +procdump processthreadsapi proxyagent proxyagentextensionvalidation proxyagentvalidation +pscustomobject ptrace pwstr +radamsa rcv RDFE redhat @@ -247,6 +341,7 @@ rgr rgs rhel RINGBUF +Roboto rockylinux rolename rootdir @@ -254,6 +349,8 @@ rpmbuild RPMS rstr rul +ruleset +runas runthis Runtimes rustfmt @@ -261,14 +358,20 @@ rustup saddr sandboxing sas +scapy schtasks scm +SDDL secauthz +Segoe serice SETFCAP SETPCAP +sev shd +shellcheck sids +SIEM sigid SIO skc @@ -276,6 +379,7 @@ sku sles sln smp +sourced spellright splitn SRPMS @@ -285,6 +389,7 @@ stackoverflow stdbool stdint stdoutput +stduser subsecond substatus Substatuses @@ -292,12 +397,15 @@ SUIDSGID suse Swatinem SWbem +SYD sysinfoapi sysinit SYSLIB SYSTEMDRIVE taiki TASKKILL +tcpdump +TCPDUMP telemetrydata tensin testcasesetting @@ -311,11 +419,21 @@ timedout timeup tlsv tmpfs +tnc +tnl +tnlp +tnp +TOCTOU tokio topdir totalentries transitioning +trustlevel trustyuser +tshark +tsv +Tsv +TSV UBR UBRSTRING udev @@ -323,6 +441,7 @@ uers uninstalls unistd unmark +unparseable Unregistering unregisters unspec @@ -355,6 +474,7 @@ wdksetup Werror westus wevtapi +wfp WFP winapi winbase @@ -369,7 +489,9 @@ wireserverandimds WMI workarounds WORKINGSET +workdir WORKDIR +wrk wrongvalue WScript wsf @@ -379,11 +501,15 @@ wstr wsum wyy xamarin +xbb +xbf +xef xcopy XDP xfsprogs xsi xxxx +XXXXXX xxxxxxxx xxxxxxxxxxx zipsas From 3bf0cd0165d0fb9572fa8893c9c7625e8efe9885 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Tue, 2 Jun 2026 19:10:43 +0000 Subject: [PATCH 33/37] fix --- .github/actions/spelling/expect.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 70a64f58..735ff931 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -211,6 +211,7 @@ joutvhu JScript keyonly kinvolk +kmemleak kotlin kprobe ktime @@ -271,13 +272,14 @@ notjson notlike norestart npidof +nprintf NSG nsudo ntdll NTSTATUS ntimeout OICI -onscreen +onscreen onebranch openprocess oneshot @@ -398,6 +400,7 @@ suse Swatinem SWbem SYD +SYG sysinfoapi sysinit SYSLIB @@ -513,4 +516,5 @@ XXXXXX xxxxxxxx xxxxxxxxxxx zipsas +zureuser zypper \ No newline at end of file From f9a441878b35ced2d593fd00235eb51c04adc73f Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 2 Jun 2026 13:47:06 -0700 Subject: [PATCH 34/37] Fix spell check: add hmacs and nics to expect.txt --- .github/actions/spelling/expect.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 735ff931..a317527b 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -177,6 +177,7 @@ hdr hklm hlist HMAC +hmacs HMACs homoglyph hostga @@ -258,6 +259,7 @@ netsh Newtonsoft nftables nic +nics nifs nmake nmap From b9c05f3e13f00bfa317588efb5cba3677e51601b Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 2 Jun 2026 13:54:37 -0700 Subject: [PATCH 35/37] Fix spell check: un-shadow hmacs/nics by removing redundant singular entries --- .github/actions/spelling/expect.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index a317527b..acf33ca8 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -176,7 +176,6 @@ handleapi hdr hklm hlist -HMAC hmacs HMACs homoglyph @@ -258,7 +257,6 @@ netns netsh Newtonsoft nftables -nic nics nifs nmake From 301c0771d135eec4ca14548eeaa352bdf66fe64a Mon Sep 17 00:00:00 2001 From: "Zhidong Peng (HE/HIM)" Date: Tue, 2 Jun 2026 14:02:17 -0700 Subject: [PATCH 36/37] Fix spell check: use only mixed-case HMACs and NICs entries --- .github/actions/spelling/expect.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index acf33ca8..59a0bbe3 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -176,7 +176,6 @@ handleapi hdr hklm hlist -hmacs HMACs homoglyph hostga @@ -257,7 +256,7 @@ netns netsh Newtonsoft nftables -nics +NICs nifs nmake nmap From 4ce0b8deac54f1dee4cce9a2c4255797dbadfa8c Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Fri, 5 Jun 2026 21:09:19 +0000 Subject: [PATCH 37/37] resolve comments --- pentest/linux/phase3_authn_authz/run.sh | 6 +++++- pentest/linux/phase4_rules_fuzz/url_diff.py | 2 +- .../__pycache__/run.cpython-312.pyc | Bin 30004 -> 0 bytes 3 files changed, 6 insertions(+), 2 deletions(-) delete mode 100644 pentest/linux/phase4b_local_rules/__pycache__/run.cpython-312.pyc diff --git a/pentest/linux/phase3_authn_authz/run.sh b/pentest/linux/phase3_authn_authz/run.sh index d4d6e13e..d975a5fd 100755 --- a/pentest/linux/phase3_authn_authz/run.sh +++ b/pentest/linux/phase3_authn_authz/run.sh @@ -125,7 +125,11 @@ fi # PASS. Different → potential SSRF-filter bypass at the network layer → FAIL. for alt in "http://0xa9fea9fe/metadata/instance?api-version=2021-02-01" \ "http://2852039166/metadata/instance?api-version=2021-02-01"; do - alt_body=/tmp/.gpa_c7_$$.body + # mktemp avoids a symlink race: this harness runs as root, and a predictable + # /tmp/.gpa_c7_$$.body path would let an unprivileged attacker pre-create a + # symlink that curl -o would follow and overwrite as root (the exact class + # of bug DESIGN.md scenarios E2/F3 exercise in GPA itself). + alt_body=$(mktemp -t gpa_c7_body.XXXXXX) alt_code=$(curl -sS -o "$alt_body" -w '%{http_code}' --max-time 6 -H "$imds_hdr" "$alt" || echo 000) alt_sha=$(sha256sum "$alt_body" 2>/dev/null | awk '{print $1}') rm -f "$alt_body" diff --git a/pentest/linux/phase4_rules_fuzz/url_diff.py b/pentest/linux/phase4_rules_fuzz/url_diff.py index 07f559af..5ad3c92a 100755 --- a/pentest/linux/phase4_rules_fuzz/url_diff.py +++ b/pentest/linux/phase4_rules_fuzz/url_diff.py @@ -71,7 +71,7 @@ def main() -> int: print(f" {name:<18} status={status:<4} {diff}") with open(args.out, "a", encoding="utf-8") as fh: - ts = datetime.datetime.utcnow().isoformat(timespec="seconds") + "Z" + ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") for name, status, diff, path, head in rows: fh.write(f"{ts}\t{name}\t{status}\t{diff}\t{path}\t{head}\n") diff --git a/pentest/linux/phase4b_local_rules/__pycache__/run.cpython-312.pyc b/pentest/linux/phase4b_local_rules/__pycache__/run.cpython-312.pyc deleted file mode 100644 index f150328e20d74d2446df27f8816276b025704c3e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30004 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%0DHR&mTf!>`Z z=Xr9z3I|9Ej`!k6(g<5o4M2Z|g6+^d$i^;y-b^M`5GH4|AAQ2{U`$A1CYFNnTRo9c zibPC>sn6+245KPvY5te6skZN65njGbAJHlb8%g9lm}zqa1z3)t_xiW2YwUfV(&mdS)Nx7B+)>UaQ1g}^|f8-Ip>2_dbyT9 zqN#&iPYx;e?B`O{aj=sf`^gz3CqNDx8yQTsM2{=v{7Z8F6*;Vy={+1IzHFE_e08ZBOkImUW$}f(U z9A3+BoU_F9%NDP$<{y}I#PiEzyK7hTYv;0=w`n!MY0mY(Qq-=s{BDX+NUsX!ERXUl z=WO%{XD_8uIA_7AXK?)7An=7ar^6RMHsW!EJ!Wyc#5qgMR<_3N1{vA;3(bEt@gS>m zHLG$htLj0PXEn<+C&aVz?!0pAmBpUbtbNFfb3EX3SGnB9%H@I>m%Gk+<5{JPeXCgq z=B)6=${Sa+8t0sk%6nG1(mB(d0t~L}v6<&D!%DZnw>-`>@XtNAXYo58?=|q%kL|hq zdH!*=gEw#N;*Q~GQgZpcc%G=<>yN1S_O8bizHtaPzkg;Z;GB@0#sK@+Uky5eL_)?h05 z$m&|KEE*T4=EF-J-#Puw(;r%E^v@S^?>KHbXf!buJW*(6=&yDccQtc=)okx>H2&D& N?yfcdxYh*!{|g}-=%D}r