17 minute read

Continuing from this post: The Python Challenge (Levels 14-19)!

Continuing with the Python Challenge!

First, the auth tuple.

auth = ('butter', 'fly')

Level 20

http://butter:fly@www.pythonchallenge.com/pc/hex/idiot2.html

Title: go away!

unreal.jpg

but inspecting it carefully is allowed.

There is no further information whatsoever in the source. Nothing at all. In fact, here’s the entire source:

<html>
<head>
<title>go away!</title>
<link rel="stylesheet" type="text/css" href="../style.css">
</head>
<body>
    <br><br>
    <center>
    <font color="gold">
    <img src="unreal.jpg" border="0"/><br><br>
    but inspecting it carefully is allowed.
</body>
</html>

So yeah. Nothing. Let’s inspect the image then.

$ file unreal.jpg
unreal.jpg: JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72, segment length 16, Exif Standard: [TIFF image data, big-endian, direntries=0], baseline, precision 8, 290x478, components 3

Nothing weird at all. I’m quite very stuck.

I’m not gonna lie, this is when I Googled “pythonchallenge 20”. And the first result said that the image is a huge file and only a small portion is being served. OK!

Opening the “Network” panel of my devtools, indeed this is staring at me in “Response Headers” of the image file:

Content-Range: bytes 0-30202/2123456789

So what if we get more of the file?

Input:

import requests

r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': 'bytes=30203-2123456789'},
)
r.content

Output:

b"Why don't you respect my privacy?\n"

Because I’m doing the Python Challenge. I’m sorry…

Input:

curpos = 30203 + len(r.content)
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
curpos, r.content

Output:

(30237, b'we can go on in this way for really long time.\n')

Input:

curpos += len(r.content)
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
curpos, r.content

Output:

(30284, b'stop this!\n')

Input:

curpos += len(r.content)
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
curpos, r.content

Output:

(30295, b'invader! invader!\n')

Input:

curpos += len(r.content)
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
curpos, r.content

Output:

(30313, b'ok, invader. you are inside now. \n')

Input:

curpos += len(r.content)
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
curpos, r.content

Output:

(30347, b'')

Welp… What now?

Input:

r.headers

Output:

{'Content-type': 'text/html; charset=UTF-8', 'Content-Length': '0', 'Date': 'Sat, 27 Jan 2024 04:45:09 GMT', 'Server': 'lighttpd/1.4.55'}

Nothing in the headers… What about the previous request?

Input:

curpos = 30313
r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes={curpos}-2123456789'},
)
r.content, r.headers

Output:

(b'ok, invader. you are inside now. \n',
 {'Content-Type': 'application/octet-stream', 'Content-Transfer-Encoding': 'binary', 'Content-Range': 'bytes 30313-30346/2123456789', 'Content-Length': '34', 'Date': 'Sat, 27 Jan 2024 04:45:10 GMT', 'Server': 'lighttpd/1.4.55'})

Still nothing…

Looking at the end of the range again, it looks like a carefully crafted number (2 + 123456789), and it’s really close to 231 (2147483648). Maybe there’s something in the back?

Input:

r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes=2123456789-2147483648'},
)
r.content, r.headers

Output:

(b'esrever ni emankcin wen ruoy si drowssap eht\n',
 {'Content-Type': 'application/octet-stream', 'Content-Transfer-Encoding': 'binary', 'Content-Range': 'bytes 2123456744-2123456788/2123456789', 'Content-Length': '45', 'Date': 'Sat, 27 Jan 2024 04:45:11 GMT', 'Server': 'lighttpd/1.4.55'})

Oh that’s something…

Input:

r.text[::-1]

Output:

'\nthe password is your new nickname in reverse'

Well my new nickname is probably “invader”. So in reverse, that’s “redavni”. But redavni.html is a 404 error… Maybe there’s more data?

Input:

r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes=2123456743-'},
)
r.content, r.headers

Output:

(b'and it is hiding at 1152983631.\n',
 {'Content-Type': 'application/octet-stream', 'Content-Transfer-Encoding': 'binary', 'Content-Range': 'bytes 2123456712-2123456743/2123456789', 'Content-Length': '32', 'Date': 'Sat, 27 Jan 2024 04:45:11 GMT', 'Server': 'lighttpd/1.4.55'})

Great, another clue!

Input:

r = requests.get(
    'http://www.pythonchallenge.com/pc/hex/unreal.jpg',
    auth=auth,
    headers={'Range': f'bytes=1152983631-'},
)
r.content[:100], r.headers

Output:

(b'PK\x03\x04\x14\x00\t\x00\x08\x00;\xa7\xaa2\xac\xe5f\x14\xa9\x00\x00\x00\xd3\x00\x00\x00\n\x00\x15\x00readme.txtUT\t\x00\x03"\xf6\x80B\x19\xf7\x80BUx\x04\x00\xe8\x03\xe8\x03R\x1d^\xf1\xe5\xbf\xa3\xc2\xc0]\xc2)\xfd|\xdbC\x9b\xa5\xf6B\xc1j\x1c\x8cJ^6VE\x87\xcd\xaa\x1e\xf3\xd5P\xc4\xb5I',
 {'Content-Type': 'application/octet-stream', 'Content-Transfer-Encoding': 'binary', 'Content-Range': 'bytes 1152983631-1153223363/2123456789', 'Content-Length': '239733', 'Date': 'Sat, 27 Jan 2024 04:45:12 GMT', 'Server': 'lighttpd/1.4.55'})

Ah, the PK at the beginning means it’s a ZIP file!

Input:

import io
import zipfile

data = r.content
zf = zipfile.ZipFile(io.BytesIO(data))
zf.filelist

Output:

[<ZipInfo filename='readme.txt' compress_type=deflate filemode='-rw-r--r--' file_size=211 compress_size=169>,
 <ZipInfo filename='package.pack' compress_type=deflate filemode='-rw-r--r--' file_size=239194 compress_size=239246>]

Input:

zippwd = b'redavni'
with zf.open('readme.txt', pwd=zippwd) as f:
    print(f.read().decode())

Output:

Yes! This is really level 21 in here. 
And yes, After you solve it, you'll be in level 22!

Now for the level:

* We used to play this game when we were kids
* When I had no idea what to do, I looked backwards.

Annnnnnd hooray!

Level 21

http://butter:fly@www.pythonchallenge.com/pc/hex/unreal.jpg (Range: bytes=1152983631-)

ZIP password: redavni

Yes! This is really level 21 in here. And yes, After you solve it, you’ll be in level 22!

Now for the level:

  • We used to play this game when we were kids
  • When I had no idea what to do, I looked backwards.

First let’s display the contents of the ZIP file again:

Input:

zf.infolist()

Output:

[<ZipInfo filename='readme.txt' compress_type=deflate filemode='-rw-r--r--' file_size=211 compress_size=169>,
 <ZipInfo filename='package.pack' compress_type=deflate filemode='-rw-r--r--' file_size=239194 compress_size=239246>]

The text above is the contents of “readme.txt”. Now what’s this “package.pack”?

Input:

with zf.open('package.pack', pwd=zippwd) as f:
    print(f.read()[:50])

Output:

b'x\x9c\x00\n@\xf5\xbfx\x9c\x00\x07@\xf8\xbfx\x9c\x00\x06@\xf9\xbfx\x9c\x00\xff?\x00\xc0x\x9c\x00\xff?\x00\xc0x\x9c\x84vuT\x14N\xd46\xddH#\xad\x80'

I can’t tell by my naked eye what this is…

$ pip install python-magic

(You can skip this if python-magic is already installed)

Input:

import magic
with zf.open('package.pack', pwd=zippwd) as f:
    print(magic.from_buffer(f.read()))

Output:

zlib compressed data

“zlib compressed data”. OK let’s decompress it then!

Input:

import zlib
# should have done this long ago :/
with zf.open('package.pack', pwd=zippwd) as f:
    data = f.read()
data = zlib.decompress(data)
magic.from_buffer(data)

Output:

'zlib compressed data'

Another one?!

Input:

data = zlib.decompress(data)
magic.from_buffer(data)

Output:

'zlib compressed data'

Hmm ok, let’s loop it then!

Input:

while True:
    try:
        data = zlib.decompress(data)
    except:
        break
    print(len(data))

Output:

238951
238870
238789
238759

Input:

magic.from_buffer(data)

Output:

'bzip2 compressed data, block size = 900k'

OK, now bzip2? Sure:

Input:

import bz2
while True:
    try:
        data = bz2.decompress(data)
    except:
        break
    print(len(data))

Output:

237334
235892
234604

Input:

magic.from_buffer(data)

Output:

'zlib compressed data'

Uhhhh…

Input:

import bz2
while True:
    try:
        data = bz2.decompress(data)
    except:
        pass
    else:
        continue
    try:
        data = zlib.decompress(data)
    except:
        break

OK maybe this is finally it…?

Input:

magic.from_buffer(data)

Output:

'data'

Good! Let’s see what it is:

Input:

data[:100]

Output:

b'\x80\x8d\x96\xcb\xb5r\xa7\x00\x06Xz\xdafO\x19\xee\x84k\xa4dAB\xe1\x14\xc9]\xfc\xffT!\xd0\xce3f\xff\xdd\x89\xdd\xa5Y_\x85\x0c%[M8\x89U,\xb1\xd9g\xe1\x13\x04\x12\x16\x85u\xaep\xff\xd1\xb52\xeb\xaav\x11t\xe8\xd1\xdc\x043V\xd6s1\xc7\xa9\xe8\x91\x85\xd0\xdf\xf0s~^\xdb\xd9\xab\x08\\\x1a\x0fQ\xd1'

Welp, still garbage… What can it possibly be? I have no idea what to do.

Then I remembered that the question statement said: “When I had no idea what to do, I looked backwards.”

Aha, so can I reverse the bytestring?

Input:

last = True
data = data[::-1]
while True:
    try:
        data = bz2.decompress(data)
    except:
        pass
    else:
        last = False
        continue
    try:
        data = zlib.decompress(data)
    except:
        if last:
            break
        last = True
        data = data[::-1]
    else:
        last = False

17! I like it. Let’s view the string:

Input:

data

Output:

b'look at your logs'

Look at my… logs? I can only guess that it refers to my logs of decompressing again and again the data. So is the logs referring to the sequence of numbers?

Input:

# Reload the data from the ZIP archive
with zf.open('package.pack', pwd=zippwd) as f:
    data = f.read()

# Since I know the final string is 17B I can cheat a bit :D
lst = []
while True:
    try:
        data = bz2.decompress(data)
    except:
        try:
            data = zlib.decompress(data)
        except:
            data = data[::-1]
    lst.append(len(data))
    if len(data) == 17:
        break
len(lst), lst[:10], lst[-10:]

Output:

(741,
 [239113,
  239032,
  238951,
  238870,
  238789,
  238759,
  237334,
  235892,
  234604,
  234523],
 [253, 179, 168, 160, 149, 138, 127, 116, 53, 17])

OK, I don’t see anything special about these numbers. But wait… What if the “logs” refer to the decompression method used each time? That could make up a bitstring! Let’s see:

Input:

with zf.open('package.pack', pwd=zippwd) as f:
    data = f.read()

od = ''
while True:
    try:
        data = bz2.decompress(data)
        od += 'B'
    except:
        try:
            data = zlib.decompress(data)
            od += 'Z'
        except:
            data = data[::-1]
            od += 'R'
    if len(data) == 17:
        break
len(od), od

Output:

(741,
 'ZZZZZZBBBZZZZZZZZZZBBBZZZZZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBBBZZBBBBBBBBRZZZZBBBBBBBZZZZZZBBBBBBBZZZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBBRZZZBBZZZZZBBZZZZBBZZZZZBBZZZBBZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZBBRZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZBBRZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBZZZZBBBBBBBBBRZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBZRZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZBBZRZZZBBZZZZZBBZZZZBBZZZZZBBZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZBBZRZZZZBBBBBBBZZZZZZBBBBBBBZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBBBBBBBBZZZBBZZZZZBBZRZZZZZZBBBZZZZZZZZZZBBBZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBBBBBBBBBZZBBZZZZZZBB')

OK. Problem: 741 is not a multiple of 8, so it can’t be a series of bytes. What can it be then?

I noticed that there aren’t a lot of R’s. How many are there?

Input:

od.count('R')

Output:

9

Only 9! That could mean something. Let’s try splitting at the R’s:

Input:

od.split('R')

Output:

['ZZZZZZBBBZZZZZZZZZZBBBZZZZZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBBBZZBBBBBBBB',
 'ZZZZBBBBBBBZZZZZZBBBBBBBZZZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBB',
 'ZZZBBZZZZZBBZZZZBBZZZZZBBZZZBBZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZBB',
 'ZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZBB',
 'ZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBBBBBBBBZZZBBBBBBBBBZZZBBBBBBBBZZZZBBBBBBBBB',
 'ZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBZZZZBBBBBBBBZ',
 'ZZBBZZZZZZZZZZZBBZZZZZZZBBZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZBBZ',
 'ZZZBBZZZZZBBZZZZBBZZZZZBBZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBZZZZBBZ',
 'ZZZZBBBBBBBZZZZZZBBBBBBBZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBBBBBBBBZZZBBZZZZZBBZ',
 'ZZZZZZBBBZZZZZZZZZZBBBZZZZZZBBZZZZZZZZZZBBZZZZZZZZZZBBBBBBBBBBZZBBZZZZZZBB']

OK, because of the way my editor is set up, I can see something there. In case it doesn’t display clearly for some people, let me make it clearer:

Input:

lst = od.split('R')
trans = str.maketrans({'Z': ' ', 'B': '@'})
for item in lst:
    print(item.translate(trans))

Output:

      @@@          @@@      @@@@@@@@    @@@@@@@@    @@@@@@@@@@  @@@@@@@@
    @@@@@@@      @@@@@@@    @@@@@@@@@   @@@@@@@@@   @@@@@@@@@   @@@@@@@@@
   @@     @@    @@     @@   @@      @@  @@      @@  @@          @@      @@
  @@           @@       @@  @@      @@  @@      @@  @@          @@      @@
  @@           @@       @@  @@@@@@@@@   @@@@@@@@@   @@@@@@@@    @@@@@@@@@
  @@           @@       @@  @@@@@@@@    @@@@@@@@    @@@@@@@@    @@@@@@@@ 
  @@           @@       @@  @@          @@          @@          @@   @@ 
   @@     @@    @@     @@   @@          @@          @@          @@    @@ 
    @@@@@@@      @@@@@@@    @@          @@          @@@@@@@@@   @@     @@ 
      @@@          @@@      @@          @@          @@@@@@@@@@  @@      @@

Well it sure as heck says “COPPER”. So here we go!

Level 22

http://butter:fly@www.pythonchallenge.com/pc/hex/copper.html

Title: emulate

level22.jpg

In the source:

<!-- or maybe white.gif would be more bright-->

OK let’s take a look!

white.gif

That is NOT bright AT ALL. Well let’s analyze it anyway!

Input:

import io
import requests
from PIL import Image

r = requests.get('http://www.pythonchallenge.com/pc/hex/white.gif', auth=auth)
im = Image.open(io.BytesIO(r.content))
repr(im)

Output:

'<PIL.GifImagePlugin.GifImageFile image mode=P size=200x200 at 0x7FBE880BD550>'

Input:

px = []
for i in range(im.height):
    row = []
    for j in range(im.width):
        row.append(im.getpixel((j, i)))
    px.append(row)
px[0][:20]

Output:

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

At first glance it looks like it’s all zeroes. But it can’t be… Right?

Input:

for i in range(im.height):
    for j in range(im.width):
        if px[i][j] != 0:
            print(i, j, px[i][j])

Output:

100 100 8

Ah, one of the pixels is not 0: the pixel at the center. But is that… it? Then I remembered that it’s a GIF file and it probably moves. Let’s see:

Input:

im.n_frames

Output:

133

Good! Let’s see what’s happening in frame 2:

Input:

im.seek(1)
px = []
for i in range(im.height):
    row = []
    for j in range(im.width):
        row.append(im.getpixel((j, i)))
    px.append(row)
px[0][:20]

Output:

[(0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0),
 (0, 0, 0)]

Hmm… Some weird reason seems to cause the pixels to become 3-tuples. That’s not the matter; what matters is that they’re all still almost all zero! Let’s check for all the frames:

Input:

nzp = []
for fr in range(im.n_frames):
    im.seek(fr)
    nzpf = []
    for i in range(im.height):
        for j in range(im.width):
            p = im.getpixel((j, i))
            if p != 0 and p != (0, 0, 0):
                nzpf.append((i, j, p))
    nzp.append(nzpf)
nzp[:50]

Output:

[[(100, 100, 8)],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(98, 102, (8, 8, 8))],
 [(98, 100, (8, 8, 8))],
 [(98, 100, (8, 8, 8))],
 [(98, 100, (8, 8, 8))],
 [(98, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(102, 98, (8, 8, 8))],
 [(100, 100, (8, 8, 8))],
 [(98, 98, (8, 8, 8))],
 [(98, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(100, 98, (8, 8, 8))],
 [(102, 98, (8, 8, 8))],
 [(102, 98, (8, 8, 8))],
 [(102, 98, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 100, (8, 8, 8))],
 [(102, 102, (8, 8, 8))],
 [(102, 102, (8, 8, 8))],
 [(102, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))],
 [(100, 102, (8, 8, 8))]]

And for sure, precisely one pixel from each frame is nonzero! The problem becomes: now… what?

I guess let’s first make these pixels more visible to the human eye:

Input:

import IPython.display

frames = []
for fr in range(im.n_frames):
    im.seek(fr)
    j, i, _ = nzp[fr][0]
    p = im.getpixel((j, i))
    if isinstance(p, int):
        im.putpixel((j, i), im.palette.colors[255, 255, 255])
    else:
        im.putpixel((j, i), (255, 255, 255))
    f = io.BytesIO()
    im.save(f, format='gif', save_all=False)
    f.seek(0)
    frames.append(Image.open(f))

f = io.BytesIO()
frames[0].save(f, save_all=True, format='gif', append_images=frames[1:])
IPython.display.Image(f.getvalue())

Output:

No description has been provided for this image

That is very cool! But what does it mean? Thinking back to the problem, it says “emulate” with a joystick. Ah OK, I think I understand!

The white pixel is moving in the 9 pixels forming a 3×3 square, and it represents a joystick. The middle (100, 100) means “don’t move”, and the 8 pixels surrounding it means “move 1px in this direction”. So if we emulate the movement of the pixel based on this GIF, we should get something!

Let’s do it!

Input:

newframes = []
last = {}
j, i = 100, 100
for fr in range(im.n_frames):
    x, y, _ = nzp[fr][0]
    j += (y - 100) // 2
    i += (x - 100) // 2
    newim = Image.new('RGB', im.size)
    newim.putpixel((j, i), (255, 255, 255))
    newframes.append(newim)

f = io.BytesIO()
newframes[0].save(f, save_all=True, format='gif', append_images=newframes[1:])
IPython.display.Image(f.getvalue())

Output:

No description has been provided for this image

It looks like it’s tracing something… But what?

Input:

last = {}
j, i = 100, 100
newim = Image.new('RGB', im.size)
for fr in range(im.n_frames):
    x, y, _ = nzp[fr][0]
    j += (y - 100) // 2
    i += (x - 100) // 2
    newim.putpixel((j, i), (255, 255, 255))

newim

Output:

No description has been provided for this image

That looks like garbage…

Inspecting the data again, it seems that (100, 100) (which in our interpretation means “don’t move”) only appears 5 times. Maybe… We should split at each time that appears?

Input:

last = {}
def draw(start) -> None:
    j, i = 100, 100
    newim = Image.new('RGB', im.size)
    for fr in range(start, im.n_frames):
        x, y, _ = nzp[fr][0]
        if (x, y) == (100, 100):
            break
        j += (y - 100) // 2
        i += (x - 100) // 2
        newim.putpixel((j, i), (255, 255, 255))
    return newim
draw(1)

No description has been provided for this image

Is that… a letter “b”?

Input:

for fr in range(im.n_frames):
    x, y, _ = nzp[fr][0]
    if (x, y) == (100, 100):
        display(draw(fr + 1))

Output:

No description has been provided for this image

No description has been provided for this image

No description has been provided for this image

No description has been provided for this image

No description has been provided for this image

It says “bonus”, annnnnd we’re done!

Level 23

http://butter:fly@www.pythonchallenge.com/pc/hex/bonus.html

Title: what is this module?

bonus.jpg

In the source:

<!--
TODO: do you owe someone an apology? now it is a good time to
tell him that you are sorry. Please show good manners although
it has nothing to do with this level.
-->

<!-- 	it can't find it. this is an undocumented module. -->

<!--
'va gur snpr bs jung?'
-->

Well, ain’t this an easy level.

The “this module” refers to the module called, well, this. Open a Python terminal and type import this, and you will get as output:

The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Furthermore, the this module is undocumented, so you can’t find it in the docs!

Looking at the source code of this, it appears to use a rot13 algorithm to encode the text, which just means each letter is shifted by 13 letters. So, abcdefghijklmnopqrstuvwxyz becomes nopqrstuvwxyzabcdefghijklm

This is a trivial encoding method, but it appears to be what the text in this problem uses too!

Input:

import codecs

codecs.decode('va gur snpr bs jung?', 'rot13')

Output:

'in the face of what?'

Back to The Zen of Python:

In the face of ambiguity, refuse the temptation to guess.

And the problem is solved!

Level 24

http://butter:fly@www.pythonchallenge.com/pc/hex/ambiguity.html

Title: from top to bottom

maze.png

Nothing in the source.

The image is obviously a maze, which the filename generously tells us. Inspecting the image closer, there is one black pixel on the top row and one on the bottom row. White seems to be the “walls” of the maze, and black/red is the path.

Let’s walk the maze then!

Input:

import io
import requests
from PIL import Image

r = requests.get('http://www.pythonchallenge.com/pc/hex/maze.png', auth=auth)
im = Image.open(io.BytesIO(r.content))
repr(im)

Output:

'<PIL.PngImagePlugin.PngImageFile image mode=RGBA size=641x641 at 0x7FBE880C6510>'

Input:

# px[row][column] is the pixel at (row, column)
px = []
w, h = im.size
for i in range(h):
    row = []
    for j in range(w):
        row.append(im.getpixel((j,i)))
    px.append(row)

Input:

import sys

sys.setrecursionlimit(10000000)

# traverse the maze with DFS!
vis = set()
path = []

def dfs(i, j):
    if (
        i < 0
        or j < 0
        or i >= h
        or j >= w
        or (i, j) in vis
        or px[i][j] == (255, 255, 255, 255)
        or px[i][j] == (127, 127, 127, 255)
    ):
        return False
    vis.add((i, j))
    path.append((i, j))
    if i == h - 1:
        return True
    if dfs(i + 1, j) or dfs(i, j - 1) or dfs(i - 1, j) or dfs(i, j + 1):
        return True
    vis.remove((i, j))
    path.pop()
    return False

assert dfs(0, w - 2)

Perfect! Let’s draw the path:

Input:

newim = Image.new('RGB', im.size)
for i, j in path:
    newim.putpixel((j, i), (255, 255, 255))
newim

No description has been provided for this image

Wow… wow. That’s a long path! But the path tells us nothing… I thought it might display some text or something…

I suppose the red pixels must mean something. What are the pixels along this path?

Input:

ppx = []
for i,j in path:
    ppx.append(px[i][j])
ppx[:20]

Output:

[(0, 0, 0, 255),
 (80, 0, 0, 255),
 (0, 0, 0, 255),
 (75, 0, 0, 255),
 (0, 0, 0, 255),
 (3, 0, 0, 255),
 (0, 0, 0, 255),
 (4, 0, 0, 255),
 (0, 0, 0, 255),
 (20, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255),
 (8, 0, 0, 255),
 (0, 0, 0, 255),
 (0, 0, 0, 255)]

So it appears that only the R color changes, G and B stay 0, and A stays 255. And the R’s seem to alternate between 0 and a number… Maybe it signifies a byte?

Input:

bs = b''
for i in range(1, len(ppx), 2):
    bs += bytes([ppx[i][0]])
bs[:50]

Output:

b'PK\x03\x04\x14\x00\x00\x00\x08\x00\x88\x9a\xb02\xa5\xb9\xd9\xb2\xe7G\x00\x00\xa0L\x00\x00\x08\x00\x15\x00maze.jpgUT\t\x00\x03?\xc8\x88B\xfa\xd1\x8a'

Well if that ain’t another ZIP file!

Input:

import zipfile
zf = zipfile.ZipFile(io.BytesIO(bs))
zf.infolist()

Output:

[<ZipInfo filename='maze.jpg' compress_type=deflate filemode='-rw-r--r--' file_size=19616 compress_size=18407>,
 <ZipInfo filename='mybroken.zip' filemode='-rw-r--r--' file_size=2701>]

So there’s another maze and a “mybroken.zip” file inside… Let’s look at the maze.jpg first:

Input:

with zf.open('maze.jpg') as f:
    im = Image.open(io.BytesIO(f.read()))
im.convert('RGBA')

Output:

No description has been provided for this image

“lake”, it says – and we’re done!

Level 25

Title: imagine how they sound

lake1.jpg

In the source:

<!-- can you see the waves? -->

Waves & sound point to .WAV files, and the image filename is “lake1.jpg”. I tried “lake2.jpg” to no avail, but “lake1.wav” is indeed a file! So is “lake2.wav”, all the way until “lake25.wav”. All of them sound like random noise. Connecting that with the image of a jigsaw puzzle, I know that I need to combine these files somehow to form an actual audio file. But, how?

Input:

import requests
import io
import wave

wvs = []
for i in range(1, 26):
    r = requests.get(f'http://www.pythonchallenge.com/pc/hex/lake{i}.wav', auth=auth)
    f = wave.open(io.BytesIO(r.content), 'r')
    wvs.append(f)

# example audio
IPython.display.Audio(r.content)

Output:

Inspecting the audio files…

Input:

wv = wvs[0]
wv.getnframes(), wv.getframerate()

Output:

(10800, 9600)

Input:

wv.readframes(100)

Output:

b'\xff\xff\xff\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xff\xff\xff\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xfe\xff\xfd\xfd\xff\xfc\xfc\xfe\xfd\xfc\xff\xfc\xfb\xff\xfb\xfb\xff\xfb\xfb\xff\xfc\xfb\xff\xfd\xfc\xff\xfe\xfe\xff\xfe\xfe\xff\xfd\xfd\xff\xfb\xfc\xff\xf9\xfb\xff\xf8\xfb\xff\xf8\xfb\xff\xf8\xfc\xff\xf8\xfc\xff\xf9\xfb\xff\xfa\xfc\xff\xfb\xfc\xff\xf9\xfb\xff\xf7\xfb\xff\xf6\xfb\xff\xf6\xfa\xff\xf4\xf8\xff\xf5'

Hmm… The frames seem to be almost all 0xff’s, which explains the noise.

I have a bold idea: what if the wave files are actually images, RGB encoded? The 10800 frames in each wave file is precisely 60x60x3, which means each wave file could be a 60×60 image. Then putting these images together would yield a big image!

Let’s test it:

Input:

im = Image.new('RGB', (300,300))
for i in range(25):
    bx,by = i%5*60, i//5*60
    wv = wvs[i]
    wv.setpos(0)
    for y in range(60):
        for x in range(60):
            r,g,b = wv.readframes(3)
            im.putpixel((bx+x,by+y), (r,g,b))
im

Output:

No description has been provided for this image

Decent indeed!

OK I think that’s enough for one post. See you again later!