From 7caf55378dd7790165f95a6c4b7134d9086191f5 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 8 Oct 2026 16:07:24 +0000 Subject: [PATCH 1/8] capture Linux executable identity via eBPF --- linux-ebpf/ebpf_cgroup.c | 52 +++++-- linux-ebpf/socket.h | 51 +++++++ pentest/linux/DESIGN.md | 3 +- pentest/linux/README.md | 2 +- pentest/linux/phase4b_local_rules/run.py | 42 ++++++ pentest/linux/test_catalog.py | 8 ++ proxy_agent/src/key_keeper/key.rs | 128 ++++++++++++++--- proxy_agent/src/proxy.rs | 12 ++ proxy_agent/src/proxy/authorization_rules.rs | 25 +++- proxy_agent/src/proxy/proxy_authorizer.rs | 15 ++ proxy_agent/src/redirector.rs | 9 ++ proxy_agent/src/redirector/linux.rs | 140 +++++++++++++------ proxy_agent/src/redirector/shared_ebpf.rs | 10 +- 13 files changed, 420 insertions(+), 77 deletions(-) diff --git a/linux-ebpf/ebpf_cgroup.c b/linux-ebpf/ebpf_cgroup.c index b49036cf..d40bb3ed 100644 --- a/linux-ebpf/ebpf_cgroup.c +++ b/linux-ebpf/ebpf_cgroup.c @@ -42,8 +42,8 @@ struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __type(key, struct gpa_audit_key); // source port and protocol - __type(value, struct gpa_audit_event); // audit event (canonical struct) - __uint(max_entries, 200); // LRU evicts oldest on overflow + __type(value, struct gpa_linux_audit_event); // audit event + executable identity + __uint(max_entries, 200); // LRU evicts oldest on overflow } audit_map SEC(".maps"); struct @@ -56,7 +56,7 @@ struct { __uint(type, BPF_MAP_TYPE_LRU_HASH); __type(key, __u64); // pid-tgid or socket cookie - __type(value, struct gpa_sock_addr_local_entry); + __type(value, struct gpa_linux_sock_addr_local_entry); __uint(max_entries, 200); } local_map SEC(".maps"); @@ -83,6 +83,41 @@ local_ip_bind_monitor_only_enabled(void) return entry != NULL && entry->enabled != 0; } +static __always_inline void +capture_executable_identity(struct gpa_linux_executable_identity *identity) +{ + struct task_struct *task = (struct task_struct *)bpf_get_current_task(); + struct mm_struct *mm = BPF_CORE_READ(task, mm); + if (mm == NULL) + { + return; + } + + struct file *exe_file = BPF_CORE_READ(mm, exe_file); + if (exe_file == NULL) + { + return; + } + + struct inode *inode = BPF_CORE_READ(exe_file, f_inode); + if (inode == NULL) + { + return; + } + + struct super_block *super_block = BPF_CORE_READ(inode, i_sb); + if (super_block == NULL) + { + return; + } + + __u64 inode_number = BPF_CORE_READ(inode, i_ino); + identity->device = BPF_CORE_READ(super_block, s_dev); + identity->inode_low = (__u32)inode_number; + identity->inode_high = (__u32)(inode_number >> 32); + identity->valid = 1; +} + /* update audit map entry if not skip redirecting. return 0 if the entry is updated, otherwise @@ -124,8 +159,9 @@ update_local_map_entry(struct bpf_sock_addr *ctx, __be32 destination_ipv4, __u32 return 0; } - struct gpa_sock_addr_local_entry entry = {0}; - entry.audit = audit; + struct gpa_linux_sock_addr_local_entry entry = {0}; + entry.audit.audit = audit; + capture_executable_identity(&entry.audit.executable); entry.protocol = ctx->protocol; __u64 ret = bpf_map_update_elem(&local_map, &pid_tip, &entry, 0); if (ret != 0) @@ -252,13 +288,13 @@ int connect6(struct bpf_sock_addr *ctx) } static __always_inline int -update_audit_map_entry_sk(__u32 local_port, struct gpa_sock_addr_local_entry *local_entry) +update_audit_map_entry_sk(__u32 local_port, struct gpa_linux_sock_addr_local_entry *local_entry) { struct gpa_audit_key key = {0}; key.protocol = local_entry->protocol; key.source_port = local_port; - struct gpa_audit_event entry = local_entry->audit; + struct gpa_linux_audit_event entry = local_entry->audit; __u64 ret = bpf_map_update_elem(&audit_map, &key, &entry, 0); if (ret != 0) @@ -294,7 +330,7 @@ trace_tcp_connect(struct sock *sk) } // Find the entry in the local map. - struct gpa_sock_addr_local_entry *local_entry = bpf_map_lookup_elem(&local_map, &pid_tgid); + struct gpa_linux_sock_addr_local_entry *local_entry = bpf_map_lookup_elem(&local_map, &pid_tgid); if (local_entry != NULL) { update_audit_map_entry_sk(skc_num, local_entry); diff --git a/linux-ebpf/socket.h b/linux-ebpf/socket.h index 527e450d..a119e0df 100644 --- a/linux-ebpf/socket.h +++ b/linux-ebpf/socket.h @@ -25,6 +25,36 @@ typedef struct gpa_audit_key sock_addr_audit_key; typedef struct gpa_audit_event sock_addr_audit_entry; typedef struct gpa_sock_addr_local_entry sock_addr_local_entry; +// Linux-only executable identity captured from the connecting task's mm->exe_file. +// Kept out of the shared gpa_audit_event so the Windows eBPF ABI is unchanged. +// Size: 16 bytes - matches Rust LinuxExecutableIdentity -> [u32; 4] +struct gpa_linux_executable_identity +{ + __u32 device; // super_block s_dev (kernel dev_t encoding) + __u32 inode_low; + __u32 inode_high; + __u32 valid; // 1 when the identity was read successfully +}; + +// Linux audit_map value: the shared audit event followed by the executable identity. +// Size: 44 bytes - matches Rust LinuxAuditMapValue -> [u32; 11] +struct gpa_linux_audit_event +{ + struct gpa_audit_event audit; + struct gpa_linux_executable_identity executable; +}; + +// Linux local_map value. Size: 48 bytes (12 x u32) +struct gpa_linux_sock_addr_local_entry +{ + __u32 protocol; + struct gpa_linux_audit_event audit; +}; + +_Static_assert(sizeof(struct gpa_linux_executable_identity) == 16, "executable identity must be 16 bytes"); +_Static_assert(sizeof(struct gpa_linux_audit_event) == 44, "linux audit event must be 44 bytes ([u32; 11])"); +_Static_assert(sizeof(struct gpa_linux_sock_addr_local_entry) == 48, "linux local entry must be 48 bytes"); + // IPv4 socket tuple (used for connection tracking) typedef struct _bpf_sock_tuple_ipv4 { @@ -76,4 +106,25 @@ struct sock { struct sock_common __sk_common; }; +struct super_block { + __u32 s_dev; +}; + +struct inode { + unsigned long i_ino; + struct super_block *i_sb; +}; + +struct file { + struct inode *f_inode; +}; + +struct mm_struct { + struct file *exe_file; +}; + +struct task_struct { + struct mm_struct *mm; +}; + #pragma clang attribute pop \ No newline at end of file diff --git a/pentest/linux/DESIGN.md b/pentest/linux/DESIGN.md index 85d6dcb5..42b2b5e7 100644 --- a/pentest/linux/DESIGN.md +++ b/pentest/linux/DESIGN.md @@ -58,7 +58,7 @@ Target: Guest Proxy Agent (GPA) running on this Azure VM |----|------|----------|----------------------------| | 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. | +| C3 | In phase 4b S9, enter a private user+mount namespace, bind-mount the attacker's `/proc//exe` over allow-listed `/usr/bin/curl`, execute it through that path, and send the request using Bash builtins. | Genuine curl is allowed; the spoof is denied. Authorization compares the executable device/inode captured by eBPF at connect time with `/usr/bin/curl` resolved in GPA's trusted mount namespace. `/proc//exe` remains diagnostic only. | 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), and avoids post-connect `execve` and PID-reuse attribution races. | | 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. 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. | @@ -244,4 +244,3 @@ cargo install --locked httpx-cli # optional ``` Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts). - diff --git a/pentest/linux/README.md b/pentest/linux/README.md index c7457dc3..ca70cfa8 100644 --- a/pentest/linux/README.md +++ b/pentest/linux/README.md @@ -15,7 +15,7 @@ lives in [DESIGN.md](DESIGN.md). | [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/`. | +| [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). S9 also runs C3's bind-mount executable spoof. 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`. | diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 6131c309..3e45b00b 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -1158,6 +1158,48 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: f"expected=200 actual={actual} (curl invocation)") passes += int(ok); fails += int(not ok) + # C3: execute Bash through a private bind mount at the allow-listed + # /usr/bin/curl path. The textual /proc//exe path can look trusted, + # but the kernel-captured executable device/inode must remain Bash's. + request_headers = ( + "Metadata: true\\r\\n" if sc.target.name == "imds" + else "x-ms-version: 2012-11-30\\r\\n" + ) + request_path = ( + "/metadata/instance?api-version=2021-02-01" + if sc.target.name == "imds" + else "/machine/?comp=goalstate" + ) + inner = ( + "mount --make-rprivate / && " + "mount --bind /proc/$$/exe /usr/bin/curl && " + "/usr/bin/curl -c '" + f"exec 3<>/dev/tcp/{sc.target.host}/{sc.target.port}; " + f"printf \"GET {request_path} HTTP/1.1\\\\r\\\\n" + f"Host: {sc.target.host}\\\\r\\\\n{request_headers}" + "Connection: close\\\\r\\\\n\\\\r\\\\n\" >&3; " + "IFS=\" \" read -r _ status _ <&3; printf \"%s\" \"$status\"'" + ) + try: + out = subprocess.run( + ["unshare", "--user", "--map-root-user", "--mount", + "--", "bash", "-c", inner], + capture_output=True, text=True, timeout=15) + if out.returncode != 0: + record("C3", "INFO", + "user/mount namespace unavailable; " + f"stderr={out.stderr.strip()[:160]}") + else: + actual = int(out.stdout or "0") + ok = (actual == 403) + record("C3", + "PASS" if ok else "FAIL", + f"expected=403 actual={actual}; " + "caller /proc//exe bind-mounted over allow-listed /usr/bin/curl") + passes += int(ok); fails += int(not ok) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + record("C3", "INFO", f"namespace probe unavailable: {exc}") + return passes, fails diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index cf127e29..525c7ba8 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -131,6 +131,14 @@ class TestInfo(TypedDict, total=False): "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.", }, + "C3": { + "title": "Bind-mount executable identity spoof", + "design": "An executable reached through an allow-listed pathname must match the device/inode captured by eBPF at connect time; a mount-namespace pathname alone is not authorization identity.", + "automation": "The phase-4b S9 scenario permits `/usr/bin/curl`, then enters a user+mount namespace, bind-mounts the attacker's `/proc//exe` over `/usr/bin/curl`, executes it through that path, and sends the HTTP request with Bash builtins. Genuine curl must receive 200; the spoof must receive 403.", + "repro_script": "sudo python3 pentest/linux/phase4b_local_rules/run.py --scenarios IMDS-S9-exepath-identity", + "repro_manual": "sudo unshare --user --map-root-user --mount -- bash -c 'mount --make-rprivate /; mount --bind /proc/$$/exe /usr/bin/curl; /usr/bin/curl -c \"exec 3<>/dev/tcp/169.254.169.254/80; printf \\\\\"GET /metadata/instance?api-version=2021-02-01 HTTP/1.1\\\\\\\\r\\\\\\\\nHost: 169.254.169.254\\\\\\\\r\\\\\\\\nMetadata: true\\\\\\\\r\\\\\\\\nConnection: close\\\\\\\\r\\\\\\\\n\\\\\\\\r\\\\\\\\n\\\\\" >&3; cat <&3\"'", + "fix": "Capture executable file identity atomically in the cgroup connect eBPF hook and compare it with the configured path resolved in GPA's trusted mount namespace. Never authorize from `/proc//exe` text alone; fail closed when kernel identity is unavailable.", + }, "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.", diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 965e5443..4c1b98f0 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -36,8 +36,10 @@ use hyper::Uri; use proxy_agent_shared::hyper_client; use proxy_agent_shared::logger::LoggerLevel; use serde_derive::{Deserialize, Serialize}; +use std::collections::HashMap; use std::fmt::{Display, Formatter}; -use std::{collections::HashMap, path::PathBuf}; +#[cfg(windows)] +use std::path::PathBuf; use std::{ffi::OsString, time::Duration}; const AUDIT_MODE: &str = "audit"; @@ -385,24 +387,74 @@ impl Identity { } } if let Some(ref exe_path) = self.exePath { - let process_path_buf: PathBuf = exe_path.into(); - if process_path_buf == claims.processFullPath { - logger.write( - LoggerLevel::Trace, - format!( - "Matched process full path '{}' from identity '{}'", - exe_path, self.name - ), - ); - } else { + #[cfg(not(windows))] + { + use std::os::unix::fs::MetadataExt; + + let metadata = match std::fs::metadata(exe_path) { + Ok(metadata) => metadata, + Err(err) => { + logger.write( + LoggerLevel::Warn, + format!( + "Could not resolve executable identity '{}' from identity '{}': {}", + exe_path, self.name, err + ), + ); + return false; + } + }; + if !claims.processExecutableIdentityValid { + logger.write( + LoggerLevel::Warn, + format!( + "Kernel executable identity unavailable for identity '{}'", + self.name + ), + ); + return false; + } + if metadata.dev() != claims.processExecutableDevice + || metadata.ino() != claims.processExecutableInode + { + logger.write( + LoggerLevel::Trace, + format!( + "Not matched executable identity '{}' from identity '{}'", + exe_path, self.name + ), + ); + return false; + } logger.write( LoggerLevel::Trace, format!( - "Not matched process full path '{}' from identity '{}'", + "Matched executable identity '{}' from identity '{}'", exe_path, self.name ), ); - return false; + } + #[cfg(windows)] + { + let process_path_buf: PathBuf = exe_path.into(); + if process_path_buf == claims.processFullPath { + logger.write( + LoggerLevel::Trace, + format!( + "Matched process full path '{}' from identity '{}'", + exe_path, self.name + ), + ); + } else { + logger.write( + LoggerLevel::Trace, + format!( + "Not matched process full path '{}' from identity '{}'", + exe_path, self.name + ), + ); + return false; + } } } if let Some(ref group_name) = self.groupName { @@ -910,7 +962,6 @@ mod tests { use std::os::unix::ffi::OsStringExt; #[cfg(windows)] use std::os::windows::ffi::OsStringExt; - use std::path::PathBuf; use super::Key; use super::KeyStatus; @@ -1637,7 +1688,14 @@ mod tests { #[tokio::test] async fn test_identity_is_match() { + #[cfg(not(windows))] + use std::os::unix::fs::MetadataExt; + let mut logger = ConnectionLogger::new(1, 1); + let current_exe = std::env::current_exe().unwrap(); + let current_exe_text = current_exe.to_string_lossy().to_string(); + #[cfg(not(windows))] + let current_exe_metadata = std::fs::metadata(¤t_exe).unwrap(); let mut claims = super::Claims { userName: "test".to_string(), @@ -1649,17 +1707,26 @@ mod tests { clientIp: "00.000.000".to_string(), clientPort: 0, // doesn't matter for this test runAsElevated: true, - processFullPath: PathBuf::from("test"), + processFullPath: current_exe, + #[cfg(not(windows))] + processExecutableDevice: current_exe_metadata.dev(), + #[cfg(windows)] + processExecutableDevice: 0, + #[cfg(not(windows))] + processExecutableInode: current_exe_metadata.ino(), + #[cfg(windows)] + processExecutableInode: 0, + processExecutableIdentityValid: true, }; - let identity = r#"{ + let identity: Identity = serde_json::from_value(serde_json::json!({ "name": "test", "userName": "test", "groupName": "test", - "exePath": "test", + "exePath": current_exe_text, "processName": "test" - }"#; - let identity: Identity = serde_json::from_str(identity).unwrap(); + })) + .unwrap(); assert!( identity.is_match(&mut logger, &claims), "identity should be matched" @@ -1747,15 +1814,30 @@ mod tests { !identity4.is_match(&mut logger, &claims), "identity should not be matched" ); - let identity4 = r#"{ + let identity4: Identity = serde_json::from_value(serde_json::json!({ "name": "test", - "exePath": "test" - }"#; - let identity4: Identity = serde_json::from_str(identity4).unwrap(); + "exePath": current_exe_text + })) + .unwrap(); assert!( identity4.is_match(&mut logger, &claims), "identity should be matched" ); + #[cfg(not(windows))] + { + claims.processExecutableInode ^= 1; + assert!( + !identity4.is_match(&mut logger, &claims), + "a spoofed path with a different kernel executable identity must not match" + ); + claims.processExecutableInode ^= 1; + claims.processExecutableIdentityValid = false; + assert!( + !identity4.is_match(&mut logger, &claims), + "exePath identity must fail closed when kernel identity is unavailable" + ); + claims.processExecutableIdentityValid = true; + } // test groupName let identity5 = r#"{ diff --git a/proxy_agent/src/proxy.rs b/proxy_agent/src/proxy.rs index 65a28309..999ddfb9 100644 --- a/proxy_agent/src/proxy.rs +++ b/proxy_agent/src/proxy.rs @@ -57,6 +57,12 @@ pub struct Claims { #[serde(with = "os_string_as_string")] pub processName: OsString, pub processFullPath: PathBuf, + #[serde(default)] + pub processExecutableDevice: u64, + #[serde(default)] + pub processExecutableInode: u64, + #[serde(default)] + pub processExecutableIdentityValid: bool, pub processCmdLine: String, pub runAsElevated: bool, pub clientIp: String, @@ -163,6 +169,9 @@ impl Claims { processId: 0, processName: OsString::from(EMPTY), processFullPath: PathBuf::from(EMPTY), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: EMPTY.to_string(), runAsElevated: false, clientIp: EMPTY.to_string(), @@ -185,6 +194,9 @@ impl Claims { processId: p.pid, processName: p.name, processFullPath: p.exe_full_name, + processExecutableDevice: entry.executable_device, + processExecutableInode: entry.executable_inode, + processExecutableIdentityValid: entry.executable_identity_valid, processCmdLine: p.command_line.clone(), runAsElevated: entry.is_admin == 1, clientIp: client_ip.to_string(), diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index 7eb261d4..c27a2e95 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -644,6 +644,15 @@ mod tests { let mut temp_test_path = std::env::temp_dir(); temp_test_path.push(logger_key); let mut test_logger = ConnectionLogger::new(0, 0); + let current_exe = std::env::current_exe().unwrap(); + #[cfg(not(windows))] + let (current_exe_dev, current_exe_ino) = { + use std::os::unix::fs::MetadataExt; + let metadata = std::fs::metadata(¤t_exe).unwrap(); + (metadata.dev(), metadata.ino()) + }; + #[cfg(windows)] + let (current_exe_dev, current_exe_ino) = (0u64, 0u64); // Test Enforce Mode let access_control_rules = AccessControlRules { @@ -658,7 +667,7 @@ mod tests { }]), identities: Some(vec![Identity { name: "test".to_string(), - exePath: Some("test".to_string()), + exePath: Some(current_exe.to_string_lossy().to_string()), groupName: Some("test".to_string()), processName: Some("test".to_string()), userName: Some("test".to_string()), @@ -687,7 +696,10 @@ mod tests { userName: "test".to_string(), userGroups: vec!["test".to_string()], processId: 0, - processFullPath: PathBuf::from("test"), + processFullPath: current_exe.clone(), + processExecutableDevice: current_exe_dev, + processExecutableInode: current_exe_ino, + processExecutableIdentityValid: true, clientIp: "0".to_string(), clientPort: 0, // doesn't matter for this test processName: OsString::from("test"), @@ -941,6 +953,9 @@ mod tests { userGroups: vec!["users".to_string()], processId: 1234, processFullPath: PathBuf::from("/usr/bin/curl"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "127.0.0.1".to_string(), clientPort: 12345, processName: OsString::from("curl"), @@ -976,6 +991,9 @@ mod tests { userGroups: vec!["users".to_string()], processId: 5678, processFullPath: PathBuf::from("/usr/bin/curl"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "127.0.0.1".to_string(), clientPort: 12345, processName: OsString::from("curl"), @@ -1072,6 +1090,9 @@ mod tests { userGroups: vec![], processId: 0, processFullPath: PathBuf::from("p"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, clientIp: "0".to_string(), clientPort: 0, processName: OsString::from("p"), diff --git a/proxy_agent/src/proxy/proxy_authorizer.rs b/proxy_agent/src/proxy/proxy_authorizer.rs index 0c59c82a..5b434dfb 100644 --- a/proxy_agent/src/proxy/proxy_authorizer.rs +++ b/proxy_agent/src/proxy/proxy_authorizer.rs @@ -308,6 +308,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -382,6 +385,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -508,6 +514,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -617,6 +626,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), @@ -743,6 +755,9 @@ mod tests { processId: std::process::id(), processName: OsString::from("test"), processFullPath: PathBuf::from("test"), + processExecutableDevice: 0, + processExecutableInode: 0, + processExecutableIdentityValid: false, processCmdLine: "test".to_string(), runAsElevated: true, clientIp: "127.0.0.1".to_string(), diff --git a/proxy_agent/src/redirector.rs b/proxy_agent/src/redirector.rs index 892bdf88..f3bb73a7 100644 --- a/proxy_agent/src/redirector.rs +++ b/proxy_agent/src/redirector.rs @@ -166,6 +166,12 @@ pub struct AuditEntry { pub destination_port: u16, // in network byte order #[serde(default)] pub address_family: AddressFamily, + #[serde(default)] + pub executable_device: u64, + #[serde(default)] + pub executable_inode: u64, + #[serde(default)] + pub executable_identity_valid: bool, } impl AuditEntry { @@ -177,6 +183,9 @@ impl AuditEntry { destination_ipv4: 0, destination_port: 0, address_family: AddressFamily::IPv4, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } diff --git a/proxy_agent/src/redirector/linux.rs b/proxy_agent/src/redirector/linux.rs index f0cf8745..97868ff6 100644 --- a/proxy_agent/src/redirector/linux.rs +++ b/proxy_agent/src/redirector/linux.rs @@ -8,7 +8,7 @@ use crate::common::{ }; use crate::redirector::shared_ebpf::linux_types::{ alert_only_event, destination_entry, sock_addr_audit_entry, sock_addr_audit_key, - sock_addr_skip_process_entry, AuditMapKey, AuditMapValue, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, + sock_addr_skip_process_entry, AuditMapKey, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, SKIP_PROCESS_MAP_NAME, }; use crate::redirector::{ip_to_string, AlertOnlyEntry, AuditEntry}; @@ -29,6 +29,21 @@ pub struct BpfObject { event_runtime: super::EventTaskRuntime, } +/// Linux audit_map value: the shared 28-byte audit entry followed by the executable +/// identity captured in-kernel (device, inode low/high, valid flag). +/// Mirrors `struct gpa_linux_audit_event` in linux-ebpf/socket.h. +pub type LinuxAuditMapValue = [u32; 11]; + +fn linux_audit_entry_from_array(value: LinuxAuditMapValue) -> AuditEntry { + let mut base = [0u32; 7]; + base.copy_from_slice(&value[..7]); + let mut entry = sock_addr_audit_entry::from_array(base).to_audit_entry(); + entry.executable_device = u64::from(value[7]); + entry.executable_inode = u64::from(value[8]) | (u64::from(value[9]) << 32); + entry.executable_identity_valid = value[10] != 0; + entry +} + // BpfObject is a wrapper around Bpf object to interact with Linux eBPF programs and maps impl BpfObject { pub fn new(ebpf: Ebpf) -> Self { @@ -305,25 +320,24 @@ impl BpfObject { pub fn lookup_audit(&self, source_port: u16) -> Result { let audit_map_name = AUDIT_MAP_NAME; match self.ebpf()?.map(audit_map_name) { - Some(map) => match HashMap::<&MapData, AuditMapKey, AuditMapValue>::try_from(map) { - Ok(audit_map) => { - let key = sock_addr_audit_key::from_source_port(source_port); - match audit_map.get(&key.as_array(), 0) { - Ok(value) => { - let audit_value = sock_addr_audit_entry::from_array(value); - Ok(audit_value.to_audit_entry()) + Some(map) => { + match HashMap::<&MapData, AuditMapKey, LinuxAuditMapValue>::try_from(map) { + Ok(audit_map) => { + let key = sock_addr_audit_key::from_source_port(source_port); + match audit_map.get(&key.as_array(), 0) { + Ok(value) => Ok(linux_audit_entry_from_array(value)), + Err(err) => Err(Error::Bpf(BpfErrorType::MapLookupElem( + source_port.to_string(), + err.to_string(), + ))), } - Err(err) => Err(Error::Bpf(BpfErrorType::MapLookupElem( - source_port.to_string(), - err.to_string(), - ))), } + Err(err) => Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( + audit_map_name.to_string(), + err.to_string(), + ))), } - Err(err) => Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( - audit_map_name.to_string(), - err.to_string(), - ))), - }, + } None => Err(Error::Bpf(BpfErrorType::GetBpfMap( audit_map_name.to_string(), "Map does not exist".to_string(), @@ -418,23 +432,25 @@ impl BpfObject { pub fn remove_audit_map_entry(&mut self, source_port: u16) -> Result<()> { let audit_map_name = AUDIT_MAP_NAME; match self.ebpf_mut()?.map_mut(audit_map_name) { - Some(map) => match HashMap::<&mut MapData, AuditMapKey, AuditMapValue>::try_from(map) { - Ok(mut audit_map) => { - let key = sock_addr_audit_key::from_source_port(source_port); - audit_map.remove(&key.as_array()).map_err(|err| { - Error::Bpf(BpfErrorType::MapDeleteElem( - source_port.to_string(), - format!("Error: {err}"), - )) - })?; - } - Err(err) => { - return Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( - audit_map_name.to_string(), - err.to_string(), - ))); + Some(map) => { + match HashMap::<&mut MapData, AuditMapKey, LinuxAuditMapValue>::try_from(map) { + Ok(mut audit_map) => { + let key = sock_addr_audit_key::from_source_port(source_port); + audit_map.remove(&key.as_array()).map_err(|err| { + Error::Bpf(BpfErrorType::MapDeleteElem( + source_port.to_string(), + format!("Error: {err}"), + )) + })?; + } + Err(err) => { + return Err(Error::Bpf(BpfErrorType::LoadBpfMapHashMap( + audit_map_name.to_string(), + err.to_string(), + ))); + } } - }, + } None => { return Err(Error::Bpf(BpfErrorType::GetBpfMap( audit_map_name.to_string(), @@ -628,10 +644,11 @@ pub async fn update_hostga_redirect_policy( #[cfg(test)] #[cfg(feature = "test-with-root")] mod tests { + use super::LinuxAuditMapValue; use crate::common::config; use crate::common::constants; use crate::redirector::shared_ebpf::linux_types::{ - sock_addr_audit_entry, sock_addr_audit_key, AuditMapKey, AuditMapValue, + sock_addr_audit_entry, sock_addr_audit_key, AuditMapKey, }; use aya::maps::HashMap; use proxy_agent_shared::misc_helpers; @@ -721,16 +738,20 @@ mod tests { address_family: crate::redirector::shared_ebpf::GPA_ADDRESS_FAMILY_IPV6, reserved: 0, }; + let mut linux_value: LinuxAuditMapValue = [0; 11]; + linux_value[..7].copy_from_slice(&value.as_array()); + linux_value[7] = 8; + linux_value[8] = 7; + linux_value[9] = 6; + linux_value[10] = 1; { // drop map_mut("audit_map") within this scope - let mut audit_map: HashMap<&mut aya::maps::MapData, AuditMapKey, AuditMapValue> = - HashMap::<&mut aya::maps::MapData, AuditMapKey, AuditMapValue>::try_from( + let mut audit_map: HashMap<&mut aya::maps::MapData, AuditMapKey, LinuxAuditMapValue> = + HashMap::<&mut aya::maps::MapData, AuditMapKey, LinuxAuditMapValue>::try_from( bpf.ebpf_mut().unwrap().map_mut("audit_map").unwrap(), ) .unwrap(); - audit_map - .insert(key.as_array(), value.as_array(), 0) - .unwrap(); + audit_map.insert(key.as_array(), linux_value, 0).unwrap(); } let audit = bpf.lookup_audit(source_port); match audit { @@ -753,6 +774,9 @@ mod tests { "destination_port is not equal" ); assert_eq!(entry.address_family, crate::redirector::AddressFamily::IPv6); + assert_eq!(entry.executable_device, 8); + assert_eq!(entry.executable_inode, (6u64 << 32) | 7); + assert!(entry.executable_identity_valid); } Err(err) => { println!("lookup_audit_internal error: {}", err); @@ -761,3 +785,41 @@ mod tests { } } } + +#[cfg(test)] +mod audit_decode_tests { + use super::{linux_audit_entry_from_array, LinuxAuditMapValue}; + + #[test] + fn linux_audit_value_decodes_executable_identity() { + let value: LinuxAuditMapValue = [ + 1000, + 42, + 0, + 0x0102_0304, + 80, + 4, + 0, + 8, + 0x89AB_CDEF, + 0x0123_4567, + 1, + ]; + let entry = linux_audit_entry_from_array(value); + + assert_eq!(entry.logon_id, 1000); + assert_eq!(entry.process_id, 42); + assert_eq!(entry.executable_device, 8); + assert_eq!(entry.executable_inode, 0x0123_4567_89AB_CDEF); + assert!(entry.executable_identity_valid); + + let mut invalid = value; + invalid[10] = 0; + assert!(!linux_audit_entry_from_array(invalid).executable_identity_valid); + } + + #[test] + fn linux_audit_value_matches_ebpf_layout() { + assert_eq!(std::mem::size_of::(), 44); + } +} diff --git a/proxy_agent/src/redirector/shared_ebpf.rs b/proxy_agent/src/redirector/shared_ebpf.rs index 7b0b8c2c..7c23cd91 100644 --- a/proxy_agent/src/redirector/shared_ebpf.rs +++ b/proxy_agent/src/redirector/shared_ebpf.rs @@ -225,6 +225,9 @@ impl sock_addr_audit_entry { } else { crate::redirector::AddressFamily::IPv4 }, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } } @@ -257,6 +260,9 @@ impl sock_addr_audit_entry_legacy { destination_ipv4: self.destination_ipv4, destination_port: self.destination_port, address_family: crate::redirector::AddressFamily::IPv4, + executable_device: 0, + executable_inode: 0, + executable_identity_valid: false, } } } @@ -415,8 +421,8 @@ impl AuditValueEntry { pub mod linux_types { pub use super::{ alert_only_event, destination_entry, sock_addr_audit_entry, sock_addr_audit_key, - sock_addr_skip_process_entry, AuditMapKey, AuditMapValue, ALERT_ONLY_MAP_NAME, - AUDIT_MAP_NAME, CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, + sock_addr_skip_process_entry, AuditMapKey, ALERT_ONLY_MAP_NAME, AUDIT_MAP_NAME, + CONFIG_MAP_NAME, GPA_CONFIG_LOCAL_IP_BIND_MONITOR_ONLY, POLICY_MAP_NAME, SKIP_PROCESS_MAP_NAME, }; } From c9c26f432da0ba1cc6a3d0a789129fa34748b3e7 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 8 Oct 2026 16:46:51 +0000 Subject: [PATCH 2/8] add comment --- linux-ebpf/socket.h | 15 +++++++++ proxy_agent/src/key_keeper/key.rs | 35 ++++++++++++++++++-- proxy_agent/src/proxy/authorization_rules.rs | 5 ++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/linux-ebpf/socket.h b/linux-ebpf/socket.h index a119e0df..23ea8de4 100644 --- a/linux-ebpf/socket.h +++ b/linux-ebpf/socket.h @@ -106,23 +106,38 @@ struct sock { struct sock_common __sk_common; }; +// Minimal kernel structs used to read the connecting task's executable identity: +// task_struct->mm->exe_file->f_inode->{i_sb->s_dev, i_ino} +// As with the structs above, only the named fields are declared; CO-RE relocates +// them against the running kernel's BTF, and the type names must match the kernel's. + +// Filesystem superblock; s_dev is the backing device (dev_t encoding) of the file's +// filesystem. Together with i_ino it identifies the file independent of any path or +// mount namespace. struct super_block { __u32 s_dev; }; +// On-disk file object; (s_dev, i_ino) is the executable identity compared by user mode. struct inode { unsigned long i_ino; struct super_block *i_sb; }; +// Open file object; f_inode is the inode backing the file. struct file { struct inode *f_inode; }; +// Process address space; exe_file is the file execve() mapped as the main +// executable (the same file /proc//exe resolves to), but read here without a +// path lookup, so a bind mount or symlink cannot change what is observed. +// May be NULL for kernel threads. struct mm_struct { struct file *exe_file; }; +// Current task (from bpf_get_current_task()); mm is NULL for kernel threads. struct task_struct { struct mm_struct *mm; }; diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 4c1b98f0..8a843a2e 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -339,6 +339,16 @@ impl Clone for Identity { } } +/// Converts a `stat` `st_dev` (userspace encoding: glibc `makedev`) to the kernel-internal +/// `dev_t` encoding (`MKDEV`: major << 20 | minor) that `super_block->s_dev` holds and that +/// the eBPF program reports in `Claims::processExecutableDevice`. +#[cfg(not(windows))] +pub(crate) fn kernel_dev_from_st_dev(st_dev: u64) -> u64 { + let major = u64::from(libc::major(st_dev)); + let minor = u64::from(libc::minor(st_dev)); + (major << 20) | minor +} + impl Identity { pub fn is_match(&self, logger: &mut ConnectionLogger, claims: &Claims) -> bool { logger.write( @@ -414,7 +424,15 @@ impl Identity { ); return false; } - if metadata.dev() != claims.processExecutableDevice + // Authorize by file identity, not by path: the eBPF hook captured the + // (device, inode) of the caller's mapped executable, which a bind mount + // or symlink over `exePath` cannot change. Compare it with the file that + // `exePath` resolves to in GPA's own (trusted) mount namespace. A mismatch + // means the caller is a different file than the one the rule allows, even + // if its `/proc//exe` path text looks identical. + // stat's st_dev uses the userspace encoding, but the eBPF value is the + // kernel's internal s_dev, so re-encode before comparing. + if kernel_dev_from_st_dev(metadata.dev()) != claims.processExecutableDevice || metadata.ino() != claims.processExecutableInode { logger.write( @@ -1686,6 +1704,19 @@ mod tests { ); } + #[cfg(not(windows))] + #[test] + fn kernel_dev_from_st_dev_matches_kernel_encoding() { + // /dev/sda1 (8,1): stat st_dev is 0x801, kernel s_dev is 0x800001. + assert_eq!(super::kernel_dev_from_st_dev(0x801), 0x80_0001); + // Minor numbers above 255 are split across the userspace encoding. + let st_dev = libc::makedev(259, 0x1234); + assert_eq!( + super::kernel_dev_from_st_dev(st_dev), + (259u64 << 20) | 0x1234 + ); + } + #[tokio::test] async fn test_identity_is_match() { #[cfg(not(windows))] @@ -1709,7 +1740,7 @@ mod tests { runAsElevated: true, processFullPath: current_exe, #[cfg(not(windows))] - processExecutableDevice: current_exe_metadata.dev(), + processExecutableDevice: super::kernel_dev_from_st_dev(current_exe_metadata.dev()), #[cfg(windows)] processExecutableDevice: 0, #[cfg(not(windows))] diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index c27a2e95..b93ad31b 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -649,7 +649,10 @@ mod tests { let (current_exe_dev, current_exe_ino) = { use std::os::unix::fs::MetadataExt; let metadata = std::fs::metadata(¤t_exe).unwrap(); - (metadata.dev(), metadata.ino()) + ( + crate::key_keeper::key::kernel_dev_from_st_dev(metadata.dev()), + metadata.ino(), + ) }; #[cfg(windows)] let (current_exe_dev, current_exe_ino) = (0u64, 0u64); From 8b4b65b60e276fad95f0f53bec81d6e0af953774 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 8 Oct 2026 19:10:15 +0000 Subject: [PATCH 3/8] update pentest scripts for validation --- pentest/linux/phase4b_local_rules/run.py | 1513 ++++------------------ pentest/windows/Phase4b-LocalRules.ps1 | 72 +- 2 files changed, 295 insertions(+), 1290 deletions(-) diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 3e45b00b..42b2b5e7 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -1,1267 +1,246 @@ -#!/usr/bin/env python3 -""" -Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. - -PRE-REQ: - 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 - 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) - - # 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"effective remote mode={effective_modes}") - - -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). - - 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, - } - - -def _wire_encode_path(path: str) -> str: - """Percent-encode non-ASCII path bytes as UTF-8 before send. - - Python's http.client transmits the request line via request.encode('ascii') - and raises UnicodeEncodeError on any raw non-ASCII char (e.g. the U+0131 - dotless-i confusable in token_unicode_dotless). That exception is caught - below as a false -1 — the request never reaches GPA, so we'd never observe - the real authorization decision. Browsers and .NET's System.Uri percent- - encode non-ASCII path bytes as UTF-8 before sending; mirror that so the - confusable arrives on the wire as %C4%B1 and GPA's 403 is actually exercised. - - Only non-ASCII codepoints are rewritten; ASCII (including existing %XX, - '/', '?', ';', '&', '=') passes through verbatim so every other probe is - byte-for-byte unchanged on the wire.""" - return "".join( - ch if ord(ch) < 0x80 else "".join(f"%{b:02X}" for b in ch.encode("utf-8")) - for ch in path - ) - - -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, _wire_encode_path(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: Any # int or tuple/list of acceptable HTTP statuses - 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) - bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file - - -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: 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( - 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), - # 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, 404)), - ], - )) - - # 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), - ], - )) - - # ------------------------------------------------------------------ - # 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), - ], - )) - - # S20: explicit fail-closed of the IMDS managed-identity token endpoint. - # defaultAccess=deny + mode=enforce + empty privileges means EVERY path — - # including every URL-equivalence variant of /metadata/identity/oauth2/ - # token (mixed case, %2F-encoded slash, double-encoded, trailing dot, - # extra slashes, fragment, matrix param, Unicode confusable) — must fall - # through to the deny default. With shadow-mode (M3) enabled this is the - # rule set under which the canonical authorizer can surface - # CANON_DIVERGENCE log lines: any variant where the legacy substring - # matcher and the canonical normalized-path matcher disagree shows up as - # `legacy=allow canon=deny` (or vice versa) in the GPA service log even - # though the on-wire response stays 403. Any 200 response here is a true - # bypass. - # `token_extra_slashes` may also surface as 404 once the pre-canonical - # traversal guard (path_has_traversal) catches `//` before the rule - # engine runs — both 403 and 404 mean the bypass attempt failed. - scenarios.append(Scenario( - sid=f"{pfx}-S20-deny-token-path", - target=target, - description="enforce + deny + no privileges; every URL-equivalence " - "variant of the IMDS token path must 403 (or 404 when " - "GPA traversal-guard fires)", - rules={ - "defaultAccess": "deny", - "mode": "enforce", - "id": "pentest-s20", - "rules": {}, - }, - probes=[ - Probe("token_canonical", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - 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_trailing_dot_path", - "/metadata/identity/oauth2/token./?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_param_injection", - "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_case_path", - "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 403), - Probe("token_extra_slashes", - "//metadata///identity//oauth2//token?api-version=2018-02-01" - "&resource=https://management.azure.com/", (403, 404)), - Probe("token_fragment", - "/metadata/identity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/#x", 403), - Probe("token_unicode_dotless", - "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01" - "&resource=https://management.azure.com/", 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: 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( - 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), - # 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, 404)), - 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), - # 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, 400)), - ], - )) - - # 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), - ], - )) - - # ------------------------------------------------------------------ - # 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 - - -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: - 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) - - passes = fails = 0 - for p in sc.probes: - actual = send(sc.target, p.method, p.path) - 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]}") - 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) - - # C3: execute Bash through a private bind mount at the allow-listed - # /usr/bin/curl path. The textual /proc//exe path can look trusted, - # but the kernel-captured executable device/inode must remain Bash's. - request_headers = ( - "Metadata: true\\r\\n" if sc.target.name == "imds" - else "x-ms-version: 2012-11-30\\r\\n" - ) - request_path = ( - "/metadata/instance?api-version=2021-02-01" - if sc.target.name == "imds" - else "/machine/?comp=goalstate" - ) - inner = ( - "mount --make-rprivate / && " - "mount --bind /proc/$$/exe /usr/bin/curl && " - "/usr/bin/curl -c '" - f"exec 3<>/dev/tcp/{sc.target.host}/{sc.target.port}; " - f"printf \"GET {request_path} HTTP/1.1\\\\r\\\\n" - f"Host: {sc.target.host}\\\\r\\\\n{request_headers}" - "Connection: close\\\\r\\\\n\\\\r\\\\n\" >&3; " - "IFS=\" \" read -r _ status _ <&3; printf \"%s\" \"$status\"'" - ) - try: - out = subprocess.run( - ["unshare", "--user", "--map-root-user", "--mount", - "--", "bash", "-c", inner], - capture_output=True, text=True, timeout=15) - if out.returncode != 0: - record("C3", "INFO", - "user/mount namespace unavailable; " - f"stderr={out.stderr.strip()[:160]}") - else: - actual = int(out.stdout or "0") - ok = (actual == 403) - record("C3", - "PASS" if ok else "FAIL", - f"expected=403 actual={actual}; " - "caller /proc//exe bind-mounted over allow-listed /usr/bin/curl") - passes += int(ok); fails += int(not ok) - except (FileNotFoundError, subprocess.TimeoutExpired) as exc: - record("C3", "INFO", f"namespace probe unavailable: {exc}") - - 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) +# 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 | 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. 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 +| 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. | +| 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 +| 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 | In phase 4b S9, enter a private user+mount namespace, bind-mount the attacker's `/proc//exe` over allow-listed `/usr/bin/curl`, execute it through that path, and send the request using Bash builtins. | Genuine curl is allowed; the spoof is denied. Authorization compares the executable device/inode captured by eBPF at connect time with `/usr/bin/curl` resolved in GPA's trusted mount namespace. `/proc//exe` remains diagnostic only. | 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), and avoids post-connect `execve` and PID-reuse attribution races. | +| 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. 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/...`). 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) + +| 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 | 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 | 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. | +| 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 | +|----|------|----------|----------------------------| +| 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 | 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. | + +--- + +## 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/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: + +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, +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 | 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 (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.) | +| `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. | +| `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`) + +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 | 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 (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. | +| `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. | +| `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 + +```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/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. +- `*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, 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. + +### 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/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/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`. + +--- + +## 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/windows/Phase4b-LocalRules.ps1 b/pentest/windows/Phase4b-LocalRules.ps1 index 464aaa61..c33505fb 100644 --- a/pentest/windows/Phase4b-LocalRules.ps1 +++ b/pentest/windows/Phase4b-LocalRules.ps1 @@ -84,46 +84,72 @@ function Get-LatestAuthRulesSnapshot { } function Assert-UseLocalFileRules { + param([string[]] $TargetNames) + $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.') + try { + $rulesSnapshot = $text | ConvertFrom-Json + } + catch { + Write-Finding PRE FAIL "cannot parse authorization rules snapshot $($snap.Name): $_" exit 2 } + + $localRulesDisabled = @() + foreach ($targetName in $TargetNames) { + $computedRule = $rulesSnapshot.computedRules.$targetName + if (-not $computedRule -or $computedRule.id -notmatch 'useLocalFileRules-true') { + $localRulesDisabled += $targetName + } + } + if ($localRulesDisabled.Count -gt 0) { + Write-Finding PRE INFO ("useLocalFileRules=true is not active for targets " + + "$($localRulesDisabled -join ', ') in $($snap.Name); skipping local-rule " + + 'tests for those targets.') + } + $activeTargetNames = @($TargetNames | Where-Object { $_ -notin $localRulesDisabled }) + # 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 } + foreach ($targetName in $activeTargetNames) { + $mode = $null + if ($rulesSnapshot.computedRules -and $rulesSnapshot.computedRules.$targetName) { + $mode = $rulesSnapshot.computedRules.$targetName.mode } + if (-not $mode -and $rulesSnapshot.inputRules -and $rulesSnapshot.inputRules.$targetName) { + $mode = $rulesSnapshot.inputRules.$targetName.mode + } + if ($mode) { $modes[$targetName] = $mode } } - catch { } $nonEnforce = @{} - foreach ($k in $modes.Keys) { - if ($modes[$k].ToLower() -ne 'enforce') { $nonEnforce[$k] = $modes[$k] } + foreach ($targetName in $activeTargetNames) { + if (-not $modes.ContainsKey($targetName) -or $modes[$targetName].ToLower() -ne 'enforce') { + $nonEnforce[$targetName] = if ($modes.ContainsKey($targetName)) { $modes[$targetName] } else { 'missing' } + } } 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.') + Write-Finding PRE INFO ("effective remote mode is not Enforce ($detail); " + + 'skipping local-rule tests for those targets.') + $activeTargetNames = @($activeTargetNames | Where-Object { -not $nonEnforce.ContainsKey($_) }) + } + if ($activeTargetNames.Count -eq 0) { + Write-Finding PRE FAIL 'no requested targets support enforce-mode local-file rules' exit 2 } - $modeStr = ($modes.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', ' - Write-Finding PRE PASS "useLocalFileRules-true confirmed in $($snap.Name); effective remote mode={$modeStr}" + $modeStr = ($activeTargetNames | ForEach-Object { "$_=$($modes[$_])" }) -join ', ' + Write-Finding PRE PASS ("useLocalFileRules-true confirmed for " + + "$($activeTargetNames -join ', ') in $($snap.Name); effective remote mode={$modeStr}") + return $activeTargetNames } -Assert-UseLocalFileRules +$TargetNames = if ($Target -eq 'both') { @('imds', 'wireserver') } else { @($Target) } +$TargetNames = @(Assert-UseLocalFileRules -TargetNames $TargetNames) # --- identity captured at probe-time ---------------------------------------- # Probes are sent from this PowerShell process. The matcher AND-matches every @@ -138,7 +164,7 @@ $Identity = @{ groupName = 'Administrators' } Write-Finding CFG INFO "current identity: user=$($Identity.userName) group=$($Identity.groupName)" -Write-Finding CFG INFO "targets: $Target" +Write-Finding CFG INFO "targets: $($TargetNames -join ', ')" # Latin → Cyrillic visual-homoglyph table used by the S19 scenarios. Pairs # only characters that render (in common fonts) as visually indistinguishable @@ -866,8 +892,8 @@ function Invoke-Scenario { 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 ($TargetNames -contains 'imds') { $all += Build-ImdsScenarios -Identity $Identity } + if ($TargetNames -contains 'wireserver') { $all += Build-WireServerScenarios -Identity $Identity } if ($Scenarios) { $all = $all | Where-Object { $Scenarios -contains $_.Sid } } return $all } From ed6b250ecfd0d637e12f134c69069e8b41f4caac Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 8 Oct 2026 20:59:19 +0000 Subject: [PATCH 4/8] fix spelling --- cspell.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cspell.json b/cspell.json index cd80c79a..ee2d3ae9 100644 --- a/cspell.json +++ b/cspell.json @@ -213,6 +213,7 @@ "fallbacked", "Fapi", "Fbar", + "Fcomp", "fde", "fea", "ffcecb", @@ -401,6 +402,7 @@ "passwordless", "pcap", "pcapng", + "pcaps", "pcr", "PDBs", "peekable", @@ -469,6 +471,7 @@ "rockylinux", "rolename", "rootdir", + "rprivate", "rpmbuild", "RPMS", "rstr", @@ -527,6 +530,7 @@ "stdint", "stdoutput", "stduser", + "subshell", "subsecond", "substatus", "Substatuses", From a5e47be6a894b1ec65e5bc688814700afc01b5a7 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Wed, 14 Oct 2026 19:26:14 +0000 Subject: [PATCH 5/8] pasted the right content. --- pentest/linux/phase4b_local_rules/run.py | 1471 ++++++++++++++++++---- 1 file changed, 1225 insertions(+), 246 deletions(-) diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 42b2b5e7..6131c309 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -1,246 +1,1225 @@ -# 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 | 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. 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 -| 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. | -| 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 -| 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 | In phase 4b S9, enter a private user+mount namespace, bind-mount the attacker's `/proc//exe` over allow-listed `/usr/bin/curl`, execute it through that path, and send the request using Bash builtins. | Genuine curl is allowed; the spoof is denied. Authorization compares the executable device/inode captured by eBPF at connect time with `/usr/bin/curl` resolved in GPA's trusted mount namespace. `/proc//exe` remains diagnostic only. | 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), and avoids post-connect `execve` and PID-reuse attribution races. | -| 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. 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/...`). 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) - -| 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 | 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 | 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. | -| 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 | -|----|------|----------|----------------------------| -| 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 | 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. | - ---- - -## 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/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: - -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, -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 | 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 (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.) | -| `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. | -| `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`) - -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 | 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 (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. | -| `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. | -| `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 - -```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/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. -- `*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, 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. - -### 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/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/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`. - ---- - -## 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). +#!/usr/bin/env python3 +""" +Phase 4b — auto-run pen-tests for the GPA local-file authorization rules. + +PRE-REQ: + 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 + 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) + + # 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"effective remote mode={effective_modes}") + + +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). + + 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, + } + + +def _wire_encode_path(path: str) -> str: + """Percent-encode non-ASCII path bytes as UTF-8 before send. + + Python's http.client transmits the request line via request.encode('ascii') + and raises UnicodeEncodeError on any raw non-ASCII char (e.g. the U+0131 + dotless-i confusable in token_unicode_dotless). That exception is caught + below as a false -1 — the request never reaches GPA, so we'd never observe + the real authorization decision. Browsers and .NET's System.Uri percent- + encode non-ASCII path bytes as UTF-8 before sending; mirror that so the + confusable arrives on the wire as %C4%B1 and GPA's 403 is actually exercised. + + Only non-ASCII codepoints are rewritten; ASCII (including existing %XX, + '/', '?', ';', '&', '=') passes through verbatim so every other probe is + byte-for-byte unchanged on the wire.""" + return "".join( + ch if ord(ch) < 0x80 else "".join(f"%{b:02X}" for b in ch.encode("utf-8")) + for ch in path + ) + + +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, _wire_encode_path(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: Any # int or tuple/list of acceptable HTTP statuses + 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) + bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file + + +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: 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( + 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), + # 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, 404)), + ], + )) + + # 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), + ], + )) + + # ------------------------------------------------------------------ + # 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), + ], + )) + + # S20: explicit fail-closed of the IMDS managed-identity token endpoint. + # defaultAccess=deny + mode=enforce + empty privileges means EVERY path — + # including every URL-equivalence variant of /metadata/identity/oauth2/ + # token (mixed case, %2F-encoded slash, double-encoded, trailing dot, + # extra slashes, fragment, matrix param, Unicode confusable) — must fall + # through to the deny default. With shadow-mode (M3) enabled this is the + # rule set under which the canonical authorizer can surface + # CANON_DIVERGENCE log lines: any variant where the legacy substring + # matcher and the canonical normalized-path matcher disagree shows up as + # `legacy=allow canon=deny` (or vice versa) in the GPA service log even + # though the on-wire response stays 403. Any 200 response here is a true + # bypass. + # `token_extra_slashes` may also surface as 404 once the pre-canonical + # traversal guard (path_has_traversal) catches `//` before the rule + # engine runs — both 403 and 404 mean the bypass attempt failed. + scenarios.append(Scenario( + sid=f"{pfx}-S20-deny-token-path", + target=target, + description="enforce + deny + no privileges; every URL-equivalence " + "variant of the IMDS token path must 403 (or 404 when " + "GPA traversal-guard fires)", + rules={ + "defaultAccess": "deny", + "mode": "enforce", + "id": "pentest-s20", + "rules": {}, + }, + probes=[ + Probe("token_canonical", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + 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_trailing_dot_path", + "/metadata/identity/oauth2/token./?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_param_injection", + "/metadata/identity/oauth2/token;x=1?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_case_path", + "/Metadata/Identity/OAuth2/Token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 403), + Probe("token_extra_slashes", + "//metadata///identity//oauth2//token?api-version=2018-02-01" + "&resource=https://management.azure.com/", (403, 404)), + Probe("token_fragment", + "/metadata/identity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/#x", 403), + Probe("token_unicode_dotless", + "/metadata/\u0131dentity/oauth2/token?api-version=2018-02-01" + "&resource=https://management.azure.com/", 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: 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( + 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), + # 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, 404)), + 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), + # 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, 400)), + ], + )) + + # 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), + ], + )) + + # ------------------------------------------------------------------ + # 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 + + +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: + 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) + + passes = fails = 0 + for p in sc.probes: + actual = send(sc.target, p.method, p.path) + 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]}") + 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) From 3f48db73be74b373a4289d13e3600c969fe7eb2d Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Wed, 14 Oct 2026 19:40:49 +0000 Subject: [PATCH 6/8] restore the change --- pentest/linux/phase4b_local_rules/run.py | 123 +++++++++++++++++------ 1 file changed, 95 insertions(+), 28 deletions(-) diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 6131c309..4a971720 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -106,50 +106,71 @@ def latest_authorization_rules_snapshot() -> Optional[Path]: 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.""" +def get_active_local_rule_targets(target_names: list[str]) -> list[str]: + """Return requested targets that support enforce-mode local-file rules.""" 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: + try: + snap_json = json.loads(snap_text) + except Exception as exc: 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.") + f"cannot parse authorization rules snapshot {snap}: {exc}") sys.exit(2) + computed_rules = snap_json.get("computedRules", {}) + local_rules_disabled = [ + target for target in target_names + if "useLocalFileRules-true" not in str( + computed_rules.get(target, {}).get("id", "")) + ] + if local_rules_disabled: + record("PRE", "INFO", + f"useLocalFileRules=true is not active for targets " + f"{', '.join(local_rules_disabled)} in {snap.name}; skipping local-rule " + "tests for those targets.") + + active_targets = [ + target for target in target_names if target not in local_rules_disabled + ] + # 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. + # Skip that target rather than reporting misleading scenario 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"} + for target in active_targets: + mode = (computed_rules.get(target, {}).get("mode") + or snap_json.get("inputRules", {}).get(target, {}).get("mode")) + if mode: + effective_modes[target] = mode + non_enforce = { + target: effective_modes.get(target, "missing") + for target in active_targets + if effective_modes.get(target, "").lower() != "enforce" + } if non_enforce: - record("PRE", "FAIL", + record("PRE", "INFO", 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.") + "skipping local-rule tests for those targets.") + active_targets = [ + target for target in active_targets if target not in non_enforce + ] + + if not active_targets: + record("PRE", "FAIL", "no requested targets support enforce-mode local-file rules") sys.exit(2) record("PRE", "PASS", - f"useLocalFileRules-true confirmed in {snap.name if snap else '?'}; " - f"effective remote mode={effective_modes}") + f"useLocalFileRules-true confirmed for {', '.join(active_targets)} " + f"in {snap.name}; " + f"effective remote mode=" + f"{ {target: effective_modes[target] for target in active_targets} }") + return active_targets def get_current_user_identity() -> dict: @@ -1158,6 +1179,50 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: f"expected=200 actual={actual} (curl invocation)") passes += int(ok); fails += int(not ok) + # C3: make Bash appear at the allow-listed curl path inside a private + # mount namespace. The connection still originates from Bash's inode, + # so trusted-namespace executable identity must deny it. + request_path = ( + "/metadata/instance?api-version=2021-02-01" + if sc.target.name == "imds" + else "/machine/?comp=goalstate" + ) + request_headers = ( + "Metadata: true\\r\\n" + if sc.target.name == "imds" + else "x-ms-version: 2012-11-30\\r\\n" + ) + namespace_script = ( + "mount --make-rprivate / && " + "mount --bind /proc/$$/exe /usr/bin/curl && " + "/usr/bin/curl -c '" + f"exec 3<>/dev/tcp/{sc.target.host}/{sc.target.port}; " + f"printf \"GET {request_path} HTTP/1.1\\r\\n" + f"Host: {sc.target.host}\\r\\n{request_headers}" + "Connection: close\\r\\n\\r\\n\" >&3; " + "IFS=\" \" read -r _ status _ <&3; printf \"%s\" \"$status\"'" + ) + try: + result = subprocess.run( + ["unshare", "--user", "--map-root-user", "--mount", + "--", "bash", "-c", namespace_script], + capture_output=True, text=True, timeout=15) + if result.returncode != 0: + record(f"C3/{sc.target.name}", "INFO", + "user/mount namespace unavailable; " + f"stderr={result.stderr.strip()[:160]}") + else: + actual = int(result.stdout or "0") + ok = (actual == 403) + record(f"C3/{sc.target.name}", + "PASS" if ok else "FAIL", + f"expected=403 actual={actual}; caller executable " + "bind-mounted over /usr/bin/curl") + passes += int(ok); fails += int(not ok) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + record(f"C3/{sc.target.name}", "INFO", + f"namespace probe unavailable: {exc}") + return passes, fails @@ -1173,7 +1238,9 @@ def main() -> int: args = ap.parse_args() require_root() - assert_use_local_file_rules_active() + target_names = (["imds", "wireserver"] + if args.target == "both" else [args.target]) + target_names = get_active_local_rule_targets(target_names) poll_s = args.poll or get_poll_interval() record("CFG", "INFO", f"poll interval = {poll_s}s") @@ -1184,9 +1251,9 @@ def main() -> int: f"group={identity['groupName']}") targets: list[Target] = [] - if args.target in ("imds", "both"): + if "imds" in target_names: targets.append(Target("imds", IMDS_HOST, IMDS_PORT, IMDS_RULES_FILE)) - if args.target in ("wireserver", "both"): + if "wireserver" in target_names: targets.append(Target("wireserver", WS_HOST, WS_PORT, WS_RULES_FILE)) record("CFG", "INFO", f"targets: {', '.join(t.name for t in targets)}") From 1213c9a010b760fd5898a27c11c9b2da831c2914 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 15 Oct 2026 02:02:01 +0000 Subject: [PATCH 7/8] make pentest running for python 3.6 --- build-linux.sh | 3 + pentest/linux/generate_report.py | 17 +-- pentest/linux/phase4_rules_fuzz/url_diff.py | 10 +- pentest/linux/phase4b_local_rules/run.py | 151 ++++++++++++------- pentest/linux/test_catalog.py | 18 +-- proxy_agent/src/common.rs | 3 + proxy_agent/src/common/linux.rs | 80 ++++++++++ proxy_agent/src/key_keeper/key.rs | 56 ++++--- proxy_agent/src/proxy/authorization_rules.rs | 2 +- 9 files changed, 228 insertions(+), 112 deletions(-) create mode 100644 proxy_agent/src/common/linux.rs diff --git a/build-linux.sh b/build-linux.sh index f1699283..09cbfb39 100755 --- a/build-linux.sh +++ b/build-linux.sh @@ -281,6 +281,9 @@ cp -f $out_dir/ProxyAgentExt $out_package_proxyagent_extension_dir/ echo "======= copy e2e test project to Package folder" cp -rf $out_dir/e2etest/ $out_package_dir/e2etest/ +echo "======= copy pentest project to Package folder" +cp -rf $root_path/pentest/ $out_package_dir/pentest/ + echo "======= Generate build-configuration-linux-$Target.zip file with relative path within the zip file" cd $out_package_dir zip -r $out_dir/build-$Configuration-linux-$Target.zip . diff --git a/pentest/linux/generate_report.py b/pentest/linux/generate_report.py index 11a8802b..5aacc3d1 100644 --- a/pentest/linux/generate_report.py +++ b/pentest/linux/generate_report.py @@ -5,8 +5,6 @@ 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 @@ -15,6 +13,7 @@ import sys from collections import Counter, defaultdict from pathlib import Path +from typing import Dict, List ROOT = Path(__file__).resolve().parent RESULTS = ROOT / "results" @@ -50,8 +49,8 @@ def phase_for(tid: str) -> str: return "Other" -def load_findings() -> list[dict]: - rows: list[dict] = [] +def load_findings() -> List[dict]: + rows: List[dict] = [] if not FINDINGS.exists(): return rows for line in FINDINGS.read_text().splitlines(): @@ -66,7 +65,7 @@ def load_findings() -> list[dict]: return rows -def load_url_diff_latest() -> list[list[str]]: +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] @@ -86,7 +85,7 @@ def section(label: str, body: str, mono: bool = False) -> str: return (f"
{label}
" f"
{html.escape(body)}
") - parts: list[str] = [] + parts: List[str] = [] if info.get("title"): parts.append(f"
{html.escape(info['title'])}
") if info.get("design"): @@ -197,7 +196,7 @@ def row_html(r: dict, *, with_phase_col: bool) -> str: """ -def build_html(findings: list[dict], url_rows: list[list[str]]) -> 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. @@ -209,7 +208,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: 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) + by_phase: Dict[str, List[dict]] = defaultdict(list) for r in findings: by_phase[r["phase"]].append(r) @@ -222,7 +221,7 @@ def build_html(findings: list[dict], url_rows: list[list[str]]) -> str: host = socket.gethostname() osinfo = f"{platform.system()} {platform.release()} ({platform.machine()})" - p: list[str] = [] + p: List[str] = [] p.append("") p.append("") p.append("GPA Pen-Test Report") diff --git a/pentest/linux/phase4_rules_fuzz/url_diff.py b/pentest/linux/phase4_rules_fuzz/url_diff.py index 4d817bd6..c9467559 100755 --- a/pentest/linux/phase4_rules_fuzz/url_diff.py +++ b/pentest/linux/phase4_rules_fuzz/url_diff.py @@ -12,8 +12,8 @@ Loopback fabric only; safe to run from a normal user account. """ -from __future__ import annotations import argparse, datetime, http.client, os, re, socket, sys, time, urllib.parse +from typing import List, Optional, Tuple IMDS_HOST = "169.254.169.254" IMDS_PORT = 80 @@ -52,7 +52,7 @@ RAW_VARIANTS = frozenset({"dot_segments", "dot_segments_encoded"}) -def send(path: str) -> tuple[int, str]: +def send(path: str) -> Tuple[int, str]: try: conn = http.client.HTTPConnection(IMDS_HOST, IMDS_PORT, timeout=8) conn.request("GET", path, headers=HEADERS) @@ -64,7 +64,7 @@ def send(path: str) -> tuple[int, str]: return -1, f"{type(exc).__name__}: {exc}" -def send_raw(path: str) -> tuple[int, str]: +def send_raw(path: str) -> Tuple[int, str]: """Write a literal HTTP/1.1 request line over a raw socket, bypassing any URL parsing/canonicalization that http.client or a future Python release might perform. The request bytes contain `path` verbatim.""" @@ -76,12 +76,12 @@ def send_raw(path: str) -> tuple[int, str]: "Accept: */*\r\n" "\r\n" ) - sock: socket.socket | None = None + sock: Optional[socket.socket] = None try: sock = socket.create_connection((IMDS_HOST, IMDS_PORT), timeout=8) sock.sendall(req_text.encode("ascii")) sock.settimeout(8) - chunks: list[bytes] = [] + chunks: List[bytes] = [] total = 0 deadline = time.monotonic() + 8.0 while total < 4096 and time.monotonic() < deadline: diff --git a/pentest/linux/phase4b_local_rules/run.py b/pentest/linux/phase4b_local_rules/run.py index 4a971720..f615f3c6 100755 --- a/pentest/linux/phase4b_local_rules/run.py +++ b/pentest/linux/phase4b_local_rules/run.py @@ -22,12 +22,10 @@ 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 +import argparse, datetime, http.client, json, os, shlex, shutil, signal, subprocess, sys, time from contextlib import contextmanager -from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Optional +from typing import Any, Dict, List, Optional, Tuple # ---------- environment ---------- RULES_DIR = Path("/var/lib/azure-proxy-agent/rules") @@ -46,15 +44,15 @@ WS_PORT = 80 -@dataclass class Target: - name: str # "imds" | "wireserver" - host: str - port: int - rules_file: Path # IMDS_Rules.json | WireServer_Rules.json + def __init__(self, name: str, host: str, port: int, rules_file: Path) -> None: + self.name = name + self.host = host + self.port = port + self.rules_file = rules_file -TARGETS: dict[str, Target] = {} # filled in main() once paths are known +TARGETS: Dict[str, Target] = {} # filled in main() once paths are known # ---------- output ---------- HERE = Path(__file__).resolve().parent @@ -106,7 +104,7 @@ def latest_authorization_rules_snapshot() -> Optional[Path]: return snaps[-1] if snaps else None -def get_active_local_rule_targets(target_names: list[str]) -> list[str]: +def get_active_local_rule_targets(target_names: List[str]) -> List[str]: """Return requested targets that support enforce-mode local-file rules.""" if not STATUS_FILE.exists(): sys.exit(f"{STATUS_FILE} not found; is azure-proxy-agent running?") @@ -182,8 +180,10 @@ def get_current_user_identity() -> dict: 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() + user = subprocess.check_output( + ["id", "-un"], universal_newlines=True).strip() + grp = subprocess.check_output( + ["id", "-gn"], universal_newlines=True).strip() return { "name": "selfRoot", "userName": user, @@ -213,7 +213,7 @@ def _wire_encode_path(path: str) -> str: def send(target: Target, method: str, path: str, headers: Optional[dict] = None, timeout: float = 8.0) -> int: - h: dict[str, str] = {} + h: Dict[str, str] = {} if target.name == "imds": h["Metadata"] = "true" elif target.name == "wireserver": @@ -234,10 +234,10 @@ def send(target: Target, method: str, path: str, # ---------- file management ---------- @contextmanager -def backup_rules_dir(targets: list[Target]): +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] = {} + 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}) @@ -278,27 +278,32 @@ def write_raw(path: Path, raw_text: str) -> None: # ---------- scenarios ---------- -@dataclass class Probe: - name: str - path: str - expected: Any # int or tuple/list of acceptable HTTP statuses - method: str = "GET" + def __init__(self, name: str, path: str, expected: Any, + method: str = "GET") -> None: + self.name = name + self.path = path + self.expected = expected + self.method = method -@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) - bom: bool = False # prepend a UTF-8 BOM (EF BB BF) to the file - - -def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: - scenarios: list[Scenario] = [] + def __init__(self, sid: str, target: Target, description: str, rules: Any, + probes: Optional[List[Probe]] = None, raw: bool = False, + bom: bool = False, exe_path: Optional[str] = None) -> None: + self.sid = sid + self.target = target + self.description = description + self.rules = rules + self.probes = probes if probes is not None else [] + self.raw = raw + self.bom = bom + self.exe_path = exe_path + + +def _imds_scenarios(target: Target, identity: dict, + s9_exe_path: str) -> List[Scenario]: + scenarios: List[Scenario] = [] pfx = "IMDS" # S1: disabled + allow-by-default — all calls succeed (control) @@ -511,11 +516,11 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # S9: identity match by exePath — allow only when caller is /usr/bin/curl + # S9: identity match by exePath — allow only the configured curl binary. scenarios.append(Scenario( sid=f"{pfx}-S9-exepath-identity", target=target, - description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + description=f"enforce + deny; identity restricts to exePath={s9_exe_path}", rules={ "defaultAccess": "deny", "mode": "enforce", @@ -525,7 +530,7 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: "roles": [{"name": "r_any", "privileges": ["p_any"]}], "identities": [{ "name": "curlOnly", - "exePath": "/usr/bin/curl", + "exePath": s9_exe_path, }], "roleAssignments": [{"role": "r_any", "identities": ["curlOnly"]}], @@ -533,10 +538,11 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: }, 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. + # its executable differs. We separately invoke the configured curl below. Probe("python_caller_denied", "/metadata/instance?api-version=2021-02-01", 403), ], + exe_path=s9_exe_path, )) # S10: malformed JSON → fail-closed @@ -736,13 +742,14 @@ def _imds_scenarios(target: Target, identity: dict) -> list[Scenario]: return scenarios -def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: +def _wireserver_scenarios(target: Target, identity: dict, + s9_exe_path: str) -> 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] = [] + scenarios: List[Scenario] = [] pfx = "WS" GOALSTATE = "/machine/?comp=goalstate" @@ -963,11 +970,11 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: ], )) - # WS-S9: per-process AuthZ via exePath — only /usr/bin/curl allowed + # WS-S9: per-process AuthZ via exePath — only the configured curl allowed. scenarios.append(Scenario( sid=f"{pfx}-S9-exepath-identity", target=target, - description="enforce + deny; identity restricts to exePath=/usr/bin/curl", + description=f"enforce + deny; identity restricts to exePath={s9_exe_path}", rules={ "defaultAccess": "deny", "mode": "enforce", @@ -978,7 +985,7 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: "privileges": ["p_machine"]}], "identities": [{ "name": "curlOnly", - "exePath": "/usr/bin/curl", + "exePath": s9_exe_path, }], "roleAssignments": [{"role": "r_machine", "identities": ["curlOnly"]}], @@ -987,6 +994,7 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: probes=[ Probe("python_caller_denied", GOALSTATE, 403), ], + exe_path=s9_exe_path, )) # WS-S10: malformed JSON → fail-closed @@ -1105,13 +1113,14 @@ def _wireserver_scenarios(target: Target, identity: dict) -> list[Scenario]: return scenarios -def build_scenarios(targets: list[Target], identity: dict) -> list[Scenario]: - out: list[Scenario] = [] +def build_scenarios(targets: List[Target], identity: dict, + s9_exe_path: str) -> List[Scenario]: + out: List[Scenario] = [] for t in targets: if t.name == "imds": - out.extend(_imds_scenarios(t, identity)) + out.extend(_imds_scenarios(t, identity, s9_exe_path)) elif t.name == "wireserver": - out.extend(_wireserver_scenarios(t, identity)) + out.extend(_wireserver_scenarios(t, identity, s9_exe_path)) else: raise ValueError(f"unknown target {t.name}") return out @@ -1125,7 +1134,7 @@ def wait_for_refresh(poll_s: int) -> None: time.sleep(delay) -def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: +def run_scenario(sc: Scenario, poll_s: int) -> Tuple[int, int]: print(f"\n=== {sc.sid}: {sc.description}") if sc.raw: text = sc.rules if isinstance(sc.rules, str) else json.dumps(sc.rules) @@ -1158,6 +1167,28 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: # Extra real-curl probe for S9 to demonstrate the positive case if sc.sid.endswith("S9-exepath-identity"): + exe_path = sc.exe_path + if not exe_path: + raise ValueError(f"{sc.sid} is missing its executable path") + try: + findmnt = subprocess.run( + ["findmnt", "-T", exe_path], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, timeout=5) + if findmnt.returncode == 0: + layout = " | ".join( + line.strip() for line in findmnt.stdout.splitlines() if line.strip() + ) + record(f"{sc.sid}/curl_filesystem_layout", "INFO", + f"findmnt -T {exe_path}: {layout}") + else: + record(f"{sc.sid}/curl_filesystem_layout", "INFO", + f"findmnt -T {exe_path} failed; " + f"stderr={findmnt.stderr.strip()[:160]}") + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + record(f"{sc.sid}/curl_filesystem_layout", "INFO", + f"findmnt -T {exe_path} unavailable: {exc}") + if sc.target.name == "imds": url = (f"http://{sc.target.host}/metadata/instance" "?api-version=2021-02-01") @@ -1167,9 +1198,10 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: extra_hdr = ["-H", "x-ms-version: 2012-11-30"] try: out = subprocess.run( - ["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", + [exe_path, "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "8", *extra_hdr, url], - capture_output=True, text=True, timeout=15) + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, timeout=15) actual = int(out.stdout or "0") except Exception: actual = -1 @@ -1194,8 +1226,8 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: ) namespace_script = ( "mount --make-rprivate / && " - "mount --bind /proc/$$/exe /usr/bin/curl && " - "/usr/bin/curl -c '" + f"mount --bind /proc/$$/exe {shlex.quote(exe_path)} && " + f"{shlex.quote(exe_path)} -c '" f"exec 3<>/dev/tcp/{sc.target.host}/{sc.target.port}; " f"printf \"GET {request_path} HTTP/1.1\\r\\n" f"Host: {sc.target.host}\\r\\n{request_headers}" @@ -1206,7 +1238,8 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: result = subprocess.run( ["unshare", "--user", "--map-root-user", "--mount", "--", "bash", "-c", namespace_script], - capture_output=True, text=True, timeout=15) + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, timeout=15) if result.returncode != 0: record(f"C3/{sc.target.name}", "INFO", "user/mount namespace unavailable; " @@ -1217,7 +1250,7 @@ def run_scenario(sc: Scenario, poll_s: int) -> tuple[int, int]: record(f"C3/{sc.target.name}", "PASS" if ok else "FAIL", f"expected=403 actual={actual}; caller executable " - "bind-mounted over /usr/bin/curl") + f"bind-mounted over {exe_path}") passes += int(ok); fails += int(not ok) except (FileNotFoundError, subprocess.TimeoutExpired) as exc: record(f"C3/{sc.target.name}", "INFO", @@ -1235,9 +1268,17 @@ def main() -> int: "(default: all). Match is exact.") ap.add_argument("--poll", type=int, default=None, help="override poll interval seconds") + ap.add_argument("--s9-exe-path", default="/usr/bin/curl", + help="curl-compatible executable path used by S9/C3; place a copy " + "on a Btrfs subvolume to test filesystem identity handling") args = ap.parse_args() require_root() + s9_exe_path = Path(args.s9_exe_path) + if not s9_exe_path.is_absolute(): + ap.error("--s9-exe-path must be an absolute path") + if not s9_exe_path.is_file() or not os.access(str(s9_exe_path), os.X_OK): + ap.error(f"--s9-exe-path must be an executable file: {s9_exe_path}") target_names = (["imds", "wireserver"] if args.target == "both" else [args.target]) target_names = get_active_local_rule_targets(target_names) @@ -1250,7 +1291,7 @@ def main() -> int: f"current identity: user={identity['userName']} " f"group={identity['groupName']}") - targets: list[Target] = [] + targets: List[Target] = [] if "imds" in target_names: targets.append(Target("imds", IMDS_HOST, IMDS_PORT, IMDS_RULES_FILE)) if "wireserver" in target_names: @@ -1258,7 +1299,7 @@ def main() -> int: record("CFG", "INFO", f"targets: {', '.join(t.name for t in targets)}") - all_scenarios = build_scenarios(targets, identity) + all_scenarios = build_scenarios(targets, identity, str(s9_exe_path)) if args.scenarios: wanted = set(args.scenarios.split(",")) all_scenarios = [s for s in all_scenarios if s.sid in wanted] diff --git a/pentest/linux/test_catalog.py b/pentest/linux/test_catalog.py index 525c7ba8..8cebcdd0 100644 --- a/pentest/linux/test_catalog.py +++ b/pentest/linux/test_catalog.py @@ -6,25 +6,17 @@ "WS-S6-encoding-bypass/certs_dot_segments"). Helpers below normalize the noisy forms (anything in `[…]` or after `/`) to the base ID before lookup. """ -from __future__ import annotations - import re -from typing import Optional, TypedDict +from typing import Dict, List, Optional -class TestInfo(TypedDict, total=False): - title: str - design: str # what invariant is being checked - automation: str # how the harness exercises it (script lines) - repro_script: str # one-liner to reproduce via the harness - repro_manual: str # how to reproduce by hand (shell commands) - fix: str # suggested remediation when this test FAILs +TestInfo = Dict[str, str] # --------------------------------------------------------------------------- # Phase 2 — listener / DoS # --------------------------------------------------------------------------- -CATALOG: dict[str, TestInfo] = { +CATALOG: Dict[str, TestInfo] = { "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.", @@ -221,7 +213,7 @@ class TestInfo(TypedDict, total=False): # 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] = { +PHASE_4B: Dict[str, TestInfo] = { # ---------------- IMDS ---------------- "IMDS-S1-disabled-allow": { "title": "Control: mode=disabled + allow", @@ -421,5 +413,5 @@ def lookup(test_id: str) -> Optional[TestInfo]: return None -def all_known_ids() -> list[str]: +def all_known_ids() -> List[str]: return list(CATALOG.keys()) + list(PHASE_4B.keys()) diff --git a/proxy_agent/src/common.rs b/proxy_agent/src/common.rs index b7c0f208..b6370069 100644 --- a/proxy_agent/src/common.rs +++ b/proxy_agent/src/common.rs @@ -16,3 +16,6 @@ pub use windows::store_key_data; #[cfg(windows)] pub use windows::fetch_key_data; + +#[cfg(not(windows))] +pub mod linux; diff --git a/proxy_agent/src/common/linux.rs b/proxy_agent/src/common/linux.rs new file mode 100644 index 00000000..c964ac8f --- /dev/null +++ b/proxy_agent/src/common/linux.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation +// SPDX-License-Identifier: MIT + +/// Converts a `stat` `st_dev` (userspace encoding: glibc `makedev`) to the kernel-internal +/// `dev_t` encoding (`MKDEV`: major << 20 | minor) that `super_block->s_dev` holds and that +/// the eBPF program reports in `Claims::processExecutableDevice`. +pub(crate) fn kernel_dev_from_st_dev(st_dev: u64) -> u64 { + let major = u64::from(libc::major(st_dev)); + let minor = u64::from(libc::minor(st_dev)); + (major << 20) | minor +} + +/// Returns the `mnt_id` line value from a `/proc//fdinfo/` dump. +fn mount_id_from_fdinfo(fdinfo: &str) -> Option<&str> { + fdinfo + .lines() + .find_map(|line| line.strip_prefix("mnt_id:")) + .map(str::trim) +} + +/// Returns the superblock `dev_t` (kernel encoding) of the mount with `mount_id`. +/// Equals the eBPF `s_dev` on Ubuntu 6.8 Btrfs but not on SLES 15 SP4 Btrfs; see `Identity::is_match`. +fn sb_dev_from_mountinfo(mountinfo: &str, mount_id: &str) -> Option { + let line = mountinfo + .lines() + .find(|line| line.split_whitespace().next() == Some(mount_id))?; + let (major, minor) = line.split_whitespace().nth(2)?.split_once(':')?; + Some((major.parse::().ok()? << 20) | minor.parse::().ok()?) +} + +/// Superblock device of the filesystem holding `path`, as the eBPF hook reports it. +pub(crate) fn sb_dev_of_path(path: &str) -> Option { + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; + + // O_PATH never blocks on FIFOs or devices. + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open(path) + .ok()?; + let fdinfo = std::fs::read_to_string(format!("/proc/self/fdinfo/{}", file.as_raw_fd())).ok()?; + let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?; + sb_dev_from_mountinfo(&mountinfo, mount_id_from_fdinfo(&fdinfo)?) +} + +#[cfg(test)] +mod tests { + #[cfg(not(windows))] + #[test] + fn kernel_dev_from_st_dev_matches_kernel_encoding() { + // /dev/sda1 (8,1): stat st_dev is 0x801, kernel s_dev is 0x800001. + assert_eq!(super::kernel_dev_from_st_dev(0x801), 0x80_0001); + // Minor numbers above 255 are split across the userspace encoding. + let st_dev = libc::makedev(259, 0x1234); + assert_eq!( + super::kernel_dev_from_st_dev(st_dev), + (259u64 << 20) | 0x1234 + ); + } + + #[cfg(not(windows))] + #[test] + fn sb_dev_lookup_uses_mountinfo_not_stat_dev() { + let mountinfo = "26 1 0:49 / /mnt/btrfs-test rw,relatime shared:1 - btrfs /dev/loop0 rw,space_cache=v2,subvolid=5,subvol=/\n\ + 27 1 8:1 / / rw,relatime - ext4 /dev/sda1 rw\n"; + assert_eq!(super::sb_dev_from_mountinfo(mountinfo, "26"), Some(49)); + assert_eq!( + super::sb_dev_from_mountinfo(mountinfo, "27"), + Some((8u64 << 20) | 1) + ); + assert_eq!(super::sb_dev_from_mountinfo(mountinfo, "2"), None); + assert_eq!( + super::mount_id_from_fdinfo("pos:\t0\nflags:\t010000000\nmnt_id:\t26\n"), + Some("26") + ); + assert!(super::sb_dev_of_path("/proc/self/exe").is_some()); + assert!(super::sb_dev_of_path("/nonexistent/path").is_none()); + } +} diff --git a/proxy_agent/src/key_keeper/key.rs b/proxy_agent/src/key_keeper/key.rs index 65cb15b0..53a54db5 100644 --- a/proxy_agent/src/key_keeper/key.rs +++ b/proxy_agent/src/key_keeper/key.rs @@ -340,16 +340,6 @@ impl Clone for Identity { } } -/// Converts a `stat` `st_dev` (userspace encoding: glibc `makedev`) to the kernel-internal -/// `dev_t` encoding (`MKDEV`: major << 20 | minor) that `super_block->s_dev` holds and that -/// the eBPF program reports in `Claims::processExecutableDevice`. -#[cfg(not(windows))] -pub(crate) fn kernel_dev_from_st_dev(st_dev: u64) -> u64 { - let major = u64::from(libc::major(st_dev)); - let minor = u64::from(libc::minor(st_dev)); - (major << 20) | minor -} - impl Identity { pub fn is_match(&self, logger: &mut ConnectionLogger, claims: &Claims) -> bool { logger.write( @@ -433,14 +423,33 @@ impl Identity { // if its `/proc//exe` path text looks identical. // stat's st_dev uses the userspace encoding, but the eBPF value is the // kernel's internal s_dev, so re-encode before comparing. - if kernel_dev_from_st_dev(metadata.dev()) != claims.processExecutableDevice - || metadata.ino() != claims.processExecutableInode - { + // Btrfs `stat` reports a per-subvolume device (`btrfs_getattr` sets + // `stat->dev = root->anon_dev`), not the superblock `s_dev` the eBPF hook reads: + // https://github.com/torvalds/linux/blob/master/fs/btrfs/inode.c + // So fall back to mountinfo's device (verified on Ubuntu 6.8 Btrfs). Known limit: on + // SLES 15 SP4 Btrfs no userspace value equals the eBPF s_dev (eBPF 45, mountinfo 46, + // stat/maps 47), so exePath there fails closed (deny). + // More details: https://github.com/Azure/GuestProxyAgent/issues/440 + let stat_dev = crate::common::linux::kernel_dev_from_st_dev(metadata.dev()); + let sb_dev = if stat_dev == claims.processExecutableDevice { + None + } else { + crate::common::linux::sb_dev_of_path(exe_path) + }; + let device_matches = stat_dev == claims.processExecutableDevice + || sb_dev == Some(claims.processExecutableDevice); + if metadata.ino() != claims.processExecutableInode || !device_matches { logger.write( LoggerLevel::Trace, format!( - "Not matched executable identity '{}' from identity '{}'", - exe_path, self.name + "Not matched executable identity '{}' from identity '{}': kernel dev={} ino={}, file stat dev={} sb dev={:?} ino={}", + exe_path, + self.name, + claims.processExecutableDevice, + claims.processExecutableInode, + stat_dev, + sb_dev, + metadata.ino() ), ); return false; @@ -1770,19 +1779,6 @@ mod tests { ); } - #[cfg(not(windows))] - #[test] - fn kernel_dev_from_st_dev_matches_kernel_encoding() { - // /dev/sda1 (8,1): stat st_dev is 0x801, kernel s_dev is 0x800001. - assert_eq!(super::kernel_dev_from_st_dev(0x801), 0x80_0001); - // Minor numbers above 255 are split across the userspace encoding. - let st_dev = libc::makedev(259, 0x1234); - assert_eq!( - super::kernel_dev_from_st_dev(st_dev), - (259u64 << 20) | 0x1234 - ); - } - #[tokio::test] async fn test_identity_is_match() { #[cfg(not(windows))] @@ -1806,7 +1802,9 @@ mod tests { runAsElevated: true, processFullPath: current_exe, #[cfg(not(windows))] - processExecutableDevice: super::kernel_dev_from_st_dev(current_exe_metadata.dev()), + processExecutableDevice: crate::common::linux::kernel_dev_from_st_dev( + current_exe_metadata.dev(), + ), #[cfg(windows)] processExecutableDevice: 0, #[cfg(not(windows))] diff --git a/proxy_agent/src/proxy/authorization_rules.rs b/proxy_agent/src/proxy/authorization_rules.rs index b93ad31b..d1e31fe6 100644 --- a/proxy_agent/src/proxy/authorization_rules.rs +++ b/proxy_agent/src/proxy/authorization_rules.rs @@ -650,7 +650,7 @@ mod tests { use std::os::unix::fs::MetadataExt; let metadata = std::fs::metadata(¤t_exe).unwrap(); ( - crate::key_keeper::key::kernel_dev_from_st_dev(metadata.dev()), + crate::common::linux::kernel_dev_from_st_dev(metadata.dev()), metadata.ino(), ) }; From 5b70c73d76a5dd40bfa9f2c673a0c544e8789db2 Mon Sep 17 00:00:00 2001 From: Zhidong Peng Date: Thu, 15 Oct 2026 02:07:11 +0000 Subject: [PATCH 8/8] fix spell --- cspell.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cspell.json b/cspell.json index ee2d3ae9..e93709cf 100644 --- a/cspell.json +++ b/cspell.json @@ -457,6 +457,7 @@ "registrykey", "reimplementation", "rekor", + "relatime", "relativeurl", "Reprovisioning", "resf", @@ -534,6 +535,9 @@ "subsecond", "substatus", "Substatuses", + "subvol", + "subvolid", + "subvolume", "SUIDSGID", "suse", "Swatinem",