In the previous post, I described how we can exploit CVE-2026-1668 to gain arbitrary code execution. In this post, I go into the details of building a useful exploit payload.

The final proof-of-concept exploit code is available at https://github.com/tangrs/cve-2026-1668-poc.

Popping a shell

The most common exploit payload is one that somehow gives the attacker interactive shell access to the vulnerable system (commonly known as shellcode). When exploiting remote systems, the most basic form of such a payload on Linux looks something like:

  1. Somehow obtain a TCP socket that is connected to the attacker’s machine.
  2. Redirect stdin, stdout and stderr (file descriptors 0, 1 and 2) to the socket using dup2.
  3. Exec a shell binary in interactive mode (i.e. /bin/sh -i).

There are two approaches for obtaining a socket to the attacker’s machine:

  • Listen for a TCP connection on an unused port, and wait for the attacker to connect to it.
  • Make a TCP connection to an attacker controlled IP address (i.e. a reverse-shell).
    • This is useful in cases where there are firewalls preventing inbound connections to the vulnerable system, but allows outgoing ones.

The attacker can use netcat to either connect to or accept a TCP connection from the remote end and interact with the remote shell.

For the proof-of-concept, I chose to implement the former approach.

Payload crafting

The proof-of-concept payload has the following logical components:

  • The data that we need to overwrite the connection state-tracking data in memory to trigger the exploit.
  • The actual MIPS instructions (i.e. the shellcode) that will be executed upon triggering the exploit.
  • Any static data that the shellcode needs.

While it’s possible to craft the payload entirely in a hex editor, doing so is incredibly time-consuming and error-prone. We can do much better by making good use of the GCC toolchain.

Using the assembler to encode data

The initial part of the payload are various bits of data that overwrite the necessary structures in memory. While we could hand-craft this in a hex editor, it’s much neater and maintainable to encode this in assembler for many reasons:

  • We can have code comments that explain what each piece of data is doing.
  • We can use assembler macros to generate data (e.g. generate padding with .fill).
  • We can use linker symbols and expressions to avoid needing to calculate memory addresses by hand (e.g. .word start instead of .word 0x2c119c60 to refer to the address of the symbol start).

We start the file off with .section .data.head to tell the assembler to place the following assembly into a ELF section called .data.head. This allows us to place the data at the start of the final payload as defined in the linker script (described later).

The assembly may contain .word directives to place hard-coded integers/symbol addresses, or it can contain MIPS instructions. It’s important to be aware that the assembler synthesizes branch delay slots by default, so some MIPS instructions may actually end up being two instructions in the final output.

Developing the shellcode

At this point, we can simply define a start label and write the entire shellcode in MIPS assembly:

.global start
start:
# ...

At a high-level, our shellcode needs to make the following syscalls:

  • socket to create a TCP socket.
  • bind to an address (in our proof-of-concept, we bind to 0.0.0.0:8888).
  • listen for connections.
  • accept a connection.
  • dup2 each of file descriptors 0, 1, and 2 to the new connection socket file descriptor.
  • execve /bin/sh -i to actually pop the shell.

The process for making a syscall under Linux for MIPS is summarized as follows:

  1. Set v0 to the requested syscall number.
  2. Up to 4 arguments are passed through registers a0 - a3.
  3. Execute the syscall instruction.
  4. The return value is stored in v0. If a3 is non-zero, an error has occurred, and v0 contains the error number.

System call numbers can be found in the unistd.h Linux kernel UAPI header.

Developing the shellcode in C

While handcrafted assembly can be nice and compact, it would be neat if we could develop the shellcode in a higher-level language like C.

For this to work, the C compiler requires that, upon entry to a function, the stack pointer is correctly initialised, and that code calling the C functions obey the MIPS calling convention. Fortunately, our code is being called from the HTTP event handler as if it were a normal C function call, so the execution environment is already amenable to running C. This means our entry point can simply be a normal C function.

The major caveat here is that we mustn’t call any functions in the standard library that may come with our toolchain. We can’t assume that the functions from the toolchain’s standard library will work in the target process, since the functions’ implementations are often tightly coupled to the runtime environment provided by the toolchain’s startup code. To prevent accidentally including unwanted dependencies (e.g. the toolchain’s startup code), code should be compiled with the following flags: -nostdlib -ffreestanding.

The most straightforward and least-dependencies approach is to implement every single function that we need ourselves. We can perform syscalls using inline assembly, and structure definitions can be declared inline (or with very careful use of the Linux kernel UAPI headers described above).

Alternatively, if memory addresses of functions in the target process address space is known, we can use static function pointers to call functions in libraries that are already loaded in the target process. For example, if the address of execv is known to be at 0x0000c200, we can define a wrapper function as:

static inline int execv(char *path, char *argv[]) {
    return ((int (*)(char *, char *[]))0x0000c200)(path, argv);
}

Making use of linker scripts

We can use linker scripts to control the placement of all the components that will appear in the final payload. We also specify where in memory the start of the payload will be loaded (i.e. 0x2c10e154) so the linker can calculate absolute memory addresses for us correctly.

SECTIONS
{
	.data 0x2c10e154 : {
		*(.data.head)

		/* ... */

The *(.data.head) line means that the .data.head section must appear as the first thing in our final payload. Some other sections must be added to support running C code, but their placement is otherwise unimportant in this particular case.

We also discard all unnecessary sections (e.g. automatically generated debug information, or build IDs), since we don’t want them appearing in our final output.

	/DISCARD/ : { *(*) }
}

Testing the payload

To test the payload without having to perform an entire exploitation cycle, we can compile a very minimal executable containing only the payload that can be run under QEMU user-space emulation on the host: mips-linux-gnu-gcc -O3 payload.c -e start -nostdlib -ffreestanding -o test

Previewing the final output

The output of all of our compilation and linking is an ELF file. We can use mips-linux-gnu-objdump -D input.elf to view the final disassembly of our payload. We should expect that the memory addresses match where we want all of our data and code to be placed in the target process.

Dumping the raw memory image

We can convert our output ELF file into a raw memory image using: mips-linux-gnu-objcopy -O binary input.elf output.bin.

Conclusion

With our final payload, we can perform the exploit, connect to the socket listener that we opened in the shellcode, and be greeted with a root shell:

/bin/sh: can't access tty; job control turned off
/etc # busybox id
uid=0(root) gid=0(root)
/etc #