Files
wadamesh/deploy/apps/sdscan/1.0/sdscan.lua
T
Kaj SchittecatandClaude Opus 5 6868911f59 touch: SD Scan, find and remove Windows malware on the SD card
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>
2026-09-18 13:33:30 +02:00

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