Cr0wn_Gh0ul’s Point puzzle write-up

pogo
7 min readMay 5, 2020

--

Welcome to the write-up to Cr0wn_Gh0ul’s Point puzzle. The puzzle was published on Twitter and the Crypto_puzzles discord (you can find a link to that at the end of the story), with the actual image being hosted on Cr0wn’s website, https://storage.cr0wngh0ul.org/Point.png. The puzzle’s prize was 0.1 ETH in an undisclosed address (since it was part of the puzzle). As expected, opening the image in a browser doesn’t show anything interesting, so we start by downloading the image.

Let’s dig in.

Making sense of the “point”

The first part was quite technical. While opening the image in an image viewer shows a single black point, the file size is over 500 kb. So there’s obviously some extra data hidden in there. Opening the image in a file viewer like Notepad++ and looking at the IDAT chunk (where we expect there to be very little data, only a few bytes, for a 1x1 image) we can see that it’s huge. So there must be much more data in the image, and the image size as reported by the IHDR chunk is incorrect. There’s no additional trailer data or much interesting stuff in the other chunks.

Now, there’s one easy way to confirm that the size reported by the IHDR chunk differs from the real data, and that’s using PNG Analyzer. And it will also come in handy later for crc32 repairs. One of the options that this tool has is “Check image size”, which does exactly the check from above. In this case it kind of bugs out but clearly shows that something is fishy.

PNG Analyzer output for Point.png

So the first thing we have to do is to determine the real image size (Width x Height) and replace the values in the IHDR. Data in a PNG image is zlib compressed and split into multiple chunks. In order to decompress the data we need to gather up all the IDAT chunks join them, deleting the “IDAT” tag together with the 8 bytes preceding the tag (which is the crc32 of the previous chunk + length of following chunk). Anyway, I already had my own png viewer written in Matlab which I used to recover the entire zlib stream. I exported the stream and opened it in python to decompress and analyze it. It turns out that from a compressed size of about 500 kB, the decompressed output has more than 500 MB! More precisely, 520073970 bytes. So there must be a looootta zeroes in there. Telling python to show only values that are non-zero in the data and their position, the first three indices it showed were: (1,0), (2,131001), (1,262002). A PNG row of values begins with a byte for the Filter Type to be used on that row. So a good assumption is that the values I was finding were the Filter Types for the rows, meaning that one row should be 1 byte for Filter + 131000 pixel values. That means there are 520073970 / 131001 = 3970 rows (the height). But the IHDR also tells us that the bitdepth is 8 and the Colortype is 6 (RGBA) so that means the real number of pixels in one row is 131000/4=37500 (the width). Great! Now, you can overwrite the IHDR with the new values (converted to hex) and then load the file in PNG Analyzer again and use the Verify All CRCs option to rebuild the CRC’s. And the 37500 x 3970 can finally be open in an image viewer of your choice!

Extracting the data

Opening the image reveals a lot of black pixels and on close inspection, a handful of non-black ones. A small area of interest is the top-left 256x256 square.

Looking closely at the pixel values will show that the there’s a pixel on the 2nd row that has color FFFFFF, a pixel on the 3rd row that has FEFFFF, one on the 5th row with FDFFFF, etc. Writing a script to analyze the data will show that:

  • there is at most one pixel per row
  • rows where pixels exist are prime numbers (2,3,5,7…) which doesn’t give us much data
  • the color code of each pixel decreases FFFFFF -> 00FFFF -> 0000FF -> 0000D9 for a total of 549 non-black pixels, giving us an order for the pixels (top to bottom)
  • the column where the pixels occur jumps a lot in a somewhat random manner, meaning that the column index is where the real “data” is stored
The entire image rescaled to fit a single screen, with the non-zero pixels marked with x. The Y axis labels are reversed because I was too lazy to fix that.

As can be seen above, the entire image itself doesn’t look like much. It took a while to figure out how to decrypt the data, but Randomiser finally figured it out how to extract the data: for each pixel, from top to bottom, XOR the row and column indices of their occurence. This reveals the message (with spaces added for visibility)

pragma solidity^0.6.0;import{!} from "./!.sol"; //0x6dd0de8217fe67382fdfa0f72eea4cff674c3814 0x2ae0783dcontract Soulve{
/*some big hex strings*/
bytes ?=hex"86E996013E77C41699000E0941D480C046B2F71A4F95B350AC1A4D426372923D8A4561D96FBFB0240595907201AD3225CF6EDED7DE02D91C386FFAC280B732EE4C9C0042007AF5E6D42D8960F00E716A8801A37FC23EA0E7ED4BE6CE248996EF61EF6A1F936B47A101EA5BC3C2467938BD4D3CDB3B2F5CB8FEA75665BF6D4195";
bytes ??=hex"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010001";
/*yeet*/
function soulve(bytes memory _???,bytes memory _????) public payable{
/*o.O*/
require( keccak256(abi.encodePacked(_???)) == keccak256(abi.encodePacked( keccak256(abi.encodePacked(msg.sender)) )),'0xD15EA5E');
/*ez*/
uint !!! = !.!!(_???,_????,??,?);
/*soulved?*/
require(!!!==0,'0xD15EA5E');
/*swag*/
msg.sender.transfer(address(this).balance);
}
/*load the loot*/
receive() externalpayable{}
} /*contract*/0x910EF5d8c822EEadcA68B4f82bbFd35Ac47E49C9

An Ethereum contract where the variables got replaced with !,?. And the address where this contract was deployed.

Putting the crypto- in cryptopuzzles

In the decoded pseudo-source, there are a few things of interest:

/*some big hex strings*/
bytes ?=hex"86E996013E77C41699000E0941D480C046B2F71A4F95B350AC1A4D426372923D8A4561D96FBFB0240595907201AD3225CF6EDED7DE02D91C386FFAC280B732EE4C9C0042007AF5E6D42D8960F00E716A8801A37FC23EA0E7ED4BE6CE248996EF61EF6A1F936B47A101EA5BC3C2467938BD4D3CDB3B2F5CB8FEA75665BF6D4195";
bytes ??=hex"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010001";

These storage variables seem important, definitely useful later…

function soulve(bytes memory _???,bytes memory _????) 

The main function has 2 arguments…

require( keccak256(abi.encodePacked(_???)) == keccak256(abi.encodePacked(  keccak256(abi.encodePacked(msg.sender))  )),'0xD15EA5E');

The keccak hash of the first argument has to be equal to the sender address hashed twice. In other words, the first argument has to be the hash of the sender address. Easy enough.

uint !!! = !.!!(_???,_????,??,?);

This is a delegate call to a function that resides in another contract, taking as inputs the 2 function arguments as well as the two storage variables. We don’t know the contract address or the function name, but if we look at the current contract’s bytecode, we can find them.

Going to https://etherscan.io/address/0x910EF5d8c822EEadcA68B4f82bbFd35Ac47E49C9#code , under the Contract tab, we can switch to Opcodes view and about halfway down we can see these 2 lines

PUSH20 0x6dd0de8217fe67382fdfa0f72eea4cff674c3814
PUSH4 0x2ae0783d

which give us the contract and the function name (hashed). Unfortunately, looking at a decompilation (like here: https://www.contract-library.com/contracts/Ethereum/0x6dd0de8217fe67382fdfa0f72eea4cff674c3814 ) of that function does not help by much unless you’re a god at reversing decompiled EVM code or have way too much time on your hands.

Instead, the idea of this step was to look at the storage variables and understand what’s going on. Luckily, Robin_Jadoul did figure that out! The 2nd variable,0x10001, is a common public exponent (E) used in RSA. Which would mean that the first variable would be the modulus (N). So the function call !.!!(_???,_????,??,?) would then have to be a validation function, validate(_message, _signature, exponent, modulus). So that would mean that we need to sign our message (which we know is the hash of our address) for which we need the private key. Normally, we couldn’t find that out from just N and E, but there are attacks that can recover certain weak public key factorizations, and since this is a puzzle…

A great tool to perform attacks starting from N and E is RsaCtfTool. Using it, we can obtain the private key

-----BEGIN RSA PRIVATE KEY----- MIICXgIBAAKBgQCG6ZYBPnfEFpkADglB1IDARrL3Gk+Vs1CsGk1CY3KSPYpFYdlv v7AkBZWQcgGtMiXPbt7X3gLZHDhv+sKAtzLuTJwAQgB69ebULYlg8A5xaogBo3/C PqDn7UvmziSJlu9h72ofk2tHoQHqW8PCRnk4vU082zsvXLj+p1Zlv21BlQIDAQAB AoGAet8zJ17lZUnPfyVJeRM5T+UUCcmEwirWRmiOBPDd4CL8U8SSbByBJ82OMkgj DsKlfQ7VFnW00lfJbvNLQj+Xue63JxGXzZJF6CEmZCfjJNcjhU5WwGSQhtxvdnO8 SXXvsZ+PyzEKnzUC/UJaamSeWvapRefAyXCRwaz/uh4oXgECQQC518i7Ibrvzyis 9DvqX6ZhdVnjf0Xn7uFSUNEv25254VrzG0DaGRvw0hmkI3TIf2qs8PILMfQ3jM2y knHXKhaxAkEAudfIuyG6788orPQ76l+mYXVZ439F5+7hUlDRL9udueFa8xtA2hkb 8NIZpCN0yH9qrPDyCzH0N4zNspJx1yoaJQJBAJcUg00OCMi3opuoGaVZiQsluaOm bhA1NNwUc1rysPDR8Xw9JaWoT/yg8NNtN51faDubzUmonJ8kSnznbMC8qKECQQCD fnXuSoB9m8N5FNqsC/+qp6DxgiVRZUmSt9I7nZXtZtG2f8sURn3pmI9B/0BreRRe x6FLYI4fHAaTWmEoUAbtAkEAhy12ftkdHQ5IvFrFpPmzOrnY1v8Jb6cYT0bygqp1 VRrwx2r9pr9wtBcVi/OQT89byIIU1cEBRoX7jldoPBIpaQ==
-----END RSA PRIVATE KEY-----

With this we can now sign our message using openssl and then we have all the data required to send a valid transaction and solve the puzzle.

This was the first time I used Node.js to interact with a Smart Contract so there were a few details I was not clear on. Cr0wn helped me with this part (since it was not puzzle related), so I thank him for that! Here’s the script I ended up using (which is worth posting because it can be adapted to other smart contracts):

const Web3 = require("web3");var msg = "0x7eeff479b292b22e03afce25b0307af72018544f6959a368c3da16de239a0518"; //keccak256 of account.address
var sig = "0x51ed39ed5b06cf4315fae3ae237aa32692b57a4ffe4ed32556116913a2009f9c6b7e9a23c9c403bd221c86971f4a396df95657a418142991a24c4aa95359b55ea9cbffae160c839c8ca136321b7151f08c9389c831f5b9a10303d3a1dbd079785b62b417b25c636de9d14063d07634207ae130869f9359dc55849aea8912eaa2";
var address = "0x910ef5d8c822eeadca68b4f82bbfd35ac47e49c9";
var abi = [{"stateMutability":"payable","type":"receive","payable":true},{"inputs":[{"internalType":"bytes","name":"_MESSAGE","type":"bytes"},{"internalType":"bytes","name":"_SIGNATURE","type":"bytes"}],"name":"soulve","outputs":[],"stateMutability":"payable","type":"function","payable":true}]
async function yeet(){
var web3 = new Web3(new Web3.providers.HttpProvider("https://mainnet.infura.io/v3/[REDACTED]"));
const account = web3.eth.accounts.privateKeyToAccount("0x[REDACTED]");
web3.eth.accounts.wallet.add(account);
web3.eth.defaultAccount = account.address;

let contract = new web3.eth.Contract(abi, address);

try {
let gasEst = await contract.methods.soulve(msg, sig).estimateGas({from: account.address});
await contract.methods.soulve(msg, sig).send({from: account.address, gas: gasEst});
} catch(ex) {
console.log(ex);
process.exit();
}

}

yeet();

Acknowledgements

Big thanks to both Randomiser and Robin_Jadoul for, well, carrying me through the hard parts of this puzzle. Also, big thanks to Cr0wn_Gh0ul and RichGirlOnLSD for helping me understand how to interact with smart contracts.

Finally, thank you Cr0wn_Gh0ul for this cool tricky and quite technical cryptopuzzle!

For some great and active puzzle-filled communities, check out:

Crypto_puzzles: https://discord.gg/bSn85h5

Neon District: https://discord.gg/RB7pKHz

ARG Solving Station: https://discord.gg/uYAXsww

--

--

Responses (2)