Puzzhole Solution Explained

0 138
Avatar for dankwing.duck
3 years ago

When I first wrote Puzzhole I was confident that it would get solved, but I had no idea how long until it would get solved, especially considering I wasn't able to fund it with a large prize pool. Immediately after it went live I noticed that some users began to say that it might not be solvable and I was beginning to get worried.

But then Reddit user -johoe posted that he had solved it at the same time that half of the funds of the wallet were removed! Congratulations! You guys probably already know him from his well known tool: Johoe's Bitcoin Mempool Size Statistics.

Also nice of him to leave half the prize pool for future solvers. Classy! 🍷(Upon request he later swept the remaining before I published this article)

The Solution

Firstly, here is the original puzzle image again:

Original puzzle image

Discovering the Symbols

As you can see the image is composed of many different symbols. If you break them down you'll find there are 8 symbols in total:

The eight different symbols used in the puzzle

Each symbol is about how many segments (or "holes") there are. A zero is such because it has no segments or holes in it, a one is such because it has one hole in the triangle, two because it has two holes in the figure eight and so on. When I asked Johoe about how he discovered this and the process of figuring it out he said:

Counting gave me 8 different symbols. I first tried to count strokes but didn't work out (not even with counting loops as two strokes; no 7 strokes), then counting the regions worked out. Only after I solved it, I noticed the puzzhole title.

Once you understand that system you can take all of the symbols and put them into some kind of variable:

glyphs = "45567212625722..."

Turning Symbols into Bits

Next you need to turn those various numbers into bits. Since there are 8 symbols, that's 3-bits per symbol since log2(8) == 3:

for n in glyphs:
    word = bin(int(n))[2:].rjust(3, '0')
    bits += word

Now you have a large binary string:

10010110111011101000101011001010111...

Turning Bits into ASCII private key

Lastly you can turn all of those bits into a private key:

key_bytes = []

for n in range(0, len(bits), 8):
    word = int(bits[n:n+7], 2)
    key_bytes.append(word)

print(bytearray(key_bytes).decode('ascii', 'ignore'))

This will leave you with the private key in WIF format:

KwEezDy4Hjr...

And you're done! I asked Johoe to share his script he used to solve the puzzle and he graciously did. It's written in Perl and a bit of a brain twister, but here it is:

@lines = qw(
45567212
...
454
);

my @bin = ('000','001','010','011', '100','101','110','111');

while(<>) {
    s/[0-7]/$bin[$&]/g;
    $j = 0;
    
    for $i (0..24) {
      $_ =~ /^([01])(.*)$/;
      $bit = int($1);
      $_ = $2;
      $j |= ($bit << (7-($i & 7)));

      if ($i % 8 == 7) {
          print chr($j/2);
          $j=0;
      }
    }
}

His code works around some oddities that were created as a byproduct of how I encoded all the bits in Python. Sorry for that!

Questions for Johoe

I asked Johoe why he gave the puzzle a try whereas it seemed some other people got caught up on trust issues and labeled it a scam, he said:

I like puzzles, so I thought I try a few minutes to see if I can make progress. And that worked out well here.

When asked about why he only took half of the prize pool:

I wanted to give others a chance to try to solve this puzzle. I didn't do it for the money in the first place.

Did the rotation have anything to do with the solution?

No, it was a red herring. It simply looked better with a random rotation applied to each symbol. Here is what the puzzle looks like without the rotation:

Puzzle image without any rotation on symbols

Where are the scripts that were originally used to generate the puzzle?

Here is the original script that generates the puzzle image from a private key:

from math import sqrt, ceil
from PIL import Image
from random import randrange
import sys

private_key = 'REDACTED WIF key'
key_bytes = private_key.encode('ascii')
bits = bin(int.from_bytes(key_bytes, byteorder='big'))

if bits[0:2] != "0b":
    print("Expected 0b at start of bit string")
    sys.exit(1)

bits = bits[2:]
print("bits", bits)

total_bytes = len(key_bytes)
total_bits = len(bits)
total_glyphs = ceil(total_bits / 3)
grid_size = ceil(sqrt(total_glyphs))

print("key is", total_bytes, "bytes")
print("key is", total_bits, "bits")
print("key has", total_glyphs, "glyphs")
print("grid size is", grid_size)

# Pad bits
bits = bits + "000"

col = 0
row = 0

width = grid_size * 100
height = grid_size * 100

img = Image.new(mode="RGBA", size = (width, height), color='#fff')
glyphs = {}
glyph_encoding = ""

for i in range(0, 8):
    glyphs[i] = Image.open("puzzhole_images/" + str(i) + ".png")

print("bits", bits)

col = 0
row = 0

for i in range(0, total_bits, 3):
    n = int(bits[i + 2]) | (int(bits[i + 1]) << 1) | (int(bits[i]) << 2)
    glyph_encoding += str(n)
    g = glyphs[n]
    g = g.rotate(randrange(0, 360))

    img.paste(g, (col * 100, row * 100))
    col += 1

    if col >= grid_size:
        col = 0
        row += 1

print("glphs", glyph_encoding)

img.save('puzzhole_tmp.png')
# img.show()

And here is the test solver Python script:


glyphs = "4556..."
bits = ""

for n in glyphs:
    word = bin(int(n))[2:].rjust(3, '0')
    bits += word

bits += ""
print("bits", bits)

# If testing bits just paste them here and uncomment
#bits = "10010..."
key_bytes = []

for n in range(0, len(bits), 8):
    word = int(bits[n:n+7], 2)
    key_bytes.append(word)

print(bytearray(key_bytes).decode('ascii', 'ignore'))

1
$ 0.48
$ 0.48 from @TheRandomRewarder
Avatar for dankwing.duck
3 years ago

Comments