Skip to content

Instantly share code, notes, and snippets.

@SciresM
Created September 30, 2020 11:44
Show Gist options
  • Save SciresM/d97e21a02d4a4cdc11b2b97cf43efea3 to your computer and use it in GitHub Desktop.
Save SciresM/d97e21a02d4a4cdc11b2b97cf43efea3 to your computer and use it in GitHub Desktop.
Quick and dirty Spelunky 2 asset extraction. Assets are a weird chacha20 variant, there are at least two cryptographic errors due to typos....
import zstd
from struct import pack as pk, unpack as up
import subprocess as sp
# Quick and dirty Spelunky 2 asset extraction, author SciresM.
# Assets are protected by a weird chacha20 variant.
# The developers made an unfortunate set of typos that
# significantly weakens the asset crypto...
def rotate_left(a, b):
return ((((a) << (b)) | ((a) >> (32 - (b))))) & 0xFFFFFFFF
def quarter_round(w, a, b, c, d):
w[a] += w[b]
w[a] &= 0xFFFFFFFF
w[d] ^= w[a]
w[d] = rotate_left(w[d], 16)
w[c] += w[d]
w[c] &= 0xFFFFFFFF
w[b] ^= w[c]
w[b] = rotate_left(w[b], 12)
w[a] += w[b]
w[a] &= 0xFFFFFFFF
w[d] ^= w[a]
w[d] = rotate_left(w[d], 8)
w[c] += w[d]
w[c] &= 0xFFFFFFFF
w[b] ^= w[c]
w[b] = rotate_left(w[b], 7)
def round_pair(w):
quarter_round(w, 0, 4, 8, 12)
quarter_round(w, 1, 5, 9, 13)
quarter_round(w, 2, 6, 10, 14)
quarter_round(w, 3, 7, 11, 15)
quarter_round(w, 0, 5, 10, 15)
quarter_round(w, 1, 6, 11, 12)
quarter_round(w, 2, 7, 8, 13)
quarter_round(w, 3, 4, 9, 14)
def two_rounds(s):
w = s_to_w(s)
round_pair(w)
round_pair(w)
return w_to_s(w)
def quad_rounds(s):
w = s_to_w(s)
round_pair(w)
round_pair(w)
round_pair(w)
round_pair(w)
return w_to_s(w)
def sxor(x, y):
return ''.join(chr(ord(a) ^ ord(b)) for a,b in zip(x,y))
def s_to_b(s):
return [ord(c) for c in s]
def b_to_s(b):
return pk('<'+('B'*len(b)), *b)
def s_to_w(s):
return list(up('<'+('I'*(len(s)/4)), s))
def w_to_s(w):
return pk('<'+('I'*len(w)), *w)
def s_to_q(s):
return list(up('<'+('Q'*(len(s)/8)), s))
def q_to_s(w):
return pk('<'+('Q'*len(w)), *w)
def add_qwords(h0, h1):
return q_to_s([(a + b) & 0xFFFFFFFFFFFFFFFF for a,b in zip(s_to_q(h0), s_to_q(h1))])
def mix_in(h, s):
def mix_partial(h, partial):
assert len(partial) <= 0x40
b = s_to_b(h)
for i,c in enumerate(partial[::-1]):
b[i] ^= ord(c)
return quad_rounds(b_to_s(b))
while s != '':
h = mix_partial(h, s[:0x40])
s = s[0x40:]
return h
def filename_hash(s):
# Generate initial hash from the string
h0 = mix_in('\x00'*0x40, s)
# Advance h0 by four round pairs to get h1
h1 = quad_rounds(h0)
# Add the two together, and advance by four round pairs.
key = quad_rounds(add_qwords(h0, h1))
# Do keyed hashing
# NOTE: This appears to be an implementation mistake on the Spelunky 2 dev's part
# They generate a quad_round advanced version of (nonce'd key), but then they
# xor with the untweaked key instead of the tweaked key...
h = ''
for i in xrange(0, len(s), 0x40):
partial = s[i:i+0x40]
h += sxor(partial, key[:len(partial)][::-1])
return h
def decrypt_data(s, data):
# Untweaked key begins as half-advanced "0xBABE"
h = two_rounds(pk('<QQQQQQQQ', 0xBABE, 0, 0, 0, 0, 0, 0, 0))
# Mix the filename in to tweak the key
for i in xrange(0, len(s), 0x40):
partial = s[i:i+0x40]
h = quad_rounds(sxor(h[:len(partial)], partial[::-1]) + h[len(partial):])
# Add the tweaked key and its advancement, then advance by four round pairs.
key = quad_rounds(add_qwords(h, quad_rounds(h)))
# NOTE: This appears to be an implementation mistake on the Spelunky 2 dev's part
# They generate a quad_round advanced version of (nonce'd key), but then they
# xor with the untweaked key instead of the tweaked key...
dec = ''
if len(data) >= 0x40:
blocks = len(data) / 0x40
dec += sxor(data, key[::-1] * blocks)
data = data[blocks * 0x40:]
if len(data) > 0:
dec += sxor(data, key[:len(data)][::-1])
return zstd.ZSTD_uncompress(dec)
def get_asset(exe, ASSETS, path):
f_hash = filename_hash(path)
f_hash_len = len(f_hash)
index = -1
for i in range(len(ASSETS)):
l = min(f_hash_len, len(ASSETS[i][0]))
if f_hash[:l] == ASSETS[i][0][:l]:
index = i
break
if index == -1:
print '%s not found, skipping...' % path
return None
a_hash, encrypted, offset, size = ASSETS[i]
print '%s found at %08x' % (path, offset)
data = exe[offset:offset+size]
if encrypted:
data = decrypt_data(path, data)
return data
if __name__ == '__main__':
with open('E:/Spel2.exe', 'rb') as f:
exe = f.read()
ofs = 0x400
ASSETS = []
while True:
asset_len, name_len = up('<II', exe[ofs:ofs+8])
if asset_len == 0 and name_len == 0:
break
assert asset_len >= 1
ASSETS.append((exe[ofs+8:ofs+8+name_len], exe[ofs+8+name_len] == '\x01', ofs + 8 + name_len + 1, asset_len - 1))
ofs = ofs + 8 + name_len + asset_len
# Edit to extract whatever you want to extract here
for level in [
'generic.lvl', 'challenge_moon.lvl', 'challenge_star.lvl', 'challenge_sun.lvl', 'junglearea.lvl', 'volcanoarea.lvl',
'olmecarea.lvl', 'templearea.lvl', 'tidepoolarea.lvl', 'icecavesarea.lvl', 'babylonarea.lvl', 'sunkencityarea.lvl',
'cityofgold.lvl', 'duat.lvl', 'abzu.lvl', 'tiamat.lvl', 'eggplantarea.lvl', 'hundun.lvl', 'basecamp.lvl', 'ending.lvl',
'beehive.lvl', 'hallofushabti.lvl', 'palaceofpleasure.lvl', 'basecamp_tutorial.lvl', 'basecamp_surface.lvl', 'basecamp_shortcut_unlocked.lvl',
'basecamp_shortcut_discovered.lvl', 'basecamp_shortcut_undiscovered.lvl', 'basecamp_garden.lvl', 'basecamp_tv_room_unlocked.lvl',
'basecamp_tv_room_locked.lvl', 'cosmicocean_dwelling.lvl', 'cosmicocean_jungle.lvl', 'cosmicocean_volcano.lvl', 'cosmicocean_tidepool.lvl',
'cosmicocean_temple.lvl', 'cosmicocean_icecavesarea.lvl', 'cosmicocean_babylon.lvl', 'cosmicocean_sunkencity.lvl', 'cavebossarea.lvl',
'dwellingarea.lvl', 'blackmarket.lvl', 'testingarea.lvl', 'lake.lvl', 'lakeoffire.lvl', 'vladscastle.lvl', 'Arena/dm1-1.lvl', 'Arena/dm1-2.lvl',
'Arena/dm1-3.lvl', 'Arena/dm1-4.lvl', 'Arena/dm1-5.lvl', 'Arena/dm2-1.lvl', 'Arena/dm2-2.lvl', 'Arena/dm2-3.lvl', 'Arena/dm2-4.lvl',
'Arena/dm2-5.lvl', 'Arena/dm3-1.lvl', 'Arena/dm3-2.lvl', 'Arena/dm3-3.lvl', 'Arena/dm3-4.lvl', 'Arena/dm3-5.lvl', 'Arena/dm4-1.lvl',
'Arena/dm4-2.lvl', 'Arena/dm4-3.lvl', 'Arena/dm4-4.lvl', 'Arena/dm4-5.lvl', 'Arena/dm5-1.lvl', 'Arena/dm5-2.lvl', 'Arena/dm5-3.lvl',
'Arena/dm5-4.lvl', 'Arena/dm5-5.lvl', 'Arena/dm6-1.lvl', 'Arena/dm6-2.lvl', 'Arena/dm6-3.lvl', 'Arena/dm6-4.lvl', 'Arena/dm6-5.lvl',
'Arena/dm7-1.lvl', 'Arena/dm7-2.lvl', 'Arena/dm7-3.lvl', 'Arena/dm7-4.lvl', 'Arena/dm7-5.lvl', 'Arena/dm8-1.lvl', 'Arena/dm8-2.lvl',
'Arena/dm8-3.lvl', 'Arena/dm8-4.lvl', 'Arena/dm8-5.lvl', 'Arena/dmpreview.tok'
]:
asset = get_asset(exe, ASSETS, 'Data/Levels/%s' % level)
if asset is not None:
with open('E:/S2/Data/Levels/%s' % level, 'wb') as f:
f.write(asset)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment