Post

Millennium Kombat

Challenge description

We have found an old video game console. There is a fighting game on it. The opponent seems to perform totally random actions. Can you beat it?

IP:port

Setup.sol:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {MillenniumKombat} from "./MK.sol";

contract Setup {
	address public player;
	MillenniumKombat public kombat;
	constructor (address _player) payable {
		player = _player;
		kombat = new MillenniumKombat();
	}
	function isSolved() public view returns (bool) {
		return kombat.successfulMoves() >= 100;
	}
}

MK.sol (extracted):

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
contract MillenniumKombat {
	bool public hasAttacked = false;
	uint private seed;
	uint public successfulMoves;

	mapping(Moves => Moves) public RPSchart;

	enum Moves {
		Grab,
		Attack, 
		Guard
	}

	constructor() payable {
		seed = block.prevrandao;
		RPSchart[Moves.Attack] = Moves.Grab;
		RPSchart[Moves.Grab] = Moves.Guard;
		RPSchart[Moves.Guard] = Moves.Attack;
	}
	
	modifier readyToAttack() {
		require(hasAttacked == false);
		_;
	}

	function fight(uint _move) public readyToAttack {
		require(_move < 3, "Choose between 0 (grab), 1 (Attack) and 2 (Guard)");
		hasAttacked = true;
		Moves opponentMove = choseNextMove();
		Moves playerMove = Moves(_move);
		if (RPSchart[playerMove] == opponentMove)
		{
			successfulMoves += 1;
		} else {
			successfulMoves = 0;
		}
		hasAttacked = false;
	}

	function choseNextMove() private returns (Moves) {
		Moves chosenMove = Moves(seed % 3);
		seed += uint160(address(this));
		return chosenMove;
	}
}

Solution

Calling nc IP port gives us 3 options: start an instance, kill an instance and get flag. After starting an instance, we write the given details into environment variables as such:

1
2
3
4
5
export UUID           =  some-hex-string
export RPC            =  http://IP:PORT/UUID
export PRIVATE_KEY    =  32-byte-address
export PLAYER_ADDRESS =  20-byte-address
export CHALLENGE      =  20-byte-address

Understand the contract

MillenniumKombat seems to be a game of rock-paper-scissors, RPSchart variable is a good giveaway. :) In short, RPSChart is a mapping between the player’s move and the move that it can win over. Just like rock > scissors. The seed is initialized randomly, readyToAttack() is a modifier that acts as a lock for the fight() function, which counts our successful moves and calls choseNextMove(). choseNextMove() is an interesting function, because here we can see the move is chosen as the remainder of seed divided by 3 and the seed is then incremented.

Solve the problem

What this means is that we only need to brute-force the first correct move! From then on, incrementing the winning move with every call will put us on a winning streak - literally. By retrying the same move we should get the first correct move in at most 3 tries.

Connect to the blockchain using the previously set environment variables:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from web3 import Web3
import time
import os

UUID = os.getenv('UUID')
RPC_URL = os.getenv('RPC')
PRIVATE_KEY = os.getenv('PRIVATE_KEY')
PLAYER_ADDRESS = os.getenv('PLAYER_ADDRESS')
CHALLENGE_ADDRESS = os.getenv('CHALLENGE')

# Insert contract ABIs here

w3 = Web3(Web3.HTTPProvider(RPC_URL))
assert w3.is_connected(), "RPC connection failed"
setup_contract = w3.eth.contract(address=CHALLENGE_ADDRESS, abi=SETUP_ABI)
contract = w3.eth.contract(address=CHALLENGE_ADDRESS, abi=MK_ABI)

Helper functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def send_fight(move):
    tx = contract.functions.fight(move).build_transaction({
        "from": PLAYER_ADDRESS,
        "nonce": w3.eth.get_transaction_count(PLAYER_ADDRESS),
        "gas": 200_000,
        "gasPrice": w3.eth.gas_price,
    })
    signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
    tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    w3.eth.wait_for_transaction_receipt(tx_hash)

def successful_moves():
    return contract.functions.successfulMoves().call()

def get_winning_move(opponent_move):
    if opponent_move == 0: 
        return 1 
    elif opponent_move == 1:  
        return 2
    else:
        return 0 

Brute force the first move (at most 3 rounds):

1
2
3
4
5
6
7
8
9
won = False
while not won:
    send_fight(0)
    
    if successful_moves() > 0:
        print("First win!")
        won = True
    else:
        print("Fail")

Infer the rest of the moves:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
last_move = 0
for i in range(100):
    current_move = (last_move + 1) % 3 # increment move to be played
    
    send_fight(current_move)
    sm = successful_moves()
    print(f"{i+1} current move: {current_move}, nr. of wins: {sm}")
    
    last_move = current_move
    
    if sm >= 100:
        break
    
    time.sleep(0.2)

Get the flag

Congratulations! You have solved it! Here’s the flag: HACKDAY{6954de044483cfd38491854328f846d6ac76005b8e1e459aa68dd4c443ceae14}