Created
September 30, 2020 11:44
-
-
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....
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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