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.
pepga just lost the password for her vault. Help her to get through it 👀
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-interlacedUpon opening thevault.png in an image viewer, we are presented with the following image.

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.

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.
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.
-- 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.
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, _).
Result
Flag: zh3r0{ree_h4x_lee7}