zh3r0 CTF 2021 / Published

The Vault

Extracting PICO-8 code hidden in a cartridge PNG's color-channel LSBs, then reimplementing its Lua check in Python to recover the password.

misc

7 min read

Challenge brief

pepga just lost the password for her vault. Help her to get through it 👀

The Vault

Author - wh1t3r0se

Inspecting the cartridge image

This challenge provided us with a tarball. Upon unpacking it, we were presented with the following files.

❯ tree public/
public/
└── thevault.png

❯ file public/thevault.png
public/thevault.png: PNG image data, 160 x 205, 8-bit/color RGBA, non-interlaced

Upon opening thevault.png in an image viewer, we are presented with the following image.

A PICO-8 cartridge

Based on what’s written on the cartridge, it seems to be an image of a PICO-8 cartridge. Ok but what good is an image of a cartridge in helping us find the flag? Is this a steganography challenge? At this point we decided to run stegoVeritas on the image since it’s our go-to tool for steganography challenges.

stegoVeritas output: bit planes 0 and 1 of the alpha, red, green and blue channels, all showing the same bands of hidden data

Extracting the PICO-8 source

Based on what we see above, it’s quite evident that there’s some LSB steganography shenanigans going on. However, extracting the two least significant bits didn’t yield us the flag. It was at this point that we decided to google "pico-8 steganography" and this led us to discover the P8PNGFileFormat.

Each PICO-8 byte is stored as the two least significant bits of each of the four color channels, ordered ARGB (E.g: the A channel stores the 2 most significant bits in the bytes). The image is 160 pixels wide and 205 pixels high, for a possible storage of 32,800 bytes. Of these, only the first 32,773 bytes are used.

- Pico-8 Wiki

The wiki excerpt explains our observations: game data is stored in the two least significant bits of each of the four color channels. Pulling out the low bits on their own showed nothing readable because each channel holds only a quarter of every byte, and the reassembled Lua code is compressed.

How one pixel's four channels supply one cartridge byteAlpha, red, green and blue are drawn as 8-bit strips in which bits 7 to 2 hold the cartridge image and opacity and bits 1 and 0 hold data. Joined in ARGB order, alpha gives byte bits 7 and 6, red 5 and 4, green 3 and 2, and blue 1 and 0, so a single bit plane carries one bit of every byte. Pixels are read row by row into 32,800 bytes, and the Lua code from offset 0x4300 is stored compressed.AalphaRredGgreenBblue76543210765432107654321076543210A1A0R1R0G1G0B1B01 pixel = 1 byteone cartridge byte, bits 7 … 0alpha = top two bitsimage, bits 7–2data, bits 1–0row by row: 160 × 205 pixels = 32,800 bytes; Lua code from 0x4300 is compressed
Figure 1. Each pixel hides one cartridge byte in the two low bits of its four channels, alpha first. One channel's two bit planes hold only a quarter of every byte.

Upon further research we stumbled upon a blog post which provides an in-depth tutorial of how to extract said game data. Conveniently for us, the post also linked to a full-fledged decoder that we could use to extract out the p8 code.

Lua
-- the secret vault
-- wh1t3r0se

-- global+main ----------------

ins=[[
** welcome to the vault **





by wh1t3r0se
]]

asci="\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\161\162\163\164\165\166\167\168\169\170\171\172\173\174\175\176\177\178\179\180\181\182\183\184\185\186\187\188\189\190\191\192\193\194\195\196\197\198\199\200\201\202\203\204\205\206\207\208\209\210\211\212\213\214\215\216\217\218\219\220\221\222\223\224\225\226\227\228\229\230\231\232\233\234\235\236\237\238\239\240\241\242\243\244\245\246\247\248\249\250\251\252\253\254\255"

-- main program ---------------
function main()

    music(0)
    cls()
    print(ins,8,8,31)
    spr(2,48,20)
    spr(3,56,20)
    spr(18,48,28)
    spr(19,56,28)
    --key
    spr(32,66,18)
    spr(33,74,18)
    spr(48,66,26)
    spr(49,74,26)
    --lock

    poke(24365,1) -- mouse+key kit

    t=""
    print("type in some text:",28,100,11)
    repeat
        grect(0,108,128,5)
        print(t,64-len(t)*2,108,6)
        grect(64+len(t)*2,108,3,5,8)
        flip()
        grect(64+len(t)*2,108,3,5,0)
        if stat(30)==true then
            c=stat(31)
            if c>=" " and c<="}" then
                t=t..c
            elseif c=="\8" then
                t=fnd(t)
            elseif c=="\13" then
                cls()
                print("got you something:",30,50,7)
                print(amugeh(t),30,62,12)
                stop()
            end
        end
    until c=="\27"

end

-->8
-- functions ------------------


function grect(h,v,x,y,c)
    rectfill(h,v,h+x-1,v+y-1,c)
end

function isprime(n)
    if n == 1 then

        return false
    end
    for i = 2, n^(1/2) do
        if (n % i) == 0 then
            return false
        end
    end
    return true
end

function fnd(a)
    return sub(a,1,#a-1)
end

function encrypt(t, k)

    return chr(asc(t) + k)

end

function check(t)
    flag="congrats! u got it"

    secret   = chr(105)
    secret ..= chr(107)
    secret ..= chr(107)
    secret ..= chr(86)
    secret ..= chr(110)
    secret ..= chr(43)
    secret ..= chr(126)
    secret ..= chr(86)
    secret ..= chr(99)
    secret ..= chr(92)
    secret ..= chr(107)
    secret ..= chr(46)

    if #secret == #t then

        cunt=1
        good=true
        while (cunt<#t+1 and good) do
            if sub(t,cunt,cunt) == sub(secret,cunt,cunt) then
                cunt+=1
            else
                return "this time try real hard:/"
            end
        end

        return flag
    end

    return "1 year old baby can do this bruhh!"

end

function len(a)
    return #a
end

function encript(t,k)
    return chr(asc(t) - k )
end

function key()
    x =0
    x+=6
    x*=9
    x+=6
    x+=9
    x\=10
    return x
end

function instr(a,b)
    local r=0
    if (a==null or a=="") return 0
        if (b==null or b=="") return 0
            for i=1,#a-#b+1 do
                if sub(a,i,i+#b-1)==b then
                    r=i
                    return r
                end
            end
            return 0
        end

        function ki()
            x =0
            x+=6
            x*=9
            x+=6
            x+=9
            x%=10

            return 9

        end

        function amugeh(renbow)

            lmao=""
            local str = sub(renbow,0,6)
            if ( str == "zh3r0{" and sub(renbow,#renbow,#renbow) == "}") then

                hk_noob=1

                repeat
                    print("")
                    if (isprime(hk_noob)) == true then
                        lmao = lmao..(encrypt(sub(renbow,6+hk_noob,6+hk_noob),key()))

                    else
                        lmao = lmao..(encript(sub(renbow,6+hk_noob,6+hk_noob),ki()))
                    end

                    hk_noob += 1
                until hk_noob> (#renbow-7)
                return check(lmao)
            end

            return "try harder !"
        end

        function asc(a)
            return instr(asci,a)
        end

        main()

Reversing the password check in Python

Running the decoder on the cartridge file provided to us yielded the code above. In summary, our input undergoes a couple of transformations before it is eventually compared against a secret string. Unfortunately (for me) the p8 language seems to based on lua which i’m not the biggest fan of, so I decided to re-implement the code in python to compute the flag.

Python
def amugeh(inputt):
    lmao = ""

    assert inputt[:6] == "zh3r0{"
    assert inputt[-1] == "}"

    hk_noob = 1

    while True:
        if isprime(hk_noob):
            lmao += encrypt(inputt[6 + hk_noob], key())
        else:
            lmao += encript(inputt[6 + hk_noob], ki())
        hk_noob += 1

        if hk_noob > (len(inputt) - 7):
            break

    return check(lmao)


def check(t):
    secret = chr(105)
    secret += chr(107)
    secret += chr(107)
    secret += chr(86)
    secret += chr(110)
    secret += chr(43)
    secret += chr(126)
    secret += chr(86)
    secret += chr(99)
    secret += chr(92)
    secret += chr(107)
    secret += chr(46)

    assert len(t) == len(secret)

    return secret == t


def isprime(n):
    if n <= 1 or n % 1 > 0:
        return False
    for i in range(2, n // 2):
        if n % i == 0:
            return False
    return True


def encript(x, y):
    return chr(ord(x) - y)


def encrypt(x, y):
    return chr(ord(x) + y)


def key():
    return 6


def ki():
    return 9


secret = chr(105)
secret += chr(107)
secret += chr(107)
secret += chr(86)
secret += chr(110)
secret += chr(43)
secret += chr(126)
secret += chr(86)
secret += chr(99)
secret += chr(92)
secret += chr(107)
secret += chr(46)

flag = "zh3r0{"

for i, j in enumerate(secret):
    if isprime(i + 1):
        flag += encrypt(j, -key())
    else:
        flag += encript(j, -ki())

flag += "}"

print(f"Flag: {flag}")

Running the above script yields us the following.

❯ python xpl.py
Flag: zh3r0{reePh4x_lee7}

Trying to submit zh3r0{reePh4x_lee7} results in an incorrect flag, but at this point we can pretty much guess that the correct flag should be zh3r0{ree_h4x_lee7}.

The stray P comes from our Python port, not the cartridge: its isprime loops over range(2, n // 2), which is empty for 4. It treats position 4 as prime and subtracts 6 (86 − 6 = 80, P). The cartridge’s Lua treats 4 as composite and shifts that character by −9, so undoing it should add 9 (86 + 9 = 95, _).

Undoing the per-position shift for the twelve characters inside zh3r0{}Twelve columns cover the characters between zh3r0{ and }; positions 2, 3, 5, 7 and 11 are prime, so their secret bytes lose 6 and the rest gain 9. The secret 105 107 107 86 110 43 126 86 99 92 107 46 becomes r e e _ h 4 x _ l e e 7. The Python isprime(4) loops over the empty range(2, 2) and returns True, so position 4 gives 86 − 6 = 80, P, instead of 95, an underscore.positionprimesecretundoinput123456789101112105107107861104312686999210746ikkVn+~Vc\k.+9−6−6+9−6+9−6+9+9+9−6+9ree_h4x_lee7zh3r0{ree_h4x_lee7}the Python isprime(4) is True, so 86 − 6 gives P
Figure 2. Prime positions were shifted by +6 and the rest by −9, so undoing the shifts gives ree_h4x_lee7. The solver's Python isprime counts 4 as prime, which turns position 4 into P.

Result

Flag: zh3r0{ree_h4x_lee7}