mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-10-15 10:29:58 +00:00
Some ThinkNode M9 cards shipped with a dormant Windows worm (Elecrow security advisory, September 2026). An infected card seen since carries autorun.inf in the root, launching xlfqf.pif on open, explore and autoplay with random-junk comment lines in between: the Sality autorun pattern. - SD Scan store app (deploy/apps/sdscan/1.0, requires "sd", not seeded): walks the card a small page per tick, lists what it finds and why, and removes it after a confirmation screen with Cancel first. It says on every screen that it only removes files it recognises and that formatting the card is the safe fix. On older firmware it still finds threats by name but cannot remove them. - Firmware: wada.sd.check(path) and wada.sd.remove(path), plus paging for wada.sd.list(path, start, max) and caps().sd_clean. What counts as a threat lives in SdThreat.h: autorun.inf, Windows program, script and shortcut extensions, or a real MZ+PE header under any name. remove() classifies again in firmware and refuses anything else, so no app can use it to delete tiles, backups or chat history. It clears read-only, hidden and system first, because FAT refuses to delete a read-only file. - A warning when a card with Windows malware in its top folder is mounted, at boot or on insert, offering SD Scan (or the Store). - Tests: test/test_sd_threat.cpp, and SD Scan harness scenarios including the real infected card's root. Removal checked on a T-Deck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
14 KiB
Lua
378 lines
14 KiB
Lua
-- SD Scan: finds Windows malware on the SD card and removes it.
|
|
--
|
|
-- Written for the ThinkNode M9 SD-card worm (Elecrow security advisory,
|
|
-- September 2026). Some cards shipped with a hidden autorun.inf and Windows
|
|
-- programs disguised as shortcuts. They cannot run on a mesh radio, but they run
|
|
-- on the first Windows PC the card is plugged into, which is exactly where you
|
|
-- take a card to copy map tiles onto it.
|
|
--
|
|
-- What counts as a threat is decided by the FIRMWARE (wada.sd.check and
|
|
-- wada.sd.remove, SdThreat.h): autorun.inf, Windows programs, scripts and
|
|
-- shortcuts, and Windows programs hiding under another name. remove() refuses
|
|
-- anything else, so this app cannot delete map tiles, backups or chat history,
|
|
-- not even by mistake. On firmware without those two calls it falls back to the
|
|
-- same rules by file name, and can report but not remove.
|
|
--
|
|
-- The card is walked a page at a time from on_tick, so the screen stays live and
|
|
-- no callback comes near the instruction budget, however big the card is.
|
|
local ui, sys, sd, timer = wada.ui, wada.sys, wada.sd, wada.timer
|
|
local C = ui.colors
|
|
local tr = sys.tr or function(s) return s end
|
|
local app = {}
|
|
|
|
local caps = sys.caps()
|
|
local CAN_LIST = caps.sd_list and sd and sd.list and true or false
|
|
local CAN_CLEAN = CAN_LIST and caps.sd_clean and sd.check and sd.remove and true or false
|
|
|
|
-- Same lists as SdThreat.h. Only used to decide which files to ask the firmware
|
|
-- about, and as the whole rule on firmware that cannot check.
|
|
local KIND = {
|
|
exe = "Windows program", scr = "Windows program", com = "Windows program",
|
|
pif = "Windows program", cpl = "Windows program", msi = "Windows program",
|
|
msp = "Windows program", dll = "Windows program", jar = "Windows program",
|
|
bat = "Windows script", cmd = "Windows script", vbs = "Windows script",
|
|
vbe = "Windows script", js = "Windows script", jse = "Windows script",
|
|
wsf = "Windows script", wsh = "Windows script", hta = "Windows script",
|
|
ps1 = "Windows script", reg = "Windows script", msc = "Windows script",
|
|
lnk = "Windows shortcut", url = "Windows shortcut", scf = "Windows shortcut",
|
|
}
|
|
-- Pictures are never read for a hidden program: Windows opens them in an image
|
|
-- viewer whatever is inside, and a map card holds tens of thousands of them.
|
|
local MEDIA = { png = true, jpg = true, jpeg = true, webp = true, bmp = true, gif = true }
|
|
|
|
local function ext_of(name)
|
|
local e = name:match("%.([^.]+)$")
|
|
return e and e:lower() or nil
|
|
end
|
|
|
|
local function by_name(name)
|
|
if name:lower() == "autorun.inf" then return "autorun file" end
|
|
local e = ext_of(name)
|
|
return e and KIND[e] or nil
|
|
end
|
|
|
|
local function join(dir, name)
|
|
if dir == "/" then return "/" .. name end
|
|
return dir .. "/" .. name
|
|
end
|
|
|
|
local function numeric(name) return name:match("^%d+$") ~= nil end
|
|
|
|
local function fmt_n(n)
|
|
local s = tostring(n)
|
|
while true do
|
|
local t, k = s:gsub("^(%d+)(%d%d%d)", "%1,%2")
|
|
s = t
|
|
if k == 0 then return s end
|
|
end
|
|
end
|
|
|
|
-- ---- scan state --------------------------------------------------------------
|
|
local W, H
|
|
local S -- the current scan or clean-up
|
|
local screen = {} -- labels of the screen on show
|
|
|
|
-- Both queues are consumed from the front and freed as they go, so they keep an
|
|
-- explicit end index: `#` on a table with holes at the front is undefined, and a
|
|
-- border in the wrong place would drop or overwrite queued work.
|
|
local function new_scan(full)
|
|
return {
|
|
full = full,
|
|
dirs = { { path = "/", start = 1, depth = 0, num = false } },
|
|
di = 1, dn = 1, -- next folder to list, last folder queued
|
|
checks = {}, -- files waiting for the firmware to look at them
|
|
ci = 1, cn = 0, -- next file to check, last file queued
|
|
found = {}, -- { path, why }
|
|
files = 0, folders = 0, skipped = 0, errors = 0, busy = 0,
|
|
}
|
|
end
|
|
|
|
-- A tile tree is a folder of numbered folders (z/x/y.png). A quick scan lists the
|
|
-- first numbered level, so a numbered folder at the top of the card is still
|
|
-- looked into, but does not walk every tile below it.
|
|
local function add_dir(path, name, parent)
|
|
S.folders = S.folders + 1
|
|
local num = numeric(name)
|
|
if num and parent.num and not S.full then
|
|
S.skipped = S.skipped + 1
|
|
return
|
|
end
|
|
S.dn = S.dn + 1
|
|
S.dirs[S.dn] = { path = path, start = 1, depth = parent.depth + 1, num = num }
|
|
end
|
|
|
|
local function add_file(path, name)
|
|
S.files = S.files + 1
|
|
local why = by_name(name)
|
|
if CAN_CLEAN then
|
|
-- The firmware has the last word, also on names: it is the rule remove()
|
|
-- applies. Pictures are only judged by name.
|
|
if why or not MEDIA[ext_of(name) or ""] then
|
|
S.cn = S.cn + 1
|
|
S.checks[S.cn] = path
|
|
end
|
|
elseif why then
|
|
S.found[#S.found + 1] = { path = path, why = why }
|
|
end
|
|
end
|
|
|
|
local CHECKS_PER_TICK = 4 -- each one opens a file and reads its first bytes
|
|
local PAGE = 24 -- folder entries per tick; every entry is a file open
|
|
|
|
-- One unit of work: some firmware checks, or else one page of one folder.
|
|
-- Returns true when the whole card has been seen.
|
|
local function scan_step()
|
|
if S.ci <= S.cn then
|
|
local stop = math.min(S.cn, S.ci + CHECKS_PER_TICK - 1)
|
|
for i = S.ci, stop do
|
|
local path = S.checks[i]
|
|
S.checks[i] = nil
|
|
local why, err = sd.check(path)
|
|
if why then S.found[#S.found + 1] = { path = path, why = why }
|
|
elseif err and err ~= "not found" then S.errors = S.errors + 1 end
|
|
end
|
|
S.ci = stop + 1
|
|
return false
|
|
end
|
|
local d = S.dirs[S.di]
|
|
if not d then return true end
|
|
local entries, err = sd.list(d.path, d.start, PAGE)
|
|
if not entries then
|
|
if err == "busy" and S.busy < 200 then S.busy = S.busy + 1; return false end
|
|
if err == "no sd" then S.nocard = true; return true end
|
|
S.errors = S.errors + 1
|
|
S.dirs[S.di] = nil
|
|
S.di = S.di + 1
|
|
return false
|
|
end
|
|
S.busy = 0
|
|
for _, e in ipairs(entries) do
|
|
local p = join(d.path, e.name)
|
|
if e.type == "dir" then add_dir(p, e.name, d) else add_file(p, e.name) end
|
|
end
|
|
if entries.truncated and entries.next then
|
|
d.start = entries.next -- same folder, next page, next tick
|
|
else
|
|
S.dirs[S.di] = nil -- done with it; let the table go
|
|
S.di = S.di + 1
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ---- screens -------------------------------------------------------------------
|
|
local M = 6 -- page margin
|
|
-- A wrapped label at y. Returns the label and the y just below it, measured
|
|
-- where the firmware can measure so a long line on a narrow screen cannot run
|
|
-- into what follows.
|
|
local function text(s, y, size, col)
|
|
size = size or 12
|
|
local l = ui.label(s, M, y, size, col or C.text)
|
|
l:width(W - 2 * M)
|
|
local lines = ui.text_lines and ui.text_lines(s, W - 2 * M, size) or 1
|
|
local lh = ui.text_h and ui.text_h(size) or (size + 4)
|
|
return l, y + lines * lh + 4
|
|
end
|
|
|
|
-- Shown on every screen that could leave someone thinking the card is now clean:
|
|
-- this app knows the files it recognises, and only a format is certain.
|
|
local NOTICE = tr("SD Scan only removes files it recognises. If the card may be infected, the safest fix is to copy off what you need and format it completely: in Files, hold the SD card row.")
|
|
|
|
-- The y for a screen's buttons: the bottom edge, or just below the text when the
|
|
-- text needs more room than that (a small screen, a longer translation). The page
|
|
-- then scrolls instead of the text running under the buttons.
|
|
local function button_row(y)
|
|
local by = H - 40
|
|
if y + 6 > by then
|
|
by = y + 6
|
|
ui.scroll(true)
|
|
else
|
|
ui.scroll(false)
|
|
end
|
|
return by
|
|
end
|
|
|
|
local function short(path, max)
|
|
if #path <= max then return path end
|
|
return "..." .. path:sub(#path - max + 4)
|
|
end
|
|
|
|
local show_intro, show_results
|
|
|
|
local function progress_line()
|
|
return string.format(tr("%s files in %s folders"), fmt_n(S.files), fmt_n(S.folders))
|
|
end
|
|
|
|
local function show_scanning()
|
|
ui.clear()
|
|
sys.keep_awake(true)
|
|
local _, y = text(S.full and tr("Full scan...") or tr("Scanning..."), M, 16, C.accent)
|
|
screen.count, y = text(progress_line(), y, 12, C.text)
|
|
screen.where, y = text("/", y, 12, C.sub)
|
|
ui.button(tr("Stop"), M, button_row(y), 90, 32, function()
|
|
S.stopped = true
|
|
end)
|
|
end
|
|
|
|
local function finish_scan()
|
|
sys.keep_awake(false)
|
|
S.done = true
|
|
show_results()
|
|
end
|
|
|
|
local function remove_step()
|
|
local f = S.found[S.ri]
|
|
if not f then return true end
|
|
local why, err = sd.remove(f.path)
|
|
if why then S.removed = S.removed + 1
|
|
else f.err = err or "failed"; S.failed[#S.failed + 1] = f end
|
|
S.ri = S.ri + 1
|
|
return false
|
|
end
|
|
|
|
local function show_removing()
|
|
ui.clear()
|
|
sys.keep_awake(true)
|
|
S.removing, S.ri, S.removed, S.failed = true, 1, 0, {}
|
|
local _, y = text(tr("Removing..."), M, 16, C.accent)
|
|
screen.count = text("", y, 12, C.text)
|
|
end
|
|
|
|
local function show_removed()
|
|
ui.clear()
|
|
sys.keep_awake(false)
|
|
S.removing = false
|
|
local _, y = nil, M
|
|
if #S.failed == 0 then
|
|
_, y = text(string.format(tr("Removed %d files."), S.removed), y, 16, C.good)
|
|
_, y = text(NOTICE, y, 12, C.text)
|
|
else
|
|
_, y = text(string.format(tr("Removed %d, %d could not be removed."), S.removed, #S.failed),
|
|
y, 16, C.bad)
|
|
_, y = text(NOTICE, y, 12, C.text)
|
|
local lh = math.max(60, H - y - 46)
|
|
local l = ui.list(M, y, W - 2 * M, lh)
|
|
for _, f in ipairs(S.failed) do
|
|
l:add(short(f.path, 40) .. " (" .. f.err .. ")", function() sys.toast(f.path, 3000) end)
|
|
end
|
|
y = y + lh + 4
|
|
end
|
|
ui.button(tr("Scan again"), M, button_row(y), 120, 32, function() show_intro() end)
|
|
end
|
|
|
|
local function show_confirm()
|
|
ui.clear()
|
|
local n = #S.found
|
|
local _, y = text(string.format(tr("Remove %d files?"), n), M, 16, C.bad)
|
|
_, y = text(tr("They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched."),
|
|
y, 12, C.text)
|
|
-- Cancel first: on a keyboard-only board the first button has the focus, and
|
|
-- a stray Enter should not delete anything.
|
|
local by = button_row(y)
|
|
ui.button(tr("Cancel"), M, by, 100, 32, function() show_results() end)
|
|
ui.button(tr("Remove"), M + 110, by, 100, 32, function() show_removing() end)
|
|
end
|
|
|
|
show_results = function()
|
|
ui.clear()
|
|
sys.keep_awake(false)
|
|
local n = #S.found
|
|
local _, y = nil, M
|
|
if S.nocard then
|
|
_, y = text(tr("No SD card found."), y, 16, C.bad)
|
|
_, y = text(tr("Insert the card and scan again."), y, 12, C.text)
|
|
ui.button(tr("Scan again"), M, button_row(y), 120, 32, function() show_intro() end)
|
|
return
|
|
end
|
|
local how = S.stopped and tr("Stopped after ") or tr("Checked ")
|
|
if n == 0 then
|
|
_, y = text(tr("No known Windows malware found."), y, 16, C.good)
|
|
else
|
|
_, y = text(string.format(tr("%d threats found"), n), y, 16, C.bad)
|
|
end
|
|
local sub = how .. progress_line()
|
|
if S.skipped > 0 then
|
|
sub = sub .. string.format(tr(", map tiles skipped (%s folders)"), fmt_n(S.skipped))
|
|
end
|
|
if S.errors > 0 then sub = sub .. string.format(tr(", %d unreadable"), S.errors) end
|
|
_, y = text(sub, y, 12, C.sub)
|
|
if n == 0 then _, y = text(NOTICE, y, 12, C.text) end
|
|
local again_x = M
|
|
if n > 0 then
|
|
local note = not CAN_CLEAN and
|
|
tr("This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.")
|
|
local note_h = 0
|
|
if note then
|
|
note_h = (ui.text_lines and ui.text_lines(note, W - 2 * M, 12) or 3) * (ui.text_h and ui.text_h(12) or 16) + 4
|
|
end
|
|
-- The list takes the room that is left, but never less than a couple of rows:
|
|
-- below that the page scrolls instead.
|
|
local lh = math.max(60, H - 46 - y - note_h)
|
|
local l = ui.list(M, y, W - 2 * M, lh)
|
|
for _, f in ipairs(S.found) do
|
|
l:add(f.why .. ": " .. short(f.path, 40), function() sys.toast(f.path, 3000) end)
|
|
end
|
|
y = y + lh + 4
|
|
if note then _, y = text(note, y, 12, C.text) end
|
|
end
|
|
local by = button_row(y)
|
|
if n > 0 and CAN_CLEAN then
|
|
ui.button(tr("Remove all"), M, by, 110, 32, function() show_confirm() end)
|
|
again_x = M + 120
|
|
end
|
|
ui.button(tr("Scan again"), again_x, by, 110, 32, function() show_intro() end)
|
|
end
|
|
|
|
show_intro = function()
|
|
ui.clear()
|
|
sys.keep_awake(false)
|
|
S = nil
|
|
local _, y = text(tr("SD card malware check"), M, 16, C.accent)
|
|
if not CAN_LIST then
|
|
text(tr("This board has no SD card access for apps."), y, 12, C.bad)
|
|
ui.scroll(false)
|
|
return
|
|
end
|
|
_, y = text(tr("Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these."),
|
|
y, 12, C.text)
|
|
_, y = text(NOTICE, y, 12, C.text)
|
|
if not CAN_CLEAN then
|
|
_, y = text(tr("This firmware can only find them. Update wadamesh to remove them here."), y, 12, C.sub)
|
|
end
|
|
local by = button_row(y)
|
|
ui.button(tr("Scan"), M, by, 100, 32, function()
|
|
S = new_scan(false); show_scanning()
|
|
end)
|
|
ui.button(tr("Full scan"), M + 110, by, 110, 32, function()
|
|
S = new_scan(true); show_scanning()
|
|
end)
|
|
end
|
|
|
|
-- ---- app callbacks -----------------------------------------------------------
|
|
function app.on_open(w, h)
|
|
W, H = w, h
|
|
show_intro()
|
|
timer.every(50)
|
|
end
|
|
|
|
function app.on_tick()
|
|
if not S or S.done and not S.removing then return end
|
|
if S.removing then
|
|
if remove_step() then show_removed()
|
|
elseif screen.count then
|
|
screen.count:set(string.format(tr("%d of %d"), S.ri - 1, #S.found))
|
|
end
|
|
return
|
|
end
|
|
if S.stopped then return finish_scan() end
|
|
if scan_step() then return finish_scan() end
|
|
if screen.count then screen.count:set(progress_line()) end
|
|
local d = S.dirs[S.di]
|
|
if screen.where and d then screen.where:set(short(d.path, 44)) end
|
|
end
|
|
|
|
function app.on_close()
|
|
sys.keep_awake(false)
|
|
end
|
|
|
|
return app
|