Indiana Jones and the Great Circle Save Encryption
— 16 minsI just finished The Great Circle (mostly) and due to an annoying bug, can’t 100% it, as a collectable is seemingly uncollectable once progressing past a certain point in Sukhothai. I thought, maybe, the save files can be edited and I can just go do a sneaky and go ‘fix’ that, but instead spent many hours learning about JPEGs. No story spoilers here, just photos of cats.
# Decrypting Save Data
I absolutely detest cryptography and it makes my brain hurt, so I first assumed that there was no encryption and it was just compressed, which isn’t totally unusual in games - but no such luck. But what if I break a save file and see if I get some useful error? I replaced a save file with garbage data instead, and thankfully, I got a useful error in the console:
[4671] WARNING: idSecureAEADCipher: BCryptDecrypt failed: c000a002
Awesome, so we know it’s BCrypt underneath and we have a error code we can look up, which is STATUS_AUTH_TAG_MISMATCH
which is expected given we fed it garbage. But the idSecureAEADCipher
was already useful, we’re likely looking at AES in GCM mode - but we don’t know the key size, is there a nonce, what the key/associated text is (and if they’re the same or different), the list goes on. I love cryptography, a lot.
Anyway, given that there’s a useful log message, what if we search for idSecureAEADCipher
in the executable:
.rdata 00000001434E5830 idSecureAEADCipher::SetCipher: Invalid aeadCipher value : %d
.rdata 00000001434E5870 idSecureAEADCipher::SetCipher: negative tagLength
.rdata 00000001434E58F0 idSecureAEADCipher: BCryptSetProperty( BCRYPT_CHAINING_MODE ) failed: %x
.rdata 00000001434E5960 idSecureAEADCipher: BCryptGetProperty( BCRYPT_AUTH_TAG_LENGTH ) failed: %x
.rdata 00000001434E59B0 idSecureAEADCipher::SetCipher: Invalid tagLength: %lld (valid range: [%d, %d])
.rdata 00000001434E5A00 idSecureAEADCipher: BCryptGetProperty( BCRYPT_BLOCK_LENGTH ) failed: %x
.rdata 00000001434E5A48 idSecureAEADCipher::Encrypt: Not initialized
.rdata 00000001434E5A80 idSecureAEADCipher::Encrypt: nonce must be exactly 12 bytes (bcrypt restriction)
.rdata 00000001434E5AE0 idSecureAEADCipher::Encrypt: Failed to allocate encrypted buffer
.rdata 00000001434E5B28 idSecureAEADCipher: BCryptEncrypt failed: %x
.rdata 00000001434E5B60 idSecureAEADCipher::Encrypt: Unexpected number of bytes written (%d instead of %d)
.rdata 00000001434E5BB8 idSecureAEADCipher::Decrypt: Not initialized
.rdata 00000001434E5BF0 idSecureAEADCipher::Decrypt: nonce must be exactly 12 bytes (bcrypt restriction)
.rdata 00000001434E5C48 idSecureAEADCipher::Decrypt: cipherText too short (%lld < %d)
.rdata 00000001434E5C90 idSecureAEADCipher::Decrypt: Failed to allocate decrypted buffer
.rdata 00000001434E5CD8 idSecureAEADCipher: BCryptDecrypt failed: %x
.rdata 00000001434E5D10 idSecureAEADCipher::Decrypt: Unexpected number of bytes written (%d instead of %d)
Saucy. So we have a 12 byte nonce, let’s try a search a bit less specific and see what other decrypt
functions we can find:
.rdata 00000001434E5578 idSecureBlockCipher::Decrypt: Not initialized
.rdata 00000001434E55B0 idSecureBlockCipher::Decrypt: Failed to allocate decrypted buffer
.rdata 00000001434E55F8 idSecureBlockCipher: BCryptDecrypt failed: %x
.rdata 00000001434E5628 idSecureBlockCipher::Decrypt: Too many bytes written (%d > %d)
.rdata 00000001434E5750 idSecureBlockCipher::DecryptWithOSPadding: Not initialized
.rdata 00000001434E5790 idSecureBlockCipher::DecryptWithOSPadding: Failed to allocate decrypted buffer
.rdata 00000001434E57E0 idSecureBlockCipher::DecryptWithOSPadding: Too many bytes written (%d > %d)
.rdata 00000001434E5BB8 idSecureAEADCipher::Decrypt: Not initialized
.rdata 00000001434E5BF0 idSecureAEADCipher::Decrypt: nonce must be exactly 12 bytes (bcrypt restriction)
.rdata 00000001434E5C48 idSecureAEADCipher::Decrypt: cipherText too short (%lld < %d)
.rdata 00000001434E5C90 idSecureAEADCipher::Decrypt: Failed to allocate decrypted buffer
.rdata 00000001434E5CD8 idSecureAEADCipher: BCryptDecrypt failed: %x
.rdata 00000001434E5D10 idSecureAEADCipher::Decrypt: Unexpected number of bytes written (%d instead of %d)
.rdata 0000000143544BB8 decrypt error
.rdata 0000000143552E90 decryption( 1 )
.rdata 0000000143552EA0 decryption( 2 )
There’s a separate block cipher, but I assume that’s not relevant here given the error we had previously, and I hadn’t seen decrypt error
yet, so we’ll try the decryption( 1 )
result and see what’s there, and there’s two xrefs to that string and the decryption( 2 )
string, the first of which seems DOOM related for some reason, but the second is more The Great Circle related:
.text:0000000140B251AD mov rcx, [rcx+10h]
.text:0000000140B251B1 lea r9, [rsp+390h+var_368]
.text:0000000140B251B6 lea r8, [rsp+390h+var_340]
.text:0000000140B251BB lea rdx, aSukhothai_0 ; "SUKHOTHAI"
.text:0000000140B251C2 call sub_140A918B0
.text:0000000140B251C7 test al, al
.text:0000000140B251C9 jnz loc_140B252A5
.text:0000000140B251CF lea rcx, aDecryption1 ; "decryption( 1 )"
.text:0000000140B251D6 call sub_140A91AF0
The other version had PAINELEMENTAL
instead of SUKHOTHAI
for whatever reason, everything was otherwise the same. Doesn’t seem to be called/used. Anyway, if we breakpoint at :$A918B0
and look at what’s passed through from the caller, it looks like it’s passing my SteamID64, SUKHOTHAI
again and one of the filenames in my save slot:
RCX : 0000019D0383B870 "76561198042042069"
RDX : 00007FF7A32FBA40 "SUKHOTHAI"
R11 : 00000022433FF538 "game_duration.dat"
Following that, it does a bunch of confusing string copy bullshit and fuckery through virtual calls and otherwise which is head hurting juice, but it looks to eventually concatenate the three into one string like the following: 76561198042042069SUKHOTHAIgame_duration.dat
. Is this the key? It looks like that the decryption( 2 )
works basically the same too, so not really sure why it exists at this stage. I would normally attempt to just try and decrypt it with that and even see if it’s right, but I don’t know how the key is derived. The concatenated string might just be the additional data to prevent save sharing and the key is hard coded or derived from the additional data, perhaps via hashing the it or a subset thereof.
However, in the spirit of laziness, and given that there’s DOOM (Eternal?) code in here because of course there is, what happens if you search GitHub for PAINELEMENTAL
? 2.4k code results and the first page had nothing useful, but the second page had a delicious nugget of information, which makes all prior work almost redundant: https://github.com/GoobyCorp/DOOMSaveManager/blob/bf20be5d56755e5ce7ce38ff5705d665e287052e/DoomEternalSavePath.cs#L58
if (Platform == DoomEternalSavePlatform.BethesdaNet && Encrypted)
fileData = Crypto.DecryptAndVerify($"{Identifier}PAINELEMENTAL{Path.GetFileName(single)}", fileData);
else if (Platform == DoomEternalSavePlatform.Steam && Encrypted)
fileData = Crypto.DecryptAndVerify($"{Identifier}MANCUBUS{Path.GetFileName(single)}", fileData);
Which is the same key format we found before. A quick look at the DecryptAndVerify
function tells us everything that we needed to know, AES AEAD with a 12 byte nonce which is stored before the cipher text, and the same additional data is used as the key when SHA256’d. It doesn’t look like in this case that there’s any platform-specific key material this time, as all code paths seem to always use SUKHOTHAI
as the game-specific key, but client builds are also platform specific as the client build information specifically states it’s a Steam build:
[14] ------ Build Information ------
Host Name: [redacted]
Bam compile specification: x64_vulkansteam-shippingretail
Binary Build:
Version: 1.0.0
Target: retail
Name: 20241212-152648-147754_jasper-olive
Branch: relic-stabilization
Requestor: user:autocompiler:forge
URL: https://www.forge.prod-arn-mg.idtech.services/builds/binaries/20241212-152648-147754_jasper-olive
Relevant CLs: 6c4a5934fa50e9ce96b2534f4eb8e8f708f0bbc1
Package:
Name: 20241212-153103-147754_chrome-nailset
Requestor: user:pierre.willbo:9d0dd
URL: https://www.forge.prod-arn-mg.idtech.services/builds/packages/20241212-153103-147754_chrome-nailset
Relevant CLs: 595616
Disc Layout:
Name: 20241212-153103-147754_ruthenium-twinkie
Candidate:
Name: 20241212-153103-147754_rotten-cat
There’s a not-insignificant chance that the keys are different per platform, but there’s also a good chance they’re the same. Either way, with what we know it’s pretty easy to find the missing key. Maybe it’s also to easier to quickly guess each place name if SUKHOTHAI
doesn’t work too.
With all that, I quickly slapped together some code based on what I found to decrypt a file based on the key we found in the executable and the code that GitHub search bestowed upon me, as a few more pages and I probably would’ve stopped looking. The following code works the best in a REPL like LINQPad :)
var sha256 = SHA256.Create();
string buildAssocData(string ident, string filename, string key = "SUKHOTHAI") => $"{ident}{key}{filename}";
byte[] decrypt(string key, byte[] data)
{
var nonce = data[..12].ToArray();
var cipherText = data[12..].ToArray();
var addDataBytes = Encoding.UTF8.GetBytes(key);
var keyHash = sha256.ComputeHash(addDataBytes);
var blockCipher = new GcmBlockCipher(new AesEngine());
var keyParams = new AeadParameters(new KeyParameter(keyHash, 0, 16), 128, nonce, addDataBytes);
blockCipher.Init(false, keyParams);
var textLen = blockCipher.GetOutputSize(cipherText.Length);
var buf = new byte[textLen];
var ret = blockCipher.ProcessBytes(cipherText, 0, cipherText.Length, buf, 0);
blockCipher.DoFinal(buf, ret);
return buf;
}
// usage
var p = @"C:\Program Files (x86)\Steam\userdata\...\2677660\remote\GAME-SLOT0\game.details";
Path.GetFileName(p).Dump();
var b = File.ReadAllBytes(p);
var key = buildAssocData("[SteamID64]", Path.GetFileName(p));
var c = decrypt(key, b);
Encoding.UTF8.GetString(c).Dump();
The result:
game.details
actionSettingAIPrespotMultiplier=1
actionSettingMaxMeleeAttackers=1
actionSettingMaxRangedAttackers=1
actionSettingMeleeAutoparry=0
actionSettingPlayerIncomingDamage=1
actionSettingStealthHUD=1
actionSettingStealthHUDThroughWalls=0
adventureSettingObjectiveMarker=0
adventureSettingObjectiveName=0
checkpointImage=
checksum=1852318942
completed=0
dateCreated=1734775685
disguise=soldier
gameDifficulty=1
gameVersion=1145896964
localSaveTimeStamp=1735222791
mapDesc=
mapName=game/relic/vatican/vatican/vatican
nextMapLayers=
nextMapName=
puzzleDifficulty=1
revisit=1
settingCameraFraming=1
settingSubtitleBacking=1
settingSubtitleClosedCaptions=0
settingSubtitleFontSize=0
settingSubtitles=1
settingSubtitleShowSpeaker=1
settingUIFontSize=0
startedPlaying=1
time=70566
# A Journey Through Save Data
Annoyed about the collectable bug, I started digging through the save files, which can be located for Steam owners here: C:\Program Files (x86)\Steam\userdata\<account number>\2677660\remote\
. If you’re on game pass or something else, google is probably your friend. There’ll be at least two folders in here (assuming you’ve played the game), a PROFILE
and GAME-SLOT0
directory. PROFILE
stores a single file, profile_pc.dat
which stores a bunch of key-value keys (but not the values from a cursory glance), settings, cutscenes (?) and some playfab keys - nothing remarkable.
GAME-SLOT0
(or whatever slot index you’re using) is where the actual save data lives. *.details
are a simple key-value store for basic information, typically used for disguise, have you revisited an area, the map name, current checkpoint amongst other things. This is stored individually for each level, and there’s also a ‘parent’ game.details
file that stores difficulty, subtitle settings, current map name (so you can do the fancy menu to in-game thing) and some other settings:
actionSettingAIPrespotMultiplier=1
actionSettingMaxMeleeAttackers=1
actionSettingMaxRangedAttackers=1
actionSettingMeleeAutoparry=0
actionSettingPlayerIncomingDamage=1
actionSettingStealthHUD=1
actionSettingStealthHUDThroughWalls=0
No idea what these do, haven’t tried playing with them. I suspect they’re set by the difficulty settings accordingly. Here’s college_day_cb67fe49.details
:
checkpoint=script_script_spawnpoint_revisit
checksum=-1216986065
dateCreated=1734776674
disguise=professor
introSeen=1
layers=game/revisit
localSaveTimeStamp=1734879741
mapName=game/relic/college/college_day
revisited=1
The *.dat
files that accompany the *.details
files are more interesting, they store the entirety of game state of an entire map in the file. A non-exhaustive list is quest state, collectables, boat position, door states, puzzle states, interactables and ragdolls. If you search the executable for SAVEGAME_JOB_NAME
you’ll find 521 jobs which saves a specific type of component state, but it doesn’t look like a lot of them are used.
The final thing I’d like to talk about that I haven’t mentioned yet, is that there is a photocamera
directory in the slot directory. This contains every photo of a point of interest you take a photo of in game (as if you take a photo randomly, you get an end-of-film angry noise from the camera). But everything that the game wants you to take a photo of is in there, and while unfortunately, each photo is only 1280x768, there’s also some film camera filtering going on with exposure, grain, (ugly) chromatic aberration and generally looking a bit shit, they fit pretty well with the vibe and era of the game.
Decrypting all these is pretty straightforward too, with the same decrypt
function from earlier:
var path = @"C:\Program Files (x86)\Steam\userdata\...\2677660\remote\GAME-SLOT0\photocamera\";
foreach(var p in Directory.EnumerateFiles(path))
{
Path.GetFileName(p).Dump();
var b = File.ReadAllBytes(p);
var key = buildAssocData("[SteamID64]", Path.GetFileName(p));
var c = decrypt(key, b);
Util.Image(c).Dump();
}
These will be shown in the results pane in LINQPad, and you can just copy/save any images out of there that you’d like (or write them to disk directly).
The same images are actually used to populate Indi’s journal from disk, so you could replace the images with your own. One thing to note with encrypting file contents here, is that BCrypt doesn’t like data that has odd sizes, it must be padded out to a 4/8 byte boundary. The easiest way to do this is just resize the array before you encrypt it, like so:
Array.Resize(ref data, (data.Length + 7) & ~7);
4 bytes should work too, but I didn’t try it; not really sure why I picked 8 bytes in hindsight. So, some quickly fixed encryption code would look like this:
byte[] EncryptAndDigest(string aad, byte[] data)
{
var nonce = new byte[12];
new Random().NextBytes(nonce);
Array.Resize(ref data, (data.Length + 7) & ~7);
var aadBytes = Encoding.UTF8.GetBytes(aad);
var aadHash = sha256.ComputeHash(aadBytes);
var cipher = new GcmBlockCipher(new AesEngine());
var cipherParams = new AeadParameters(new KeyParameter(aadHash, 0, 16), 128, nonce, aadBytes);
cipher.Init(true, cipherParams);
var len = cipher.GetOutputSize(data.Length);
var ciphertext = new byte[len];
var retLen = cipher.ProcessBytes(data, 0, data.Length, ciphertext, 0);
cipher.DoFinal(ciphertext, retLen);
var output = new byte[nonce.Length + ciphertext.Length];
Buffer.BlockCopy(nonce, 0, output, 0, nonce.Length);
Buffer.BlockCopy(ciphertext, 0, output, nonce.Length, ciphertext.Length);
return output;
}
# Replacing Journal Photos
Replacing the photocamera
images is Type 3 Fun, as the game is very specific on the size, format, and probably other image bullshit that shouldn’t matter but it does. I haven’t had much luck actually getting the game to load the images just by giving it a correctly sized JPEG, but it will not log any errors if it fails for some reason, it will just not work instead and you get a black texture. The only error you will get is if the size is wrong, which is helpful if you get confused between 1280x720 and 1280x768 like I did. I suspect there’s an in-house lightweight implementation of a JPEG encoder/decoder that’s used for this (or generally, in engine) and likes things in a certain way.
Wondering why it’s still not working and I’m just getting nothingness textures, I had a bit of a look at the differences in the JPEG I was giving it and what I had decrypted, and while the data in the headers is the same, the structure is completely different. I tried with a few different tools and photo editing applications and never got anything close to how they encode JPEGs.
First up is a decrypted image from the game itself:
Name Offset Size
struct JPGFILE jpgfile 0h 16B31h
enum M_ID SOIMarker 0h 2h
struct APP0 app0 2h 12h
struct DQT dqt 14h 86h
enum M_ID marker 14h 2h
WORD szSection 16h 2h
struct QuanTable qtable[0] 18h 41h
struct QuanTable qtable[1] 59h 41h
struct SOFx sof0 9Ah 13h
enum M_ID marker 9Ah 2h
WORD szSection 9Ch 2h
ubyte precision 9Eh 1h
WORD Y_image 9Fh 2h
WORD X_image A1h 2h
ubyte nr_comp A3h 1h
struct COMPS comp[3] A4h 9h
struct DHT dht ADh 1A4h
enum M_ID marker ADh 2h
WORD szSection AFh 2h
struct Huffmann_Table huff_table[0] B1h 1Dh
struct Huffmann_Table huff_table[1] CEh B3h
struct Huffmann_Table huff_table[2] 181h 1Dh
struct Huffmann_Table huff_table[3] 19Eh B3h
struct SOS scanStart 251h Eh
char scanData[92366] 25Fh 168CEh
enum M_ID EOIMarker 16B2Dh 2h
char unknownPadding[2] 16B2Fh 2h
Versus the seemingly more typical JPEG structure and the structure of the test image I’m trying to load:
Name Offset Size
struct JPGFILE jpgfile 0h 2EDF3h
enum M_ID SOIMarker 0h 2h
struct APP0 app0 2h 12h
struct DQT dqt[0] 14h 45h
enum M_ID marker 14h 2h
WORD szSection 16h 2h
struct QuanTable qtable 18h 41h
struct DQT dqt[1] 59h 45h
enum M_ID marker 59h 2h
WORD szSection 5Bh 2h
struct QuanTable qtable 5Dh 41h
struct SOFx sof0 9Eh 13h
enum M_ID marker 9Eh 2h
WORD szSection A0h 2h
ubyte precision A2h 1h
WORD Y_image A3h 2h
WORD X_image A5h 2h
ubyte nr_comp A7h 1h
struct COMPS comp[3] A8h 9h
struct DHT dht[0] B1h 21h
enum M_ID marker B1h 2h
WORD szSection B3h 2h
struct Huffmann_Table huff_table B5h 1Dh
struct DHT dht[1] D2h B7h
enum M_ID marker D2h 2h
WORD szSection D4h 2h
struct Huffmann_Table huff_table D6h B3h
struct DHT dht[2] 189h 21h
enum M_ID marker 189h 2h
WORD szSection 18Bh 2h
struct Huffmann_Table huff_table 18Dh 1Dh
struct DHT dht[3] 1AAh B7h
enum M_ID marker 1AAh 2h
WORD szSection 1ACh 2h
struct Huffmann_Table huff_table 1AEh B3h
struct SOS scanStart 261h Eh
char scanData[191362] 26Fh 2EB82h
enum M_ID EOIMarker 2EDF1h 2h
So the key things are that there’s still 2 quantization tables and 4 Huffmann tables, but instead of a single table stored per struct, all of them are stored in a single struct. The images the game produces are smaller too, saving 16 bytes per image, which makes me curious why this isn’t the normal way if this is possible. 16 bytes isn’t much but over thousands of images, that’d be a nice improvement. What’s also curious is basically everything I tried will encode a JPEG like the second format, and never the first. Maybe it’s a compatibility thing and the more ‘standard’ format is more widely accepted, but everything I have loads the decrypted image as expected. Saving/copying the image out of the image viewer a lot of the time will write the image in the second, more standard format, which had me scratching my head before I realised the structure was completely different.
Anyway, that all said, nothing that isn’t too hard to fix in a hex editor. So I manually copied the bytes from my source image into the skeleton of an image that came out of the game, and it still worked in my usual selection of image viewers. The only things I moved across was the scanData
and the 4 Huffmann tables and the 2 quantization tables. I initially didn’t copy the SOFx
struct, but when the image didn’t work initially, I realised that comp[0]
had a different horizontal and vertical sampling configuration. I suspect if this image doesn’t load, it might only like it’s specific sampling factor it uses, which is seemingly consistent (hello what kind of encoding is this???) where the following rules apply but I also only checked like 5 of them because that is scientific enough for me and my sanity is rapidly dwindling:
comp[0]: 2x2
comp[1]: 1x1
comp[2]: 1x1
Whereas my image was 1x1 sampling for each component. But that shouldn’t matter right? I didn’t think I’d ever have to care so much about what the fuck a JPEG is, but rabbit holes be rabbit holes.
As the proud owner of a freshly hand-minted JPEG that definitely hasn’t cost me any sanity whatsoever, I encrypted the image again and slapped it into the photocamera/
folder…
Fuck. Maybe I need to use the same component sampling factor as they do? Apparently imagemagick can do this via convert
, so…
$ identify -verbose angery.jpg | grep sampling-factor
jpeg:sampling-factor: 1x1,1x1,1x1
$ convert angery.jpg -sampling-factor 4:2:0 angery2.jpg
$ identify -verbose angery2.jpg | grep sampling-factor
jpeg:sampling-factor: 2x2,1x1,1x1
Great success… but the structure is all wonky again. Out of desperation I was scrolling through the command line args for something that looked like it would do the structure part for me but I don’t think that exists and it’s just an implementation-specific quirk. Sadness. The game refuses to load the image that imagemagick has just spat out too without any changes, so that sorta confirms that they only accept their wacky JPEG structure. Anyway, time to mint a new JPEG by hand again because I don’t think I have the brain cells left to automate this, even though it doesn’t feel like it should be that hard.
The worst possible situation has occured for my love of hex editing, the Huffmann tables are all a different size and it doesn’t fit nicely into the same structure as-is, so I have to do math too now and not just copy paste shit. To clarify, before we had one struct that was 418 bytes in size including the size, but the new Huffmann tables that imagemagick produced were only 173 bytes across the 4 DHT structs.
Anyway, with some basic arithmetic down, 29+75+27+42 = 173
for the old DHT struct sizes, which totally didn’t work the first time because I didn’t remove 3 u16
s for the DHT structs and their sizes we no longer had in the file (so 173 - 6 = 167
), we have Hand-Minted-JPEG 2: Electric Boogaloo. Works in my photos viewer and Firefox so you know it’s a quality JPEG.
So we encrypt it again, drop it into the photocamera/
folder again, and reload the save and… it doesn’t work, at all. Black. Nothingness. Maybe I’m wrong about it being super pedantic about the structures, but it’s never worked with a vanilla JPEG ever, so I think I’m at least somewhat correct there. I also had a look at some DOOM modding wikis and there doesn’t seem to be anything about JPEGs there, and the only mention of a JPEG on the idStudio docs is about the screenshot requirements for DOOM mods. The JPEG I hand crafted with equal parts love and anger might be really fucked too, but it works in basically everything I opened it with, and even tried error checking it with some random tools that said they did that and they all report that the image is fine.
At this point I feel like it’s something stupid I’m messing up or missing, but I’ve spent more time trying to get a funny JPEG into Indi’s journal as a shitpost than I probably should have, and I don’t want to sit in front of a debugger figuring out how the game loads JPEGs as that sounds even less fun.
If you figure out what the deal with these JPEGs are, I’d probably want to know just so I know how close I was to getting it right.