Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
"fallbacked",
"Fapi",
"Fbar",
"Fcomp",
"fde",
"fea",
"ffcecb",
Expand Down Expand Up @@ -401,6 +402,7 @@
"passwordless",
"pcap",
"pcapng",
"pcaps",
"pcr",
"PDBs",
"peekable",
Expand Down Expand Up @@ -469,6 +471,7 @@
"rockylinux",
"rolename",
"rootdir",
"rprivate",
"rpmbuild",
"RPMS",
"rstr",
Expand Down Expand Up @@ -527,6 +530,7 @@
"stdint",
"stdoutput",
"stduser",
"subshell",
"subsecond",
"substatus",
"Substatuses",
Expand Down
52 changes: 44 additions & 8 deletions linux-ebpf/ebpf_cgroup.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down
66 changes: 66 additions & 0 deletions linux-ebpf/socket.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -76,4 +106,40 @@ 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/<pid>/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;
};

#pragma clang attribute pop
3 changes: 1 addition & 2 deletions pentest/linux/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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/<pid>/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/<pid>/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. |
Expand Down Expand Up @@ -244,4 +244,3 @@ cargo install --locked httpx-cli # optional
```

Plus a small workspace under [pentest/](./) (PoCs, pcaps, harness scripts).

2 changes: 1 addition & 1 deletion pentest/linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
Loading
Loading