It all started during a weekly Archipelago game with friends. It's a story as old as time: someone mentions a game, wouldn't it be funny if it had support for the buttplug sex toy control library? Haha, yeah. How hard could it be anyway? Alright, let's go cram some buttplug into Crypt of the Necrodancer.

After downloading the game for the first time, we take a look at the files. I'm using the Steam version, but it shouldn't really matter. My file listing looks like this:

$ ls
data/           essentia-2.0.zip   libfftw3f.so.3      libschroedinger-1.0.so.0  license.txt
fmod/           libavcodec.so.53   libglfw.so.2        libsteam_api.so           NecroDancer
NecroDancer64/  libavformat.so.53  libgsm.so.1         libtag.so.1               NecroDancer.sh
essentia        libavutil.so.51    libsamplerate.so.0  libyaml-0.so.2            steam_appid.txt

Looks like we're working with a native Linux game, very cool! What kind of symbols can we find in it?

$ objdump -t NecroDancer
<hundreds of lines>
08428060  w    F .text	0000005e              _ZN6StringC2Ev
08257590 g     F .text	00000056              _ZN12bb_dove_Dove8m_UpdateEv
080cc7f0 g     F .text	00000273              _ZN22bb_player_class_Player23m_FeetIgnoreWaterAndTarEv
08188460 g     F .text	00000533              _ZN14bb_level_Level17g_RenderExitArrowEv
081db7c0 g     F .text	000001b1              _ZN14bb_list_Node545g_newEPS_S0_P27bb_util_HighScoreSubmission
0811b1b0 g     F .text	00000005              _ZN22bb_tweenable_Tweenable5g_newEv
0807eb20 g     F .text	000001d8              _ZN18bb_flyaway_Flyaway8m_RenderEv
08082fc0 g     F .text	00000054              _ZN15bb_map_IntMap104markEv
081c4e60 g     F .text	00000017              _ZN26bb_blademaster_Blademaster6g_new2Ev
08427590  w    F .text	0000000f              _ZN14bb_list_Node26D0Ev
080dc450 g     F .text	000003a7              _ZN12bb_map_Map217m_Set24E6StringP14bb_point_Point
084cd3a0  w    O .rodata	00000015              _ZTS18bb_list_HeadNode19
080cad80 g     F .text	0000021a              _ZN16bb_weapon_Weapon7m_IsBowEv
0856a490 g     O .bss	00000004              _ZN40bb_advancelevel_callback_Stairs_callback9g_zoneValE
084cc968  w    O .rodata	00000016              _ZTS19bb_list_Enumerator4
0856a644 g     O .bss	00000004              _ZN12bb_item_Item12g_itemImagesE
08090c90 g     F .text	000000ab              _ZN14bb_list_Node209m_Remove3Ev
08433600  w    F .text	000000a5              _ZN43bb_sarcophagus_training_TrainingSarcophagusD0Ev
082b4470 g     F .text	000002b3              _ZN16bb_shrine_Shrine17m_GiveOutPainItemEii
084e1f00  w    O .rodata	0000005c              _ZTV21bb_blob_room_BlobRoom

We can tell by the symbol mangling that it's written in C++, and it looks like there's a ton of functions we can hook to do our bidding! I don't know about you, but it's a bit difficult for me to read these mangled symbol names. Helpfully, objdump's -C flag will demangle the names for us, along with whatever type information it can decode. Here's the same output as above, with names demangled:

$ objdump -tC NecroDancer
<hundreds of lines>
08428060  w    F .text	0000005e              String::String()
08257590 g     F .text	00000056              bb_dove_Dove::m_Update()
080cc7f0 g     F .text	00000273              bb_player_class_Player::m_FeetIgnoreWaterAndTar()
08188460 g     F .text	00000533              bb_level_Level::g_RenderExitArrow()
081db7c0 g     F .text	000001b1              bb_list_Node54::g_new(bb_list_Node54*, bb_list_Node54*, bb_util_HighScoreSubmission*)
0811b1b0 g     F .text	00000005              bb_tweenable_Tweenable::g_new()
0807eb20 g     F .text	000001d8              bb_flyaway_Flyaway::m_Render()
08082fc0 g     F .text	00000054              bb_map_IntMap10::mark()
081c4e60 g     F .text	00000017              bb_blademaster_Blademaster::g_new2()
08427590  w    F .text	0000000f              bb_list_Node26::~bb_list_Node26()
080dc450 g     F .text	000003a7              bb_map_Map21::m_Set24(String, bb_point_Point*)
084cd3a0  w    O .rodata	00000015              typeinfo name for bb_list_HeadNode19
080cad80 g     F .text	0000021a              bb_weapon_Weapon::m_IsBow()
0856a490 g     O .bss	00000004              bb_advancelevel_callback_Stairs_callback::g_zoneVal
084cc968  w    O .rodata	00000016              typeinfo name for bb_list_Enumerator4
0856a644 g     O .bss	00000004              bb_item_Item::g_itemImages
08090c90 g     F .text	000000ab              bb_list_Node20::m_Remove3()
08433600  w    F .text	000000a5              bb_sarcophagus_training_TrainingSarcophagus::~bb_sarcophagus_training_TrainingSarcophagus()
082b4470 g     F .text	000002b3              bb_shrine_Shrine::m_GiveOutPainItem(int, int)
084e1f00  w    O .rodata	0000005c              vtable for bb_blob_room_BlobRoom

Let's start out by writing a little hook in C. First, we need to find a symbol to hook. Something related to attacking would be a great option.

Note: C is useful for prototyping here because it gives us low-level control over things like symbol names, memory layout, and calling conventions. If we were to make a larger-scale mod, C++ may let us write prettier code without need for manually replicating C++ code generation, vtables, and name mangling.

$ objdump -tC NecroDancer | grep Attack
0856a3d9 g     O .bss	00000001              bb_soul_familiar_SoulFamiliar::g_hasPlayedAttackThisFrame
083cffc0 g     F .text	00000b0a              bb_player_class_Player::m_AttackDirection(int, bool)
081a8ed0 g     F .text	00000054              bb_soul_familiar_SoulFamiliar::g_CanAttackEnemy(bb_enemy_Enemy*)
08375e80 g     F .text	000009fd              bb_weapon_Weapon::m_Attack(bb_player_class_Player*, int, bool)
0826b260 g     F .text	000005c0              bb_player_class_Player::m_VocalizeAttack()
083a3560 g     F .text	00000512              bb_octoboss_Octoboss::m_DoAttackSplash()
0839f9d0 g     F .text	000009cf              bb_weapon_Weapon::m_AttackPoints(bb_player_class_Player*, int, bool, bb_list_List25*)

I'm going to choose bb_player_class_Player::m_AttackDirection(int, bool), since it seems the most pertinent. One thing to note here is that since this is a class method, it gets a sneaky hidden bb_player_class_Player *this argument at the start of its list. We also don't know its return type yet since it's not included in the mangled name, so I'm going to pretend it's void* until we know better. We'll also need the mangled symbol name. To get that, we can simply grep for the address:

$ objdump -t NecroDancer | grep 083cffc0
083cffc0 g     F .text	00000b0a              _ZN22bb_player_class_Player17m_AttackDirectionEib

We can use the information we've gained to write our hook. Let's call it buttmod.c.

#include <dlfcn.h>
#include <stdbool.h>
#include <stdio.h>

// Uninitialized reference to the original function
static void* (*_ZN22bb_player_class_Player17m_AttackDirectionEib_real)(void *this, int arg1, bool arg2);
// Our replacement that will be loaded in place of the original
void* _ZN22bb_player_class_Player17m_AttackDirectionEib(void *this, int arg1, bool arg2) {
  printf("Hooked call to bb_player_class_Player::m_AttackDirection(%p, %d, %d)\n", this, arg1, arg2);

  // If we haven't grabbed the address of the real function yet, do so now
  if (!_ZN22bb_player_class_Player17m_AttackDirectionEib_real) {
    // We specify RTLD_NEXT to skip over the hook function in our library
    _ZN22bb_player_class_Player17m_AttackDirectionEib_real = dlsym(RTLD_NEXT, "_ZN22bb_player_class_Player17m_AttackDirectionEib");
  }

  // Call the original and return its result
  return _ZN22bb_player_class_Player17m_AttackDirectionEib_real(this, arg1, arg2);
}

Note: void* pointers are used as opaque placeholders for unknown or undefined pointer types. They can be implicitly cast to any other pointer type, except function pointers (I'll show how to work with those a bit later).

Now we can compile it like so:

$ gcc -m32 -fPIC -shared -ldl -g buttmod.c -o buttmod.so

And run the game using it...

$ LD_PRELOAD=./buttmod.so ./NecroDancer.sh

...And the game crashes on startup, before we've even done anything?

I realize I haven't tried running the game on its own without Steam yet, so let's try that to make sure...

$ ./NecroDancer.sh

Yep, still broke.

Note: It seems like this is an issue related to Steam Cloud on the old 32-bit version of the game, and can be worked around with the arguments --no-steam-cloud --no-load, however our initial attempt still fails and this adventure ends up going a different direction.

Alright, how does Steam launch the game anyways? Let's dump the launch commandline. I put this in the game's launch arguments:

echo %command% > ~/cotn-launch.txt

and our output looks like this:

/home/problems/.local/share/Steam/ubuntu12_32/steam-launch-wrapper -- /home/problems/.local/share/Steam/ubuntu12_32/reaper SteamLaunch AppId=247080 -- /home/problems/.local/share/Steam/steamapps/common/SteamLinuxRuntime_soldier/_v2-entry-point --verb=waitforexitandrun -- /home/problems/.local/share/Steam/steamapps/common/SteamLinuxRuntime/scout-on-soldier-entry-point-v2 -- /home/problems/.local/share/Steam/steamapps/common/Crypt of the NecroDancer/NecroDancer64/NecroDancer

Trying to run that directly doesn't work, let's fix the quoting issue real quick... Wait, what's this NecroDancer64 directory? I must have missed that earlier. Let's take a peek!

$ ls NecroDancer64
dlc/  versions/   config.json		NecroDancer	NecroDancer.x64
lib/  Compat.wsp  gamecontrollerdb.txt	NecroDancer.wsp

Hmm, that's definitely different. Let's try running it without Steam's wrappers, just to see.

./NecroDancer

And it does work! It seems like we were just looking at the old 32-bit version. Let's see what the shiny new 64-bit version has for us!

$ objdump -tC NecroDancer.x64 | grep Attack

Nothing? What's the big deal? Scrolling through the symbol table doesn't seem to give me what I'm looking for, just a bunch of weird lua glue functions. Let's look at this new lib folder.

$ ls lib
libdiscord_game_sdk.so	libluajit-5.1.so.2    libsfml-graphics.so.2.5  libsfml-window.so.2.5
libFLAC.so.8		libnecrolevel.so      libsfml-network.so.2.5   libsteam_api.so
libGalaxy64.so		libsfml-audio.so.2.5  libsfml-system.so.2.5

This libnecrolevel.so seems interesting, maybe they moved the symbols we want into that library?

$ objdump -tC lib/libnecrolevel.so | grep Attack
000000000014c4d0 l     F .text	000000000000004e              c_SoulFamiliar::m_CanAttackEnemy(c_Enemy*)
000000000012e180 l     F .text	0000000000000722              c_Player::p_VocalizeAttack()
0000000000820708 l     O .bss	0000000000000001              c_SoulFamiliar::m_hasPlayedAttackThisFrame
00000000003b14b0 l     F .text	0000000000001474              c_Weapon::p_Attack(c_Player*, int, bool)
00000000003b0050 l     F .text	000000000000145d              c_Weapon::p_AttackPoints(c_Player*, int, bool, c_List22*)
000000000012f1f0 l     F .text	0000000000001426              c_Player::p_AttackDirection(int, bool)
000000000040bff0 l     F .text	0000000000000714              c_Octoboss::p_DoAttackSplash()

Bingo! The names have changed a bit, but our approach should still be sound.

#include <dlfcn.h>
#include <stdbool.h>
#include <stdio.h>

// Uninitialized reference to the original function
static void* (*_ZN8c_Player17p_AttackDirectionEib_real)(void *this, int arg1, bool arg2);
// Our replacement that will be loaded in place of the original
void* _ZN8c_Player17p_AttackDirectionEib(void *this, int arg1, bool arg2) {
  printf("Hooked call to bb_player_class_Player::m_AttackDirection(%p, %d, %d)\n", this, arg1, arg2);

  // If we haven't grabbed the address of the real function yet, do so now
  if (!_ZN8c_Player17p_AttackDirectionEib_real) {
    // We specify RTLD_NEXT to skip over the hook function in our library
    _ZN8c_Player17p_AttackDirectionEib_real = dlsym(RTLD_NEXT, "_ZN8c_Player17p_AttackDirectionEib");
  }

  // Call the original and return its result
  return _ZN8c_Player17p_AttackDirectionEib_real(this, arg1, arg2);
}

compile, and run:

$ gcc -fPIC -shared -ldl -g buttmod.c -o buttmod.so
$ LD_PRELOAD=./buttmod.so ./NecroDancer

Hmm, it runs, but nothing's getting printed when we attack an enemy! What the heck?

I started looking for symbols that were directly imported by NecroDancer.x64, since those would be sure to go through the dynamic linker. Here's a few interesting finds:

0000000000507680 g     F .text	0000000000000013              NecroLevel_freeLevel
0000000000507590 g     F .text	0000000000000015              NecroLevel_init
00000000005075b0 g     F .text	000000000000000f              NecroLevel_cleanUp
0000000000507630 g     F .text	0000000000000015              NecroLevel_generateLevel
0000000000507650 g     F .text	0000000000000030              NecroLevel_getLevelData
00000000005075c0 g     F .text	000000000000006e              NecroLevel_makeDefaultParameters

Of these, I decided to hook NecroLevel_init, which seems to receive a char* of XML data as its only argument. My hook for this actually does work! Using this as an entrypoint for my mod, I started playing around with alternative hooking methods.

Detour hooking is a method that involves overwriting the first few bytes of a function body with a jump to your hook. Since you're overwriting the function body directly, it doesn't matter how the function is called (via vtable lookup, dynamic linking, etc.). However it does have downsides. W^X restrictions mean that to install a hook, you have to make its memory region writeable, and restore its original permissions afterwards. Additionally, to call the original function, you'll have to uninstall your hook by restoring the original bytes that were overwritten by the detour, then reinstall it after the call. I decided to use libdetour for this, which handles all of that for you, including the platform-specific memory protection calls.

At this point I was tired of writing hooks for things that weren't getting called, so I was determined to hook the simplest thing that was absolutely sure to be called: the constructor for c_Player.

At this point, my buttmod.c looked something like this:

#include <dlfcn.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

#include "libdetour.h"

DETOUR_DECL_TYPE(void, _ZN8c_PlayerC2Ev, void*);

detour_ctx_t detour_ctx;

void c_Player_hook(void *this) {
  printf("c_Player::c_Player(%p)\n", this);
  DETOUR_ORIG_CALL(&detour_ctx, _ZN8c_PlayerC2Ev, this);
}

static void (*NecroLevel_init_real)(char *xml_data);
void NecroLevel_init(char *xml_data) {
  printf("NecroLevel_init(%ld bytes of XML data)\n", strlen(xml_data));

  if (!NecroLevel_init_real) {
    NecroLevel_init_real = dlsym(RTLD_NEXT, "NecroLevel_init");
  }

  detour_init(&detour_ctx, dlsym(RTLD_DEFAULT, "_ZN8c_PlayerC2Ev"), c_Player_hook);
  detour_enable(&detour_ctx);

  NecroLevel_init_real(xml_data);
}

Compile and run it (now including libdetour.c):

$ gcc -fPIC -shared -ldl -g buttmod.c libdetour.c -o buttmod.so
$ LD_PRELOAD=./buttmod.so ./NecroDancer
/home/problems/.steam/root/steamapps/common/Crypt of the NecroDancer/NecroDancer64/NecroDancer: line 4: 115165 Aborted                    ./NecroDancer.x64 "$@"

Ah crap, what's wrong now? Let's try running it in GDB to get some more info about the crash.

NecroLevel_init(249946 bytes of XML data)

Thread 24 "NecroDancer.x64" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7fff9be346c0 (LWP 115777)]
0x00007ffff5b7d7c0 in ?? () from /usr/lib64/libc.so.6
=> 0x00007ffff5b7d7c0:	48 8b 4c 16 f8     	mov    -0x8(%rsi,%rdx,1),%rcx
(gdb) i s
#0  0x00007ffff5b7d7c0 in ?? () from /usr/lib64/libc.so.6
#1  0x00007ffff7fbb40b in detour_init (ctx=0x7ffff7fbe040 <detour_ctx>, orig=0x0, 
    hook=0x7ffff7fbb219 <c_Player_hook>) at libdetour.c:97
#2  0x00007ffff7fbb305 in NecroLevel_init (
    xml_data=0x7fffc827c520 "<necrodancer>\n\t<items>\n\t\t<addchest_black levelEditor=\"False\" flyaway=\"|314|+1 BLACK CHEST PER RUN|\" hint=\"|314|+1 BLACK CHEST PER RUN|\" diamondCost=\"4\">addchest_black.png</addchest_black>\n\t\t<addchest_"...) at buttmod.c:25

Huh, the dlsym call failed and returned NULL... but why?

As it turns out, there can be symbols present in a library's symbol table that are not exported. We can read them with tools like objdump, but dlsym will pretend they don't exist, and we can forget about regular dynamic linking as well. I briefly considered linking in libelf for this, but I didn't care to spend more effort learning a new tool with nearly nonexistent documentation just for it to wind up not working again, so let's just calculate some offsets ourselves instead.

#define OFF__ZN8c_PlayerC2Ev 0xfc460
#define OFF_NecroLevel_init 0x507590
// ...
  detour_init(&detour_ctx, NecroLevel_init_real - OFF_NecroLevel_init + OFF__ZN8c_PlayerC2Ev, c_Player_hook);
// ...

...aaaaaaand...

NecroLevel_init(249946 bytes of XML data)
c_Player::c_Player(0x7fff3ca743d0)
c_Player::c_Player(0x7fff3d68af30)
c_Player::c_Player(0x7fff3c002130)

Hey, at least one thing works the way I expect! c_Player seems to be instantiated multiple times on a level load, which is somewhat interesting but there could be a number of reasons for that. Now that we have an instance of c_Player, maybe we can hook the vtable?

Vtable hooking is a bit cleaner than detour hooking, since it only involves overwriting pointers. From my explorations in Ghidra, I know that the vtable for c_Player is 163 pointers long and there's a function called p_Hit at index seven. To hook it, we just make a copy of the original vtable and overwrite the index we care about.

void **c_Player_vtable_real;
void *c_Player_vtable_hook[163];

typedef void* (*c_Player__p_Hit_real(void *this, char *arg1, int arg2, int arg3, void *entity, bool arg5, int arg6));
void* c_Player__p_Hit(void *this, char *arg1, int arg2, int arg3, void *entity, bool arg5, int arg6) {
  printf("c_Player::p_Hit(%p, %s, %d, %d, %p, %d, %d)\n", this, arg1, arg2, arg3, entity, arg5, arg6);
  return ((c_Player__p_Hit_real*)c_Player_vtable_real[7])(this, arg1, arg2, arg3, entity, arg5, arg6);
}

void c_Player_hook(void *this) {
  printf("c_Player::c_Player(%p)\n", this);
  DETOUR_ORIG_CALL(&detour_ctx, _ZN8c_PlayerC2Ev, this);

  c_Player_vtable_real = *(void***)this;
  memcpy(&c_Player_vtable_hook, c_Player_vtable_real, sizeof(c_Player_vtable_hook));
  c_Player_vtable_hook[7] = c_Player__p_Hit;
  *(void**)this = &c_Player_vtable_hook;
}

Note: c_Player__p_Hit_real is a typedef here instead of a static value. Instead of assigning a static address to the original function like before (which is still a valid way to do this) I wanted to demonstrate how a vtable pointer can be cast as a function pointer. I'll leave it as an exercise to the reader to think of scenarios where one technique would be preferred over the other.

Now it's hooked, but... oh no. It's not getting called. Now seriously, what the heck?!

At this point, my sanity and patience were both beginning to wane, so I decided to brute force fill the entire vtable with pointers to my own function. Of course, if any of them actually get called, it'll crash the game as we're not ensuring we use the right signatures at all... but at least we'll know.

  // c_Player_vtable_hook[7] = c_Player__p_Hit;
  for (int i = 0; i < 163; i++) {
    c_Player_vtable_hook[i] = c_Player__p_Hit;
  }

and yeah, sure enough:

c_Player::c_Player(0x7fff34a743d0)
c_Player::p_Hit(0x7fff34a743d0, (null), 872417504, -883685419, 0x20, 31, 883362224)

Thread 33 "NecroDancer.x64" received signal SIGSEGV, Segmentation fault.
[Switching to Thread 0x7fff7dc0f6c0 (LWP 129116)]
0x00007ffff774ff3c in c_Player::p_Hit(String, int, int, c_Entity*, bool, int) ()
   from lib/libnecrolevel.so
=> 0x00007ffff774ff3c <_ZN8c_Player5p_HitE6StringiiP8c_Entitybi+348>:	49 8b 1f           	mov    (%r15),%rbx
(gdb) i s
#0  0x00007ffff774ff3c in c_Player::p_Hit(String, int, int, c_Entity*, bool, int) ()
   from lib/libnecrolevel.so
#1  0x00007ffff7fbb2ad in c_Player__p_Hit (this=0x7fff34a743d0, arg1=0x0, arg2=872417504, 
    arg3=-883685419, entity=0x20, arg5=31, arg6=883362224) at buttmod.c:21
#2  0x00007ffff77703e3 in c_Player::m_new(int, int) () from lib/libnecrolevel.so

Now hold your horses, this isn't really much of a win. We don't even know which function it was trying to call, we threw all that information away when we overwrote every vtable index with the same function. Is there a decent way to figure that out? What will it even get me? At least I know that whatever it is, it's being called from c_Player::m_new...

At this point, something's starting to click. Functions that were included in the 32-bit version now being pulled out into this shared library, all the lua glue gunk, the dead code... Damn it.


Something I've avoided mentioning until now is that Crypt of the Necrodancer has an official modding API. The reason I've gone so far out of my way to avoid this is twofold:

  1. I took a look at the API docs, and there didn't seem to be any easy or clean way to do what I need.
  2. I don't own the Synchrony DLC, which is required for using the modding API.

What I failed to consider in this decision, however, is that in preparing the new version of the game for this DLC, the developers had entirely rewritten the game's logic in Lua. Yes, all the symbols I was looking for were still present in libnecrolevel.so, but as the name would suggest, it's merely a hollowed-out husk of the previous version of the game. Talk about necromancy!

Right, so after getting that DLC, let's see if we can break the lua sandbox!

Making a mod in Crypt of the Necrodancer is easy. All we have to do is go into the mod menu, hit "New Mod", and type in the name we want. I'll go with "NecroPlug" for this. It created a new directory in ~/.local/share/NecroDancer/mods/NecroPlug containing these files:

mod.json  NecroPlugBanner.png  NecroPlugIcon.png  NecroPlug.lua

Let's see what NecroPlug.lua looks like.

-- Press Shift+F1 to display debug output in-game
print("Hello world, this is NecroPlug!")

Alright, pretty simple. Let's see what happens if we try to import something useful, like ffi.

[Debug] [info] "Hello world, this is NecroPlug!"
[error] NecroPlug/NecroPlug.lua:3: Cannot require library 'ffi': Permission denied
stack traceback:
	[C]: in function 'error'
	scripts/core/ScriptLoader.lua:298: in function 'require'
	NecroPlug/NecroPlug.lua:3: in function 'scriptFunc'
	scripts/core/ScriptLoader.lua:496: in function 'loadScriptImpl'
	scripts/core/ScriptLoader.lua:545: in function <scripts/core/ScriptLoader.lua:544>
	[C]: in function 'xpcall'
	scripts/core/ScriptLoader.lua:544: in function 'loadScript'
	scripts/core/ScriptLoader.lua:600: in function 'loadAll'
	scripts/core/Init.lua:53: in main chunk

As expected, it doesn't let us do that. Maybe we can read the code and figure out how it's filtering imports? From a cursory glance through the game's files, I noticed an interesting file called config.json in the directory though, and it contains these very interesting looking keys:

        "scriptBlacklist": [
          "necro.mod.*"
        ],
        "scriptWhitelist": [
          "necro.*",
          "system.game.Audio",
          "system.game.Bitmap",
          "system.game.Entities",
          "system.game.FileIO",
          "system.game.Graphics",
          "system.game.Input",
          "system.network.IPC",
          "system.events.*",
          "system.gfx.*",
          "system.i18n.*",
          "system.utils.*"
        ]

However, adding ffi or os to the whitelist doesn't seem to get us anywhere. Back to the drawing board.

Looking back at the error, we can take note of where it's being thrown from. This isn't the standard Lua require function, it's a custom one defined in scripts/core/ScriptLoader.lua., grep -R ScriptLoader.lua reveals a match inside a binary file called NecroDancer.wsp. Let's take a look at that.

$ file NecroDancer.wsp
NecroDancer.wsp: data
$ binwalk3 NecroDancer.wsp

/home/problems/.local/share/Steam/steamapps/common/Crypt of the NecroDancer/NecroDancer64/NecroDancer.wsp
---------------------------------------------------------------------------------------------------------
DECIMAL                            HEXADECIMAL                        DESCRIPTION
---------------------------------------------------------------------------------------------------------
6440                               0x1928                             Copyright text: "copyright.png"
152009                             0x251C9                            PNG image, total size: 2879  bytes
307045                             0x4AF65                            PNG image, total size: 533 bytes
307636                             0x4B1B4                            PNG image, total size: 561 bytes
318351                             0x4DB8F                            PNG image, total size: 3617  bytes
430928                             0x69350                            PNG image, total size: 2030  bytes
536811                             0x830EB                            PNG image, total size: 1490  bytes
620552                             0x97808                            PNG image, total size: 1420  bytes
735896                             0xB3A98                            PNG image, total size: 1757  bytes
<many lines of PNG images>

Hmm, it seems to be some sort of asset bundle, but my tools don't recognize it. It may be an in-house archive format. A search for .wsp and the WSP16 bytes at the start of the file doesn't return anything, either. It may be possible to reverse engineer the format, but we should check for easier routes first. With our project open in Ghidra, can we find any functions related to Lua file loading? A quick search finds one: luaL_loadbuffer. Since this is imported from libluajit-5.1.so.2, we should be able to hook it easily with our LD_PRELOAD technique from earlier.

#include <dlfcn.h>
#include <stdbool.h>
#include <stdio.h>
#include <luajit-2.1/lua.h>
#include <luajit-2.1/lualib.h>
#include <stdlib.h>

static int (*luaL_loadbuffer_real)(lua_State* L, const char* buff, size_t sz, const char* name);
int luaL_loadbuffer(lua_State* L, const char* buff, size_t sz, const char* name) {
  printf("luaL_loadbuffer(%p, %s, %ld, %s)\n", L, buff, sz, name);
  if (!luaL_loadbuffer_real) {
    luaL_loadbuffer_real = dlsym(RTLD_NEXT, "luaL_loadbuffer");
  }

  char filename[256] = {0};
  FILE* file;

  snprintf(filename, 256, "dump/%s", name);
  file = fopen(filename, "w");
  if (file) {
    fwrite(buff, 1, sz, file);
    fclose(file);
  }

  return luaL_loadbuffer_real(L, buff, sz, name);
}

It should be noted that this will segfault if the directory it's trying to write the file into doesn't already exist. That's because I'm lazy and didn't want to implement recursive directory creation in C. I ran this under a debugger and manually created directories every time it segfaulted, which was good enough for the few iterations it took to get through it all.

Now we're left with a directory tree that looks like this:

dump
└── @core
    ├── jit
    │   ├── dump.lua
    │   └── p.lua
    ├── CoreUtils.lua
    ├── Debug.lua
    ├── DefaultScriptProvider.lua
    ├── DependencyGraph.lua
    ├── EventRegistry.lua
    ├── Init.lua
    ├── Inspect.lua
    ├── LoadingAnimation.lua
    ├── Logger.lua
    ├── ReadOnlyGlobal.lua
    ├── ScriptLoader.lua
    ├── ScriptProvider.lua
    ├── StandardLibrary.lua
    └── SystemEvents.lua

And what do you know, ScriptLoader.lua is right there! Let's take a peek.

@scripts/core/ScriptLoader.lua)-L
                                 �currentLoadScript�A*
fullNameX�DD�persistent globalVariablestblfuncglobalData
oldFunc�?&X�-2
              ��X�-X�32�L2�KL
                             �
                              persist
                                     loadercurrentLoadScriptglobalVariablestblkeyx
"|

  X�K4=-99BK�	nameremoveScriptHandlers
                                        exports




eventRegistryscript

Hmm, that's definitely not raw Lua. It's probably LuaJIT bytecode. Can we decompile it? I found this nifty little webapp that lets us drop compiled Lua files in and get the decompiled output. It may be missing a lot of variable names, but it's much more readable. Here's the decompiled function that's throwing our error:

local function r31_0(r0_16, r1_16)
  -- line: [292, 320] id: 16
  local r2_16, r3_16 = r24_0(r1_16)
  if r2_16 then
    if r3_16 and not r6_0.getPermissions(r0_16).allowUnsafeLibraries then
      error("Cannot require library \'" .. r1_16 .. "\': Permission denied", 2)
    end
    return r25_0(r1_16)
  else
    if not r6_0.scriptExists(r1_16) then
      error("Module \'" .. tostring(r1_16) .. "\' not found", 2)
    else
      local r4_16, r5_16 = r30_0(r0_16, r1_16)
      if not r4_16 then
        error("Cannot require module \'" .. r1_16 .. "\': " .. tostring(r5_16), 2)
      elseif r23_0(r1_16) then
        return r29_0(r0_16, r1_16)
      else
        return require(r1_16)
      end
    end
    return 
  end
end

Well, if we just remove that check, we should theoretically be able to import whatever we want. According to the documentation for luaL_loadbuffer, it should be able to accept either compiled bytecode or plain Lua. Let's modify our hook to load files from disk instead of dumping them out.

  snprintf(filename, 256, "load/%s", name);
  file = fopen(filename, "r");
  if (file) {
    fseek(file, 0, SEEK_END);
    sz = ftell(file);
    fseek(file, 0, SEEK_SET);
    buff = malloc(sz);
    fread((void*) buff, 1, sz, file);
    fclose(file);
  }

  return luaL_loadbuffer_real(L, buff, sz, name);

Save the decompiled Lua to load/@core/ScriptLoader.lua, and try to run the game.

[ScriptManager] [error] Error during initialization:

scripts/core/Init.lua:41: module 'core.ScriptLoader' not found:
	no field package.preload['core.ScriptLoader']
	no file './core/ScriptLoader.so'
	no file '/usr/local/lib/lua/5.1/core/ScriptLoader.so'
	no file '/usr/local/lib/lua/5.1/loadall.so'
stack traceback:
	[C]: in function 'require'
	scripts/core/Init.lua:41: in main chunk

Hmm, that didn't work. I don't know exactly why, but let's see if there's an alternative method for now.

I searched online for methods to patch LuaJIT bytecode, and found another very cool webapp. Let's drop our file in.

The LuaJIT Editor interface, showing a selectable list of "protos" and their properties

I'm not quite sure what a proto is, but I can read the names and strings! Let's try to find our reference to allowUnsafeLibraries.

A LuaJIT pseudo-assembly listing, showing references to strings such as "allowUnsafeLibraries" and "Permission denied"

It looks like we can also decompile a proto in the editor. It even has nicer names for intermediate variables!

A decompiled Lua function containing a check for if needPrivileges and not scriptProvider.getPermissions(sourceScript).allowUnsafeLibraries

Now that we've found that, how do we override it? These bytecode instructions are similar to assembly. I managed to find some documentation over here.

Since all instructions are fixed-length, if we can overwrite a single instruction it shouldn't change any of the surrounding context. TGETS moves a value from a table (argument 2), indexed by a string literal (argument 3), into the destination register (argument 1). If we want to skip that check, we can try replacing that with a constant operation like KPRI which stores a primitive (0 = nil, 1 = false, 2 = true). Let's try that:

The pseudo-assembly listing from before, with TGETS 4 4 1 ("allowUnsafeLibraries") replaced with KPRI 4 2 (true)

We can verify our change in the decompilation:

The decompiled Lua function with the check replaced with if not true

Nice! Let's go save our file as load/@core/ScriptLoader.lua and try it in game.

[LocalGame] [error] Error loading script 'NecroPlug.NecroPlug':
scripts/core/ScriptLoader.lua:167: module 'ffi' not found:
	no field package.preload['ffi']
	no file './ffi.so'
	no file '/usr/local/lib/lua/5.1/ffi.so'
	no file '/usr/local/lib/lua/5.1/loadall.so'
stack traceback:
	[C]: in function 'require'
	scripts/core/ScriptLoader.lua:167: in function 'require'
	NecroPlug/NecroPlug.lua:3: in function 'scriptFunc'
	scripts/core/ScriptLoader.lua:496: in function 'loadScriptImpl'
	scripts/core/ScriptLoader.lua:545: in function <scripts/core/ScriptLoader.lua:544>
	[C]: in function 'xpcall'
	scripts/core/ScriptLoader.lua:544: in function 'loadScript'
	scripts/core/ScriptLoader.lua:600: in function 'loadAll'
	scripts/system/mod/AssetReloader.lua:80: in function 'reloadAll'
	scripts/system/mod/AssetReloader.lua:126: in function 'func'
	scripts/system/events/AbstractSelector.lua:75: in function 'fire'
	scripts/system/game/Cycle.lua:65: in function <scripts/system/game/Cycle.lua:64>
	scripts/core/SystemEvents.lua:35: in function <scripts/core/SystemEvents.lua:28>
	[C]: in function 'xpcall'
	scripts/core/SystemEvents.lua:28: in function <scripts/core/SystemEvents.lua:25>

Hey, that's a different error! I'm not sure why ffi isn't being found, but maybe we can try a different restricted module, like os.

local os = require("os")
print(os)
[Debug] [info] "Hello world, this is NecroPlug!"
[Debug] [info] {
  <metatable> = {
    __index = {
      clock = <function 1>,
      date = <function 2>,
      difftime = <function 3>,
      execute = <function 4>,
      exit = <function 5>,
      getenv = <function 6>,
      remove = <function 7>,
      rename = <function 8>,
      setlocale = <function 9>,
      time = <function 10>,
      tmpname = <function 11>
    },
    __newindex = <function 12>
  }
}

Hey, it worked! We installed a hook to intercept resource loading, patched LuaJIT bytecode, and escaped the sandbox! Now we should have all the tools we need to hook up our mod to buttplug. How do we do that?

While poking around a bit, I discovered that print(require("package")) gets us a very interesting list of all loaded Lua modules and their exports, and look at this:

            ["core.StandardLibrary"] = {
              <metatable> = {
                __index = {
              <a bunch of functions>
                  print = <function 782>,
                  rawequal = <function 783>,
                  rawget = <function 784>,
                  rawset = <function 785>,
                  require = <function 786>,
                  select = <function 787>,
              <and even more functions>

Incredible! If we can just import core.StandardLibrary, we'll have access to the unfiltered require function without having to patch anything at all! Unfortunately it gives us this error:

NecroPlug/NecroPlug.lua:3: Cannot require module 'core.StandardLibrary': Permission denied
stack traceback:
	[C]: in function 'error'
	scripts/core/ScriptLoader.lua:311: in function 'require'
	NecroPlug/NecroPlug.lua:3: in function 'scriptFunc'

Note the subtle difference between this and the error before. This says "Cannot require module", not library like earlier. Do you remember the config.json we found, with the scriptWhitelist? Let's try adding core.* there now.

local StandardLibrary = require("core.StandardLibrary")
print(StandardLibrary.require("os"))
[Debug] [info] "Hello world, this is NecroPlug!"
[Debug] [info] {
  clock = <function 1>,
  date = <function 2>,
  difftime = <function 3>,
  execute = <function 4>,
  exit = <function 5>,
  getenv = <function 6>,
  remove = <function 7>,
  rename = <function 8>,
  setlocale = <function 9>,
  time = <function 10>,
  tmpname = <function 11>
}

Hooray! No hooking or patching required! Now, how do we connect to buttplug? LuaJIT has a way to call native code, through ffi, but it doesn't seem like we can use that here. It may not be built into the LuaJIT library packaged with the game, or it may be disabled somehow. I briefly tried LD_PRELOADing my system's libluajit but it didn't seem to make a difference. Either way, we can still try the normal Lua way of loading native code: require.

Using require on a native library will load the library and call a function called luaopen_{libname} on it, passing a Lua context. We can create a native Lua module in Rust (what buttplug is written in) using the mlua crate like so:

fn hello(_: &Lua) -> LuaResult<()> {
  println!("Hello from Rust!");
  Ok(())
}

#[mlua::lua_module]
fn libnecroplug_rs(lua: &Lua) -> LuaResult<LuaTable> {
  let exports = lua.create_table()?;
  exports.set("hello", lua.create_function(hello)?)?;
  Ok((exports))
}

With this, require("libnecroplug_rs") should return a Lua table with one function called "hello". Let's build it and drop libnecroplug_rs.so next to our NecroPlug.lua and try to call into it.

local StandardLibrary = require("core.StandardLibrary")
local NecroPlug = StandardLibrary.require("libnecroplug_rs")
NecroPlug.hello()
[LocalGame] [error] Error loading script 'NecroPlug.NecroPlug':
NecroPlug/NecroPlug.lua:4: module 'libnecroplug_rs' not found:
	no field package.preload['libnecroplug_rs']
	no file './libnecroplug_rs.so'
	no file '/usr/local/lib/lua/5.1/libnecroplug_rs.so'
	no file '/usr/local/lib/lua/5.1/loadall.so'
stack traceback:
	[C]: in function 'require'
	NecroPlug/NecroPlug.lua:4: in function 'scriptFunc'
	scripts/core/ScriptLoader.lua:496: in function 'loadScriptImpl'
	scripts/core/ScriptLoader.lua:545: in function <scripts/core/ScriptLoader.lua:544>

Uh oh, it tried to load our library but couldn't find it. It looked in the working directory, but that must not be our mod directory. How does the game normally resolve require imports? Let's go back to the decompiled wrapper/filter function. We can use a whole-file disassembly using the much nicer intermediate names provided by the LuaJIT Editor we used earlier.

Tracing the code from requireModule, we can see a call to scriptProvider.scriptExists, which is defined in our decompiled ScriptProvider.lua. We can trace the flow of execution to this function:

function resolve(scriptName)
	for i = 1, #providers do
		provider = providers[i]
		success, scriptPath = pcall(provider.resolve, scriptName)

		if success and scriptPath and bridge.res.resourceExists(scriptPath) then
			return scriptPath, provider
		end
	end
end

If we call require("core.ScriptProvider").resolve("NecroPlug.NecroPlug") we get "mods/NecroPlug/NecroPlug.lua". We can try StandardLibrary.require("mods/NecroPlug/libnecroplug_rs"), but it doesn't work because this is not a complete path relative to the working directory. The absolute path would be /home/problems/.local/share/NecroDancer/mods/NecroPlug. We could hardcode this directory, but for optimal compatibility particularly across operating systems it's best to use or mimic what the game does as best as possible.

At this point, I started looking into what other mods were doing. My first instinct was to check if an Archipelago mod exists for the game. Since Archipelago support requires communication with an external client, I knew that they would be doing some similarly tricky things to enable that. I grabbed the Archipelago Redux mod and started poking through its code, starting with scripts/client/APConnection.lua. The first thing that stood out to me was this interesting import:

local hasStorage, storage = pcall(require, "system.file.Storage")

I didn't know exactly why they were doing this pcall method of calling require, and I hadn't seen this API used elsewhere. I decided to print out the contents of the module to see what it contained:

print(require("system.file.Storage"))
[Debug] [info] {
  <metatable> = {
    __index = <function 1>,
    __newindex = <function 2>
  }
}

Hmm, it's unclear how to use it. How is it used in Archipelago Redux?

if hasStorage and not (APConnection.storage and next(APConnection.storage)) then
    APConnection.storage = storage.new("archipelago")
    APConnection.initFiles()
end

Ah, it wants to be instantiated with a name. What does the name do? Let's see what we get if we print(require("system.file.Storage").new("NecroPlug")):

[Debug] [info] {
  <metatable> = {
    __index = {
      createDirectory = <function 1>,
      delete = <function 2>,
      exists = <function 3>,
      getAbsolutePath = <function 4>,
      getInfo = <function 5>,
      getLocation = <function 6>,
      getPath = <function 7>,
      getRealPath = <function 8>,
      isDirectory = <function 9>,
      isFile = <function 10>,
      list = <function 11>,
      mount = <function 12>,
      move = <function 13>,
      openWithSystemHandler = <function 14>,
      peekPackage = <function 15>,
      readFile = <function 16>,
      unmount = <function 17>,
      writeFile = <function 18>,
      writeFileAsync = <function 19>
    },
    __newindex = <function 20>
  }
}

Much more useful! Let's see what happens if we call getAbsolutePath():

[Debug] [info] "/home/problems/.local/share/NecroDancer/NecroPlug/"

This tells me that the argument given is a path within ~/.local/share/NecroDancer. What if we combine this with our previous finding?

print(Storage.new().getAbsolutePath(ScriptProvider.resolve("NecroPlug.NecroPlug")))
[Debug] [info] "/home/problems/.local/share/NecroDancer/mods/NecroPlug/NecroPlug.lua"

And we've just solved the issue of getting the absolute path to our script! Putting it all together, we end up with this:

local StandardLibrary = require "core.StandardLibrary"
local ScriptProvider = require "core.ScriptProvider"
local Storage = require "system.file.Storage"
local native_path = Storage.new().getAbsolutePath(
  ScriptProvider.resolve("NecroPlug.NecroPlug"):gsub("NecroPlug.lua", "libnecroplug_rs.so")
)
local NecroPlug = StandardLibrary.package.loadlib(native_path, "luaopen_libnecroplug_rs")()

Note: The usage of loadlib instead of require is due to the fact that require uses a particular kind of search string for modules, which relies on string substitutions. It does not accept absolute paths, so this is a usable alternative. If we could figure out how to modify the search path it uses, we could use that instead. However, naively trying to modify StandardLibrary.package.path does not appear to affect the behavior of StandardLibrary.require, so we're stuck with this slightly uglier invocation.

The rest of the mod is fairly standard buttplug and Lua API usage, hooking up game events to vibration triggers, so I won't expand on the details of that. If you're interested, you can check out the finished mod on GitHub.