Sudoku Writeup Web3 Midnight CTF 2025
Introduction
The Sublocku challenge shows how Sudoku validation works on the Ethereum blockchain. This challenge mixes blockchain development, storage handling, and puzzle-solving code. What makes it cool is how it uses Ethereum’s storage patterns to run a game.
The contract:
// 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){
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;
}
}
Looking at the Code
Main Contract (Sublocku.sol)
Let’s check out the main parts of the contract:
contract Sublocku {
uint private size;
uint256[][] private game;
bool public isSolved = false;
address public owner;
address public lastSolver;
}
The contract uses a two-dimensional array (uint256[][]
) for the game board, which creates interesting storage patterns on the blockchain. Let’s look at the key parts:
How it Checks Sudoku Rules
The contract has three types of checks, each handling a different part of Sudoku rules:
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;
}
This row checking function is smart because:
- It uses the
available
array like a checklist - It makes sure numbers are valid (
val <= 0 || val > size
) - It makes sure no number appears twice
- It checks that all numbers 1-9 are used by checking the sum
Reading the Storage (GetSublocku.s.sol)
First, we need to see what the grid looks like, so we create a contract for that:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Script.sol";
import "forge-std/console.sol";
contract GetSublocku is Script {
address public challenge = 0x5FbDB2315678afecb367f032d93F642f64180aa3;
function run() external view {
// Get the size - slot 0
bytes32 sizeData = vm.load(challenge, bytes32(uint256(0)));
uint256 size = uint256(sizeData);
console.log("Game size:", size);
// The game variable starts at slot 1
// For dynamic arrays of arrays, the slot holds the length of the outer array
bytes32 gameDataLength = vm.load(challenge, bytes32(uint256(1)));
console.log("External array length:", uint256(gameDataLength));
// The hash of this slot contains the location of the actual array
bytes32 location = keccak256(abi.encode(1));
// Show the grid
console.log("Initial game grid:");
for (uint i = 0; i < size; i++) {
// For each row, first get the length
bytes32 rowLocation = bytes32(uint256(location) + i);
bytes32 rowDataLocation = keccak256(abi.encode(rowLocation));
string memory rowStr = "";
for (uint j = 0; j < size; j++) {
bytes32 cellData = vm.load(challenge, bytes32(uint256(rowDataLocation) + j));
uint256 cellValue = uint256(cellData);
if (j > 0) rowStr = string(abi.encodePacked(rowStr, ", "));
rowStr = string(abi.encodePacked(rowStr, vm.toString(cellValue)));
}
console.log("[%s]", rowStr);
}
}
}
The grid doesn’t change between runs, so we can hardcode it in the solver script.
This storage reading script shows how Ethereum stores data:
contract GetSublocku is Script {
address public challenge = 0x5FbDB2315678afecb367f032d93F642f64180aa3;
function run() external view {
bytes32 sizeData = vm.load(challenge, bytes32(uint256(0)));
uint256 size = uint256(sizeData);
bytes32 gameDataLength = vm.load(challenge, bytes32(uint256(1)));
bytes32 location = keccak256(abi.encode(1));
This script shows advanced blockchain concepts:
- Reading storage slots directly with
vm.load
- Understanding how dynamic arrays are stored
- Using
keccak256
to find storage locations
The coolest part is how it rebuilds the grid:
for (uint i = 0; i < size; i++) {
bytes32 rowLocation = bytes32(uint256(location) + i);
bytes32 rowDataLocation = keccak256(abi.encode(rowLocation));
string memory rowStr = "";
for (uint j = 0; j < size; j++) {
bytes32 cellData = vm.load(challenge, bytes32(uint256(rowDataLocation) + j));
uint256 cellValue = uint256(cellData);
This shows how nested arrays are stored in Solidity:
- The length of the outer array is at slot 1
- The actual data location is found using
keccak256
- Each inner array has its own storage pattern
Solver Code (SolveSublocku.s.sol)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Script.sol";
import "forge-std/console.sol";
interface ISublocku {
function unlock(uint256[][] memory solve) external;
function isSolved() external view returns (bool);
function lastSolver() external view returns (address);
}
contract SolveSublocku is Script {
address public challenge = 0x685215B6aD89715Ef72EfB820C13BFa8E024401a;
function run() external {
// Initialize the grid we found
uint256[][] memory grid = new uint256[][](9);
grid[0] = new uint256[](9);
grid[0][0] = 3; grid[0][1] = 1; grid[0][2] = 7; grid[0][3] = 4; grid[0][4] = 9;
grid[0][5] = 5; grid[0][6] = 0; grid[0][7] = 8; grid[0][8] = 2;
grid[1] = new uint256[](9);
grid[1][0] = 9; grid[1][1] = 2; grid[1][2] = 6; grid[1][3] = 3; grid[1][4] = 1;
grid[1][5] = 8; grid[1][6] = 7; grid[1][7] = 5; grid[1][8] = 4;
grid[2] = new uint256[](9);
grid[2][0] = 5; grid[2][1] = 4; grid[2][2] = 8; grid[2][3] = 7; grid[2][4] = 2;
grid[2][5] = 6; grid[2][6] = 3; grid[2][7] = 1; grid[2][8] = 9;
grid[3] = new uint256[](9);
grid[3][0] = 4; grid[3][1] = 3; grid[3][2] = 1; grid[3][3] = 8; grid[3][4] = 0;
grid[3][5] = 7; grid[3][6] = 9; grid[3][7] = 2; grid[3][8] = 6;
grid[4] = new uint256[](9);
grid[4][0] = 6; grid[4][1] = 9; grid[4][2] = 2; grid[4][3] = 1; grid[4][4] = 3;
grid[4][5] = 4; grid[4][6] = 8; grid[4][7] = 7; grid[4][8] = 5;
grid[5] = new uint256[](9);
grid[5][0] = 8; grid[5][1] = 7; grid[5][2] = 5; grid[5][3] = 9; grid[5][4] = 6;
grid[5][5] = 2; grid[5][6] = 4; grid[5][7] = 3; grid[5][8] = 1;
grid[6] = new uint256[](9);
grid[6][0] = 7; grid[6][1] = 8; grid[6][2] = 9; grid[6][3] = 5; grid[6][4] = 4;
grid[6][5] = 1; grid[6][6] = 2; grid[6][7] = 6; grid[6][8] = 3;
grid[7] = new uint256[](9);
grid[7][0] = 1; grid[7][1] = 0; grid[7][2] = 4; grid[7][3] = 2; grid[7][4] = 8;
grid[7][5] = 0; grid[7][6] = 5; grid[7][7] = 9; grid[7][8] = 7;
grid[8] = new uint256[](9);
grid[8][0] = 2; grid[8][1] = 5; grid[8][2] = 3; grid[8][3] = 6; grid[8][4] = 7;
grid[8][5] = 9; grid[8][6] = 1; grid[8][7] = 4; grid[8][8] = 8;
// Show the starting grid
console.log("Initial grid:");
printGrid(grid);
// Solve the Sudoku
uint256[][] memory solution = solveSudoku(grid);
// Show the solution
console.log("Solution:");
printGrid(solution);
// Send the solution to the contract
uint256 privateKey = 0x6f9b790c8f49db765ebf784eb62f17a2d5d5518bd7018544fcc9a42d80c5c3be;
vm.startBroadcast(privateKey);
ISublocku(challenge).unlock(solution);
bool solved = ISublocku(challenge).isSolved();
address lastSolver = ISublocku(challenge).lastSolver();
console.log("Challenge solved:", solved);
console.log("Last solver:", lastSolver);
vm.stopBroadcast();
}
function printGrid(uint256[][] memory grid) internal view {
for (uint i = 0; i < grid.length; i++) {
string memory row = "";
for (uint j = 0; j < grid[i].length; j++) {
if (j > 0) row = string(abi.encodePacked(row, " "));
row = string(abi.encodePacked(row, vm.toString(grid[i][j])));
}
console.log(row);
}
}
function solveSudoku(uint256[][] memory grid) internal pure returns (uint256[][] memory) {
uint256[][] memory solution = new uint256[][](9);
for (uint i = 0; i < 9; i++) {
solution[i] = new uint256[](9);
for (uint j = 0; j < 9; j++) {
solution[i][j] = grid[i][j];
}
}
solve(solution, 0, 0);
return solution;
}
function solve(uint256[][] memory grid, uint row, uint col) internal pure returns (bool) {
// If we reached the end of the grid, solution is found
if (row == 9) return true;
// Move to next row if we reached the end of the column
if (col == 9) return solve(grid, row + 1, 0);
// If cell is already filled, move to the next one
if (grid[row][col] != 0) return solve(grid, row, col + 1);
// Try each possible value
for (uint num = 1; num <= 9; num++) {
if (isValid(grid, row, col, num)) {
grid[row][col] = num;
// Recursion
if (solve(grid, row, col + 1)) {
return true;
}
// Backtracking if solution not found
grid[row][col] = 0;
}
}
return false;
}
function isValid(uint256[][] memory grid, uint row, uint col, uint num) internal pure returns (bool) {
// Check row
for (uint j = 0; j < 9; j++) {
if (grid[row][j] == num) return false;
}
// Check column
for (uint i = 0; i < 9; i++) {
if (grid[i][col] == num) return false;
}
// Check 3x3 square
uint startRow = (row / 3) * 3;
uint startCol = (col / 3) * 3;
for (uint i = 0; i < 3; i++) {
for (uint j = 0; j < 3; j++) {
if (grid[startRow + i][startCol + j] == num) return false;
}
}
return true;
}
}
The solver script uses a backtracking method:
function solve(uint256[][] memory grid, uint row, uint col) internal pure returns (bool) {
if (row == 9) return true;
if (col == 9) return solve(grid, row + 1, 0);
if (grid[row][col] != 0) return solve(grid, row, col + 1);
for (uint num = 1; num <= 9; num++) {
if (isValid(grid, row, col, num)) {
grid[row][col] = num;
if (solve(grid, row, col + 1)) {
return true;
}
grid[row][col] = 0;
}
}
return false;
}
This code is neat because:
- It uses recursion for backtracking
- It handles grid edges well
- It keeps existing numbers
- It properly backtracks by resetting failed attempts
How the Server Works
While our local testing used a grid with zeros, the actual server used a fixed grid with numbers. This matters because:
- It changed our testing approach
- It made our final solution simpler
- It made the dynamic solver less important
Because the server always gave us the same grid, we could optimize our solution. But it’s worth noting that the SolveSublocku.s.sol
script includes a full solver that could handle any valid Sudoku grid.
Understanding Storage Layout
Understanding how data is stored was key for this challenge. In Ethereum, storage slots are 32 bytes each, and arrays use a complex storage pattern:
- Slot 0: Contract size variable
- Slot 1: Length of the game array
- Other slots: Found using
keccak256
The 2D array follows this pattern:
1
2
3
4
5
slot[0] = size
slot[1] = length of outer array
slot[keccak256(1)] = length of first inner array
slot[keccak256(1) + 1] = first element of first inner array
...
Ways to Improve
While our solution worked for the challenge, we could make it better:
Better Solver
The current SolveSublocku.s.sol
has a complete solver, but we didn’t need to use it because the server always used the same grid. In a more changing environment, this solver would be valuable. The solver:
- Tries numbers 1-9 in each empty cell
- Checks each try against Sudoku rules
- Goes back when it hits a dead end
- Keeps going until it finds a valid solution
Speed Improvements
The current checking functions could be faster:
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();
This could be better by:
- Using bit tricks instead of arrays
- Combining checks
- Adding early exits
Conclusion
The Sublocku challenge shows how regular puzzles can work on a blockchain. It combines:
- Smart contract coding
- Storage patterns
- Algorithm design
- Speed improvements
While we used a simple solution because of how the server worked, the challenge shows you need to understand both theory (Sudoku algorithms) and practice (blockchain storage, gas saving) in smart contract development.
The best things we learned from this challenge were:
- How Ethereum stores data
- Creating check systems in smart contracts
- Balancing between nice code and practical speed
- Working with arrays in Solidity
- Reading contract storage directly
This challenge connects regular programming and blockchain development, teaching useful things about both.
Thanks for reading, see ya !