2a1b

Dave Ball / nzdjb (he/him)

About me

Husband, Dad, Computer Wrangler in Te Whanganui-a-Tara, Aotearoa.

Me elsewhere

Arbitrary Code Exec in Balatro

Dave @ 2025-12-05

Recently, I found an Eval Injection vulnerability in Balatro, resulting in arbitrary code execution.

This affects version 1.0.1o-FULL and is unpatched at time of writing.

The game

Balatro is a poker-based rogue-like deck-builder game, created by LocalThunk.

The bug

Eval Injection (or "Improper Neutralization of Directives in Dynamically Evaluated Code") is an issue where input is executed as code, without proper sanitisation or sandboxing. This is a type of code execution vulnerability, which can lead to all manner of security breaches.

In the case of Balatro, the bug can be triggered by loading a specially crafted save game file.

Finding the bug

Balatro, like a variety of other games, has some achievements that are not necessarily hard but require a lot of luck and repetition to complete. Sometimes, I reach a certain point where messing with the game to make the achievements easier becomes more interesting than grinding them out. This often involves decoding and modifying save files.

In the case of Balatro, I decoded the save file (it was compressed with raw deflate) and saw a Lua table. That's a little tricky to work with, but not impossible.

Then I noticed that the save file ... starts with "return". Is the save file a function? I decided to test this out.

I looked through the table and found a promising value named "dollars". I confirmed that modifying this with a static value would change the amount of money the save game had. Then I set the value to some arithmetic: 50+47.

After recompressing the file, putting it in the right place and starting the game, I saw that the arithmetic had been performed and the money was set to 97. This confirms that code in the save file is being run.

Diving into the code

I decided to look deeper into what was going on. I started by inspecting the binary with Detect-It-Easy. This showed that the binary was comprised primarily of a Lua runtime and an archive. I opened the binary with 7z and found it contained a bunch of Lua files. This was the unobfuscated source code for the game1.

I searched for save.jkr, the name of the save file, and found that a function called G.FUNCS.can_continue is responsible for loading saved games.

In particular, this section of the code:

      if not G.SAVED_GAME then 
        G.SAVED_GAME = get_compressed(G.SETTINGS.profile..'/'..'save.jkr')
        if G.SAVED_GAME ~= nil then G.SAVED_GAME = STR_UNPACK(G.SAVED_GAME) end
      end

This has get_compressed and STR_UNPACK, both from engine/string_packer.lua. get_compressed is fairly mundane2, but STR_UNPACK is:

function STR_UNPACK(str)
  return assert(loadstring(str))()
end

From some other information, I saw that the game was built using the LÖVE framework, which uses Lua 5.1.

  • loadstring takes a string and turns it into a "chunk" (basically Lua code).
  • assert throws an error if its argument is nil or false, otherwise returns the argument.
  • () executes the chunk.

So this code reads save.jkr, decompresses it and executes it. That's a pretty straight-forward Eval Injection path.

Developing an exploit

What can we do with this? All sorts of things! The game executes code in the global Lua context so an exploit has access to essentially the entire programming language, as well as the LÖVE framework.

The following is an example save file that will break out of the application and run commands in the OS.

return {
  ["EXAMPLE"] = {
    ["Linux"] = os.execute("gnome-calculator"),
    ["Macos"] = os.execute("open -a Calculator"),
    ["Windows"] = os.execute("calc.exe"),
  }
}

Putting this file in the profile folder and running the game results in a calculator process starting. Yay.

This could obviously be replaced with something far nastier, but spawning calculator serves as a proof-of-concept.

How to fix this

Using safe serialization formats

Don't! Load! Code!

This issue resulted from a mechanism used for loading code being used to load data. Using a format that doesn't represent executable code is a safer approach. JSON and protobuf are popular choices. Even then, it's important to make sure that the deserializer does not have any "helpful" features that can result in code execution.

Sandboxing

If you really must load code, ensure it's done in a manner that limits the functionality it has access to.

In Lua, this is actually really easy to do:

function STR_UNPACK(str)
  local func = assert(loadstring(str)) -- turn the string into a function
  local safe_func = setfenv(func, {}) -- set an empty environment on the function
  return safe_func()
end

This loads the string into a chunk, sets the environment (what functions the function has access to) to an empty set and then executes it.

The result

Risk

The main risk associated with this issue is that an attacker could turn file write into code execution. This risk is increased if an attacker can convince a user to install a save file they have provided.

Mitigations

  • Don't load save games from untrusted sources until there is a patch for this issue.
  • Don't run the game if there's a chance someone else has write access to your Balatro profile folder.

Vendor contact

I had a lot of trouble trying to get hold of someone to acknowledge the bug. The developer never responded and the publisher said they would investigate, but then went silent.

Timeline

  • 2025-09-07: Found the issue and determined impact.
  • 2025-09-08: Emailed the developer about the issue.
  • 2025-09-16: Sent follow up email to the developer.
  • 2025-09-19: Sent message to the developer through their contact form.
  • 2025-09-21: Disclosed bug in #bug-reports channel on the official Discord.
  • 2025-09-28: Emailed the publisher, got an automated response.
  • 2025-10-02: Received response from publisher saying they would log and investigate the issue.
  • 2025-11-13: Emailed publisher for update and stating intention to publish. No response.
  • 2025-12-05: Published this post.

Further work

Balatro has several other files that are stored in the same place with the same .jkr extension. They are likely also exploitation vectors, but I haven't looked into them.

  1. I found out later that the recommended approach for shipping LÖVE games is to concatenate the framework loader and a zip of the source together, so this is intentional.

  2. One interesting thing about get_compressed is that if checks the first 6 bytes of a save file for "return" and skips decompression if there's a match. This made exploit development easier.


AWS Multi-session Support

Dave @ 2025-12-02

A major issue with working in an AWS environment beyond a trivial size has always been that you can only use the Console on a single account at a time. This had lead to all sorts of workarounds such as using incognito windows or additional browser profiles.

AWS fixed this (mostly) in January this year by adding multi-session support.

What is it?

Multi-session support is pretty much what it says on the tin. You can now be logged into multiple AWS Console sessions at a time.

This is handled by using a custom domain name for each of your AWS Console sessions, to maintain a separate session cookie for each.

How do I get it?

You need to enable multi-session support before you can use it. This can be done by selecting "Turn on multi-session support" in the AWS Management Console or by selecting "Enable multi-session" on https://console.aws.amazon.com.

Caveats

There are a few things to keep in mind when using multi-session support.

  • You can only be signed into up to five sessions at a time. If you attempt to sign in to a sixth session, you'll be prompted to sign out of one of the existing ones.
  • When using SSO, signing into the same role twice will count as two different sessions (this may depend on your organization's setup).
  • Not every single part of the Console supports multiple sessions properly. The coverage of this has gotten much better since launch, but there are still a few odd corners that don't.

Introduction to Kodiak

Dave @ 2025-12-01

Kodiak is a tool for automating GitHub a range of pull request functions. It's very easy to set up and is very configurable, making it useful for a wide range of workflows.

Primary uses

Kodiak is useful for automating merges and keeping branches up-to-date.

It's key feature is around automatically merging to main when all checks pass and the "automerge" label has been added. When these are true, it will update the branch from main (if required) and merge the PR.

How I use it

I primarily use Kodiak to handle https://docs.github.com/en/code-security/dependabot PRs for me. These are a more than weekly occurence and if all my checks pass, I'm inevitably going to merge them, so it's better to have a bot do it for me!

My typical .kodiak.toml file looks like this:

version = 1

[merge]
method = "squash"
delete_branch_on_merge = true

[merge.automerge_dependencies]
versions = ["minor", "patch"]
usernames = ["dependabot"]

The most interesting part of this is the merge.automerge_dependencies section. This will cause PRs to automatically be merged if they are created by dependabot and they are minor or patch level dependency updates (based on Semantic Versioning).

The versions array also accepts "major" as an option, though I personally like to eye-ball any breaking changes before accepting them.

Pricing

Kodiak is free for personal GitHub accounts and public repositories. Organizations can subscribe to use it on private repos.

Conclusion

Kodiak has personally saved me heaps of time and teams I've been on in the past even more. I'd highly recommend trying it out if it fits your workflow.


Reverse Engineering Slay The Spire

Dave @ 2025-09-03

In this article, I describe the steps I took to reverse engineer the save game code of Slay the Spire to unlock an achievement more quickly.

What is reverse engineering?

Reverse engineering is the process of taking a product and observing its behaviour and deconstructing it to understand the how it works. This is often done with the aim of reproducing parts of it in another product or modifying the behaviour of the product in some way.

The game

Slay the Spire (2019) is a deck builder game developed and published by Mega Crit. In it, your goal is to climb the Spire by defeating a variety of enemies. To do so, you must assemble a deck of cards that are used in combat.

The goal

One of the most fun things to do in a deck builder game is to build decks that synergise in different ways to produce interesting effects. Unfortunately, the randomness involved in availability of cards can make this quite frustrating. If I could start the game with the deck the way we wanted it, I could get straight to the fun part without the frustration!

The problem

But how can I make that happen? An obvious answer is to create a saved game and then manipulate it to put the game into the state I want. If I start the game and save as soon as possible, I should have a fairly basic save file that will provide a good base to work from.

Starting a game as the Ironclad class and saving after the initial encounter provided me with a file called IRONCLAD.autosave. Opening this, I saw what looked like a base64 string. Loading the file in CyberChef and running From Base64 gave an unreadable response, so I had to dig further.

Reverse engineering the game

In the game directory, there are several interesting files:

  • SlayTheSpire.exe (364KB)
  • desktop-1.0.jar (356,531KB)
  • mod-uploader.jar (1,600KB)
  • mts-launcher.jar (1,250KB)

The presence of the jar (Java Archive) files suggested that this game was written in Java (or another JRE language). Given the small size of SlayTheSpire.exe and comparatively large size of desktop-1.0.jar, it's likely that the exe is a native wrapper for starting the jar, which would then contain the game logic.

I validated this theory by exploring the contents of the jar. jar files are zip archives structured in a particular way, so they can be extracted them with an archiving tool like 7-Zip.

After extracting the files and searching for filenames containing 'save', I found com/megacrit/cardcrawl/saveAndContinue/SaveFileObfuscator.class, which sounded very promising. This is a Java class file which contains byte-code, which is run on a JVM (Java Virtual Machine). This can usually be decompiled into readable Java fairly easily.

In this particular case, I opened it in VSCode and used the Language Support for Java(TM) by Red Hat extension to decompile the class file and was shown the following:

 // Source code is unavailable, and was generated by the Fernflower decompiler.
package com.megacrit.cardcrawl.saveAndContinue;

import org.apache.commons.codec.binary.Base64;

public class SaveFileObfuscator {
   public static final String key = "key";

   public static String encode(String s, String key) {
      return base64Encode(xorWithKey(s.getBytes(), key.getBytes()));
   }

   public static String decode(String s, String key) {
      return new String(xorWithKey(base64Decode(s), key.getBytes()));
   }

   private static byte[] xorWithKey(byte[] a, byte[] key) {
      byte[] out = new byte[a.length];

      for(int i = 0; i < a.length; ++i) {
         out[i] = (byte)(a[i] ^ key[i % key.length]);
      }

      return out;
   }

   private static byte[] base64Decode(String s) {
      return Base64.decodeBase64(s);
   }

   private static String base64Encode(byte[] bytes) {
      return new String(Base64.encodeBase64(bytes));
   }

   public static boolean isObfuscated(String data) {
      return !data.contains("{");
   }
}

This shows a class with three public methods, encode, decode and isObfuscated as well as a public field key. The interesting one is decode which does the following:

  1. Takes a string s and a string key
  2. Base64 decodes s into a byte array
  3. Turns key into a byte array
  4. Passes both byte arrays into xorWithKey, which xors them together for the length of s, repeating key as necessary
  5. Creates a string from the resulting byte array

Seeing this, I went back to CyberChef and added an xor step to the recipe, using "key" as the key. This output a JSON document containing the card list, as well as many other parts of the game state.

Putting it together

Using this recipe, I was able to decode the save file. After modifying it as needed, I could run the edited JSON through a recipe comprised of the steps in reverse order to produce a save file that the game would open1.

Summary

Deobfuscating save files required a small amount of effort, but provided a huge payoff in ability to control the game state. This approach can likely be applied to many other games.

  1. I later discovered that the game will happily open an unobfuscated save file, so this step was unnecessary.


Reverse Engineering MOLEK-SYNTEZ

Dave @ 2025-06-07

In this article, I describe the steps I took to reverse engineer the Solitaire portion of MOLEK-SYNTEZ to unlock an achievement more quickly.

What is reverse engineering?

Reverse engineering is the process of taking a product and observing its behaviour and deconstructing it to understand the how it works. This is often done with the aim of reproducing parts of it in another product or modifying the behaviour of the product in some way.

The game

MOLEK-SYNTEZ (2019) is a puzzle game developed and published by Zachtronics. In it, you program a molecular synthesizer to transform various precursor molecules into the desired output molecules.

As with many Zachtronics games, there is also a Solitaire game included.

The achievement

Charlatan: Win 100 games of solitaire.

Winning the Solitaire is not especially hard, but takes a while. After winning around 20 something games, I got bored and decided to see if I could speed things up.

Failed approaches

Typically, I'd use Cheat Engine or Saved Game Modification for this sort of thing.

Cheat Engine

Cheat Engine allows you to repeatedly scan the memory of a process for particular values. By changing the value in game and rescanning, you can narrow down the list of memory addresses to identify the address that stores the value. Once this is done, you can then change the value at that address to affect the state of the game.

Unfortunately, after identifying the address for the number of solitaire wins, modifying it didn't impact the achievement and the number was reset to the real value after another win was added.

Saved Game Modification

Another approach for modifying a game's state is to decode and modify the save files.

However, in this case, the number of solitaire wins didn't seem to be stored in any of the save files. I also checked the registry, but wasn't able to find it there either.

Reverse engineering the game

The first step to finding out how the game works is to try to decompile it. For that, it helps to know what kind of executable we're working with.

Using the file command, we can find that MOLEK-SYNTEZ.exe is a .Net assembly. .Net languages are compiled to the Common Intermediate Language, which can be decompiled fairly easily to readable C# code. My tool of choice for this is dnSpy, which supports debugging as well as decompilation.

On opening the binary and decompiling it, I found that it was partially obfuscated. All the variables, most of the method calls and some of the class names had been replaced with indecipherable strings. Fortunately, there's a very good tool for deobfuscating .Net called de4dot. With the help of this, I was able to return the code to a fairly readable state.

While de4dot can't recover obfuscated symbols, a lot of the classes in the binary hadn't had their names obfuscated. In particular, these looked promising: SolitaireAnimation, SolitaireItem, SolitaireScreen and SolitaireState.

I also noticed that the game used a library called Steamworks.NET, which is used for interacting with Steam for tasks including setting achievements. Looking at the Solitaire classes, I found that the library was used in SolitaireScreen, with a class called SteamUserStats. Static methods from this class were used several times in one part of the code, which looked like this:

if (this.method_0().method_0() || GClass74.smethod_14((GEnum122)3, Key.F11))
{
  GClass5.smethod_2().sound_20.smethod_1();
  GameLogic.gameLogic_0.gclass116_0.method_7(this.method_0().bool_0);
  if (!this.method_0().bool_0)
  {
    SteamUserStats.SetAchievement("NO_CHEAT");
    SteamUserStats.StoreStats();
  }
  if (GClass102.bool_3 && this.method_0().method_0() && SteamUserStats.GetStat("SOLITAIRE", out this.int_0))
  {
    this.int_0++;
    SteamUserStats.SetStat("SOLITAIRE", this.int_0);
    SteamUserStats.StoreStats();
    if (this.int_0 <= 1)
    {
      SteamUserStats.IndicateAchievementProgress("SOLITAIRE_1", (uint)this.int_0, 1U);
    }
    else if (this.int_0 <= 10)
    {
      SteamUserStats.IndicateAchievementProgress("SOLITAIRE_2", (uint)this.int_0, 10U);
    }
    else if (this.int_0 <= 100)
    {
      SteamUserStats.IndicateAchievementProgress("SOLITAIRE_3", (uint)this.int_0, 100U);
    }
  }
  this.method_0().genum116_0 = (GEnum116)2;
  this.method_0().float_1 = 0f;
}

In particular, these five lines stood out:

if (GClass102.bool_3 && this.method_0().method_0() && SteamUserStats.GetStat("SOLITAIRE", out this.int_0))
{
  this.int_0++;
  SteamUserStats.SetStat("SOLITAIRE", this.int_0);
  SteamUserStats.StoreStats();

This code is retrieving the value of the "SOLITAIRE" stat, incrementing it and storing it back. I set a breakpoint on the post-increment line and attached the debugger to my game process. After winning a game of Solitaire, the game froze and dnSpy showed me I was at the post-increment line.

From there, I opened the Locals tab in dnSpy and found this.int_0. Unfortunately, changing the value directly in the Locals tab gave an "Internal debugger error", but I was able to work around this by opening the address in a Memory tab and changing the hex value directly.

After that, I continued the process. My edited Win Count was displayed in the game and I was given the achievement. Hooray!

Summary

Decompiling and debugging the game was quite a lot more work than fiddling values through Cheat Engine, but it provided quite a lot more control over the process, which was what was needed in this situation.