Compare commits

...
2 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 803a4ebfc7 release: beta_82 notes
SD Scan for the ThinkNode M9 SD-card worm, the firmware calls behind it,
and the startup warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:34:09 +02:00
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
24 changed files with 1674 additions and 21 deletions

No files matched your search

+4 -3
View File
@@ -17,9 +17,10 @@ API — `wada.ui` (widgets, colours, a text prompt), `wada.sys`, `wada.store`
(persistence), `wada.timer`, and on the larger boards `wada.fs`, `wada.net`,
`wada.crypto` and the writable half of `wada.mesh`. Supported boards also expose
asynchronous WAV/MP3 playback through `wada.audio`; plain filenames come from the
app sandbox on internal flash, SD, or SD_MMC. Read-only physical-SD directory
metadata is available through `wada.sd`, and direct card playback uses an explicit
`sd:/...` path. There is no general-purpose filesystem or network access; the API
app sandbox on internal flash, SD, or SD_MMC. Physical-SD directory metadata
is available through `wada.sd`, which can also find and delete Windows malware on the
card (the firmware decides what counts, so it cannot delete anything else), and
direct card playback uses an explicit `sd:/...` path. There is no general-purpose filesystem or network access; the API
is the whole surface, which is what makes reviewing tractable. It is documented at
[wadamesh.com/sdk.html](https://wadamesh.com/sdk.html).
+8
View File
@@ -50,6 +50,14 @@
"ver": "1.0",
"desc": "Compass rose: magnetometer or GPS course, live fix, bearing to contacts",
"seed": false
},
{
"id": "sdscan",
"name": "SD Scan",
"ver": "1.0",
"desc": "Finds and removes Windows malware on the SD card, like the M9 worm.",
"requires": "sd",
"seed": false
}
]
}
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1246,3 +1246,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1249,3 +1249,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+27
View File
@@ -1267,3 +1267,30 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1249,3 +1249,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1247,3 +1247,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1249,3 +1249,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1251,3 +1251,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+28
View File
@@ -1250,3 +1250,31 @@ strongest first strongest first
then stop then stop
this device this device
waiting for a GPS fix - samples are discarded until then waiting for a GPS fix - samples are discarded until then
This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card. This SD card has Windows malware on it, like the worm some ThinkNode M9 cards shipped with. It cannot run on this radio, but it will on a Windows PC. Before the card goes in a computer, remove it with SD Scan or, safest, format the card.
Open SD Scan Open SD Scan
%d of %d %d of %d
%d threats found %d threats found
%s files in %s folders %s files in %s folders
, %d unreadable , %d unreadable
, map tiles skipped (%s folders) , map tiles skipped (%s folders)
Checked Checked
Full scan Full scan
Full scan... Full scan...
Insert the card and scan again. Insert the card and scan again.
No SD card found. No SD card found.
Remove %d files? Remove %d files?
Remove all Remove all
Removed %d files. Removed %d files.
Removed %d, %d could not be removed. Removed %d, %d could not be removed.
Removing... Removing...
SD card malware check SD card malware check
Scan Scan
Scanning... Scanning...
Stopped after Stopped after
They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched. They are Windows programs, scripts, shortcuts or autorun files. A mesh radio never uses them, and nothing else on the card is touched.
This board has no SD card access for apps. This board has no SD card access for apps.
This firmware can only find them. Update wadamesh to remove them here. This firmware can only find them. Update wadamesh to remove them here.
Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these. Finds and removes Windows programs, scripts, shortcuts and autorun files, like the worm on some ThinkNode M9 cards. A mesh radio never uses these.
No known Windows malware found. No known Windows malware found.
This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row. This firmware can find these but not remove them. Update wadamesh, or format the card: in Files, hold the SD card row.
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. 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.
+1
View File
@@ -0,0 +1 @@
{"id":"sdscan","name":"SD Scan","ver":"1.0","desc":"Finds and removes Windows malware on the SD card, like the M9 worm.","icon":"search"}
+377
View File
@@ -0,0 +1,377 @@
-- 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
+8 -5
View File
@@ -466,13 +466,16 @@ end</code></pre>
<section class="doc" id="sd">
<h2>wada.sd <span class="chip">sd_list</span></h2>
<div class="row"><div class="prose">
<p>Read-only directory access to the physical SD card. This is separate from <a href="#fs">wada.fs</a>: it can inspect the card's directory tree, but cannot read file contents, write, rename or delete anything. Check <code>wada.sys.caps().sd_list</code> before using it.</p>
<p>Directory access to the physical SD card. This is separate from <a href="#fs">wada.fs</a>: it can inspect the card's directory tree, but cannot read file contents, write or rename anything. The one thing it can delete is Windows malware, and the firmware, not the app, decides what that is. Check <code>wada.sys.caps().sd_list</code> before using it, and <code>caps().sd_clean</code> before using <code>check</code>, <code>remove</code> or the paging arguments.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.sd.list([path])</td><td>Returns an array of <code>{name, type, size, mtime}</code>, or <code>nil, error</code>. <code>path</code> defaults to <code>/</code>. <code>type</code> is <code>"file"</code> or <code>"dir"</code>; directories report size 0, and <code>mtime</code> is 0 when the filesystem has no timestamp.</td></tr>
<tr><td>wada.sd.list([path [, start [, max]]])</td><td>Returns an array of <code>{name, type, size, mtime}</code>, or <code>nil, error</code>. <code>path</code> defaults to <code>/</code>. <code>type</code> is <code>"file"</code> or <code>"dir"</code>; directories report size 0, and <code>mtime</code> is 0 when the filesystem has no timestamp. <code>start</code> (default 1) is the index of the first entry to return and <code>max</code> (1 to 192, default 192) the page size. <span class="chip">sd_clean</span></td></tr>
<tr><td>wada.sd.check(path)</td><td>Whether a file on the card is Windows malware: a reason string (<code>"autorun file"</code>, <code>"Windows program"</code>, <code>"Windows script"</code>, <code>"Windows shortcut"</code> or <code>"renamed Windows program"</code>), <code>false</code> when it is not one, or <code>nil, error</code>. <span class="chip">sd_clean</span></td></tr>
<tr><td>wada.sd.remove(path)</td><td>Deletes the file only if <code>check</code> would name it, and returns that reason. Anything else returns <code>nil, "not a threat"</code>, and directories are never removed. Ask the user before calling it. <span class="chip">sd_clean</span></td></tr>
</table>
<p>Paths are absolute from the card root and at most 191 bytes. Empty segments, trailing slashes, backslashes, <code>.</code> and <code>..</code> are rejected with <code>nil, "bad path"</code>. A missing or unreadable card returns <code>nil, "no sd"</code>; <code>"busy"</code> means another storage operation is changing the card lifecycle, so retry later. Missing paths and files used as directories return <code>"not found"</code> and <code>"not a directory"</code>.</p>
<p>One call returns at most 192 entries. When more exist, the returned array also has <code>entries.truncated == true</code>. Listing may mount an inserted card through the firmware's existing SD lifecycle, but never formats or modifies it.</p>
<p>Paths are absolute from the card root and at most 191 bytes. Empty segments, trailing slashes, backslashes, <code>.</code> and <code>..</code> are rejected with <code>nil, "bad path"</code>. A missing or unreadable card returns <code>nil, "no sd"</code>; <code>"busy"</code> means another storage operation is changing the card lifecycle, so retry later. Missing paths and files used as directories return <code>"not found"</code> and <code>"not a directory"</code>; <code>check</code> and <code>remove</code> on a directory return <code>"not a file"</code>.</p>
<p>One call returns at most <code>max</code> entries. When more exist, the returned array also has <code>entries.truncated == true</code> and <code>entries.next</code>, the <code>start</code> of the next page. Every entry costs the card a file open, so a small page (the SD Scan app uses 24) keeps the screen responsive in a folder of map tiles. Listing may mount an inserted card through the firmware's existing SD lifecycle, but never formats or modifies it.</p>
<p>What counts as malware is fixed in firmware (<code>src/ui-touch/SdThreat.h</code>): a file named <code>autorun.inf</code>; a Windows program, script or shortcut by extension (<code>.exe .scr .com .pif .cpl .msi .msp .dll .jar</code>, <code>.bat .cmd .vbs .vbe .js .jse .wsf .wsh .hta .ps1 .reg .msc</code>, <code>.lnk .url .scf</code>); or any file with a real Windows program header, whatever its name. A mesh radio uses none of these, so an app can offer to remove them, but it can never use <code>remove</code> to delete map tiles, backups or anything else on the card. It exists for the SD-card worm some ThinkNode M9 cards shipped with.</p>
</div></div>
</section>
@@ -488,7 +491,7 @@ end</code></pre>
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
<tr><td>wada.sys.tr(s)</td><td>Translate <code>s</code> through the device's active language, using the same table the firmware's own interface uses. Returns <code>s</code> unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to <code>deploy/apps/lang/&lt;code&gt;.lang</code> alongside the firmware's. Older firmware has no <code>sys.tr</code>: write <code>local tr = sys.tr or function(x) return x end</code> and call <code>tr()</code>.</td></tr>
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span>, <code>sd_list</code> before using <code>wada.sd</code>, and <code>audio</code> plus the format flag before using <code>wada.audio</code>. <code>audio_sd</code> means direct <code>sd:</code> paths are available; plain audio names use app storage and do not require it.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, sd_clean, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span>, <code>sd_list</code> before using <code>wada.sd</code>, and <code>audio</code> plus the format flag before using <code>wada.audio</code>. <code>audio_sd</code> means direct <code>sd:</code> paths are available; plain audio names use app storage and do not require it.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> &mdash; does this board <i>have</i> the sensor rail &mdash; and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}</code>, or <code>nil</code> with no fix &mdash; which is the normal indoor case, so handle it. <code>lat_e6</code>/<code>lon_e6</code> are exact micro-degrees; <code>lat</code>/<code>lon</code> are single-precision floats, so <a href="#discover">log the integers</a>. <code>alt_m</code> is metres. <code>time</code> is satellite time, absent until the receiver has decoded the date. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) appear only on boards whose GPS provider reports them, and <code>course</code> only while actually moving &mdash; a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional.</td></tr>
+15
View File
@@ -0,0 +1,15 @@
# beta_82 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- ThinkNode M9 SD cards: Windows malware -------------------------------------------------------
Some ThinkNode M9 cards shipped with a Windows worm on them (Elecrow security advisory, September 2026). It cannot run on the radio, but it runs on the first Windows PC the card is put into, which is exactly where you take the card to copy map tiles. On the infected cards seen so far it is a hidden autorun.inf and a program called xlfqf.pif in the top folder of the card.
New in the Store: SD Scan. It looks through the SD card for Windows programs, scripts, shortcuts and autorun files, shows what it found and why, and removes it after you confirm. A mesh radio never uses any of these files, and nothing else on the card is touched.
SD Scan only removes files it recognises. If a 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.
When a card with Windows malware in its top folder is in the radio at startup, or is inserted, wadamesh now warns you and offers to open SD Scan.
If your M9 card has already been in a Windows PC, run a full virus scan on that PC as well.
# --- For app authors ------------------------------------------------------------------------------
wada.sd.check and wada.sd.remove find and delete Windows malware on the card. The firmware decides what counts, and remove refuses everything else, so no app can use it to delete anything else on the card. wada.sd.list can now page through big folders. All documented at wadamesh.com/sdk.html.
# --- For testers ----------------------------------------------------------------------------------
SD Scan is tested on a T-Deck. M9 owners: worth reporting whether the scan, the removal and the startup warning all work with the M9's keys.
+473 -7
View File
@@ -86,11 +86,11 @@ local function mklabel(text, x, y, size, col)
checkstr(text, "label text"); if x then checkint(x, "label x") end; if y then checkint(y, "label y") end
if size then checkint(size, "label size") end; checkcol(col, "label")
widgets.labels = widgets.labels + 1
local l = { text = tostring(text), x = x, y = y }
local l = { text = tostring(text), x = x, y = y, size = size }
function l:set(s) checkstr(s, "label:set"); l.text = tostring(s) end
function l:pos(px, py) checkint(px, "label:pos"); checkint(py, "label:pos") end
function l:color(c) checkcol(c, "label:color") end
function l:width(w) checkint(w, "label:width") end
function l:width(w) checkint(w, "label:width"); l.w = w end
labels[#labels + 1] = l
return l
end
@@ -104,6 +104,151 @@ local function mkbutton(text, x, y, w, h, fn)
return b
end
-- wada.ui.list: rows with optional callbacks, same argument checks as uiList/lsAdd
local lists = {}
local function mklist(x, y, w, h)
checkint(x, "list x"); checkint(y, "list y"); checkint(w, "list w"); checkint(h, "list h")
assert(w > 0 and h > 0, "list: width and height must be positive")
local l = { rows = {}, x = x, y = y, w = w, h = h }
function l:add(text, fn)
checkstr(text, "list:add text"); assert(fn == nil or type(fn) == "function", "list:add fn")
l.rows[#l.rows + 1] = { text = tostring(text), fn = fn }; return #l.rows
end
function l:set(i, text) checkint(i, "list:set"); checkstr(text, "list:set"); if l.rows[i] then l.rows[i].text = tostring(text) end end
function l:color(i, c) checkint(i, "list:color"); checkcol(c, "list:color") end
function l:clear() l.rows = {} end
function l:count() return #l.rows end
function l:select(i) checkint(i, "list:select") end
function l:selected() return 0 end
function l:pos(px, py) checkint(px, "list:pos"); checkint(py, "list:pos") end
lists[#lists + 1] = l
return l
end
-- ---- mock SD card: mirrors wada.sd in LuaAppHost.cpp -------------------------
-- cfg.sdtree is built by mktree(): a dir is { dir = {name -> node}, order = {names} },
-- a file is { data = "bytes" }. Paging, path rules, error strings and the threat
-- policy (SdThreat.h) all follow the firmware, so an app tested here behaves the
-- same on a card.
local SD_PAGE_MAX = 192
local SD_THREAT_EXT = {
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",
}
local function sd_safe(path)
if type(path) ~= "string" or path == "" or path:sub(1, 1) ~= "/" or #path >= 192 then return false end
if #path > 1 and path:sub(-1) == "/" then return false end
if path:find("[\0-\31\127\\]") or path:find("//", 1, true) then return false end
for seg in path:gmatch("[^/]+") do if seg == "." or seg == ".." then return false end end
return true
end
local function sd_node(path)
local node = cfg.sdtree or { dir = {}, order = {} }
for seg in path:gmatch("[^/]+") do
if not node.dir then return nil end
node = node.dir[seg]
if not node then return nil end
end
return node
end
local function sd_classify(path, node)
local name = path:match("[^/]+$")
if name:lower() == "autorun.inf" then return "autorun file" end
local e = name:match("%.([^.]+)$")
if e and SD_THREAT_EXT[e:lower()] then return SD_THREAT_EXT[e:lower()] end
local d = node.data
if #d >= 64 and d:sub(1, 2) == "MZ" then
local off = d:byte(0x3D) | (d:byte(0x3E) << 8) | (d:byte(0x3F) << 16) | (d:byte(0x40) << 24)
if off >= 64 and off <= 65536 and off <= #d - 4 and d:sub(off + 1, off + 4) == "PE\0\0" then
return "renamed Windows program"
end
end
return nil
end
local function sd_file(path)
if not sd_safe(path) or path == "/" then return nil, "bad path" end
if cfg.sd_nocard then return nil, "no sd" end
local node = sd_node(path)
if not node then return nil, "not found" end
if node.dir then return nil, "not a file" end
return node
end
local function sdmock_list(path, start, max)
if path == nil then path = "/" end
checkstr(path, "sd.list path")
start = start or 1; checkint(start, "sd.list start")
assert(start >= 1, "sd.list: start must be 1 or more")
max = max or SD_PAGE_MAX; checkint(max, "sd.list max")
assert(max >= 1 and max <= SD_PAGE_MAX, "sd.list: max must be 1..192")
if not sd_safe(path) then return nil, "bad path" end
if cfg.sd_nocard then return nil, "no sd" end
local node = sd_node(path)
if not node then return nil, "not found" end
if not node.dir then return nil, "not a directory" end
cfg.sd_calls = cfg.sd_calls or {}
cfg.sd_calls[#cfg.sd_calls + 1] = { path = path, start = start, max = max }
local out, count, i = {}, 0, start
while node.order[i] do
if count >= max then out.truncated = true; out.next = start + count; break end
local name = node.order[i]
local child = node.dir[name]
count = count + 1
out[count] = { name = name, type = child.dir and "dir" or "file",
size = child.data and #child.data or 0, mtime = 0 }
i = i + 1
end
return out
end
local function sdmock_check(path)
checkstr(path, "sd.check path")
local node, err = sd_file(path)
if not node then return nil, err end
return sd_classify(path, node) or false
end
local function sdmock_remove(path)
checkstr(path, "sd.remove path")
local node, err = sd_file(path)
if not node then return nil, err end
local why = sd_classify(path, node)
if not why then return nil, "not a threat" end
if cfg.sd_remove_fail and cfg.sd_remove_fail[path] then return nil, cfg.sd_remove_fail[path] end
local parent = sd_node(path:match("^(.*)/[^/]+$") ~= "" and path:match("^(.*)/[^/]+$") or "/")
local name = path:match("[^/]+$")
parent.dir[name] = nil
for i, n in ipairs(parent.order) do if n == name then table.remove(parent.order, i); break end end
cfg.sd_removed = cfg.sd_removed or {}
cfg.sd_removed[#cfg.sd_removed + 1] = path
return why
end
-- Build a card from { ["/path/file"] = "bytes" | true (an empty folder) }.
local function mktree(files)
local root = { dir = {}, order = {} }
local paths = {}
for path in pairs(files) do paths[#paths + 1] = path end
table.sort(paths)
for _, path in ipairs(paths) do
local node, segs = root, {}
for seg in path:gmatch("[^/]+") do segs[#segs + 1] = seg end
for i, seg in ipairs(segs) do
if not node.dir[seg] then
local is_dir = i < #segs or files[path] == true
node.dir[seg] = is_dir and { dir = {}, order = {} } or { data = files[path] }
node.order[#node.order + 1] = seg
end
node = node.dir[seg]
end
end
return root
end
local function sd_exists(path) return sd_node(path) ~= nil end
local function build_wada()
local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} }
wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B }
@@ -119,6 +264,18 @@ local function build_wada()
return math.floor(n * ((sz or 12) + 4) * 0.48)
end
wada.ui.chart = function() error("chart not mocked") end
wada.ui.list = mklist
wada.ui.clear = function() labels, buttons, lists = {}, {}, {}; widgets.cleared = (widgets.cleared or 0) + 1 end
wada.ui.text_lines = function(str, width, sz)
checkstr(str, "text_lines"); checkint(width, "text_lines width")
if width <= 0 then return 1 end
local lines = 0
for part in (tostring(str) .. "\n"):gmatch("(.-)\n") do
local w = wada.ui.text_w(part, sz or 14)
lines = lines + math.max(1, (w + width - 1) // width)
end
return lines
end
wada.sys.millis = function() return clock_ms end
wada.sys.keep_awake = function(on) cfg.awake = (on == nil) or on end
@@ -132,6 +289,7 @@ local function build_wada()
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
accel = cfg.caps.accel, discover = cfg.caps.discover,
sd_list = cfg.caps.sd_list or false,
sd_clean = cfg.caps.sd_clean or false,
audio = cfg.caps.audio or false,
audio_wav = cfg.caps.audio or false,
audio_mp3 = cfg.caps.audio or false,
@@ -210,10 +368,8 @@ local function build_wada()
end
end
if cfg.caps.sd_list then
wada.sd = { list = function(path)
assert(path == "/", "mock SD only exposes the root")
return {}
end }
wada.sd = { list = sdmock_list }
if cfg.caps.sd_clean then wada.sd.check = sdmock_check; wada.sd.remove = sdmock_remove end
end
return wada
end
@@ -247,6 +403,7 @@ end
local function reset_world()
widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 }
lists = {}
clock_ms = 1000
audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
audio_log = {}
@@ -887,7 +1044,316 @@ scenarios.cost = function()
assert(worst < BUDGET / 4, "tick too expensive")
end
local order = APP_PATH:find("/wardrive/", 1, true)
-- ---- SD Scan (deploy/apps/sdscan) --------------------------------------------
local PNG = "\137PNG\r\n\26\n" .. string.rep("\0", 120)
-- A file carrying real DOS + PE headers and nothing else: no code at all.
local function pe_bytes(n, e_lfanew)
e_lfanew = e_lfanew or 0x80
local t = {}
for i = 1, n do t[i] = "\0" end
t[1], t[2] = "M", "Z"
for k = 0, 3 do t[0x3C + 1 + k] = string.char((e_lfanew >> (8 * k)) & 0xFF) end
t[e_lfanew + 1], t[e_lfanew + 2] = "P", "E"
return table.concat(t)
end
local NBSP = "\194\160" -- the worm's blank-looking folder name
-- The M9 card as the advisory describes it, plus wadamesh's own data and tiles.
local function infected_card(extra)
local f = {
["/autorun.inf"] = "[autorun]\r\nopen=" .. NBSP .. "\\svchost.exe\r\n",
["/" .. NBSP .. "/svchost.exe"] = pe_bytes(512),
["/tiles.lnk"] = "L\0\0\0\1\20\2\0" .. string.rep("\0", 80),
["/RECYCLER/S-1-5-21-1004/desktop.dat"] = pe_bytes(300, 0x78), -- a program under a harmless name
["/copyright.png"] = PNG,
["/test file.txt"] = "test",
["/wadamesh/contacts3"] = "MZ" .. string.rep("\165", 200), -- starts with MZ by chance
["/wadamesh/ui_threads_v1.bin"] = string.rep("\1", 300),
["/wadamesh/lang/hu.lang"] = "# ver: 22\n",
["/autorun"] = true, -- an empty "vaccine" folder
}
for z = 8, 10 do for x = 130, 133 do for y = 80, 83 do
f[string.format("/maps/osm/%d/%d/%d.png", z, x, y)] = PNG
end end end
for k, v in pairs(extra or {}) do f[k] = v end
return mktree(f)
end
local function sd_cfg(w, h, extra_caps)
local c = { w = w or 320, h = h or 196,
caps = { sdk_ext = true, keyboard = true, touch = false, sd_list = true, sd_clean = true } }
for k, v in pairs(extra_caps or {}) do c.caps[k] = v end
return c
end
local function press(text)
for i = #buttons, 1, -1 do
if buttons[i].text == text then
assert(guarded(BUDGET, buttons[i].fn), "button '" .. text .. "' failed")
return
end
end
error("no '" .. text .. "' button; screen: " .. label_dump())
end
local function has_button(text)
for _, b in ipairs(buttons) do if b.text == text then return true end end
return false
end
local function screen_has(text) return label_dump():find(text, 1, true) ~= nil end
local function scan_until(app, text, max_ticks)
for _ = 1, max_ticks or 4000 do
if screen_has(text) then return end
tick(app, 1, 50)
end
error("never reached '" .. text .. "'; screen: " .. label_dump())
end
local function found_paths()
local out = {}
for _, l in ipairs(lists) do for _, r in ipairs(l.rows) do out[#out + 1] = r.text end end
return out
end
local function rows_mention(needle)
for _, r in ipairs(found_paths()) do if r:find(needle, 1, true) then return true end end
return false
end
-- Wrapped text on the current screen must end above every button below it.
local function assert_no_overlap(where)
for _, l in ipairs(labels) do
if l.w and l.text ~= "" then
local bottom = l.y + wada.ui.text_lines(l.text, l.w, l.size or 12) * wada.ui.text_h(l.size or 12)
for _, b in ipairs(buttons) do
if b.y > l.y then
assert(bottom <= b.y, string.format("%s: text runs into the '%s' button (%d > %d): %s",
where, b.text, bottom, b.y, l.text:sub(1, 40)))
end
end
end
end
end
local NOTICE_START = "SD Scan only removes files it recognises"
scenarios.sdscan_layouts = function()
for _, dims in ipairs({ { 320, 196 }, { 240, 276 }, { 480, 178 }, { 222, 436 } }) do
reset_world()
cfg = sd_cfg(dims[1], dims[2])
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
local where = dims[1] .. "x" .. dims[2]
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has(NOTICE_START), where .. ": the start screen must carry the notice")
assert_no_overlap(where .. " start")
press("Scan")
scan_until(app, "threats found")
assert_no_overlap(where .. " results")
press("Remove all")
assert_no_overlap(where .. " confirm")
press("Remove")
scan_until(app, "Removed 4 files.")
assert(screen_has(NOTICE_START), where .. ": the removal screen must carry the notice")
assert(not screen_has("safe to put"), where .. ": do not tell people the card is safe")
assert_no_overlap(where .. " removed")
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
assert(screen_has(NOTICE_START), where .. ": a clean result must carry the notice")
assert_no_overlap(where .. " clean")
end
end
-- The card root of a real infected M9 (photographed 2026-09-18), autorun.inf text
-- as it was on the card: Sality's pattern, with the payload as xlfqf.pif.
local SALITY_AUTORUN = table.concat({
";jedgRfgxKpOwxYkkslpwEyPfQXwXltdBYsJ", "XCmmWo aRguMqaH",
"sHell\\EXplore\\COmmanD=xlfqf.pif", "sHELl\\OpEN\\Default=1",
";qaApt hKlVtplou hjPQkb", "shell\\opEn\\CoMmanD = xlfqf.pif",
";vpWCt WxgC", "ShelL\\AUTopLay\\ComMaNd=xlfqf.pif", ";", "" }, "\r\n")
scenarios.sdscan_real_m9 = function()
cfg = sd_cfg(320, 196)
local f = {
["/autorun.inf"] = SALITY_AUTORUN,
["/xlfqf.pif"] = pe_bytes(4096),
["/copyright.png"] = PNG,
["/bl/list"] = "0",
["/meshcomod/contacts3"] = string.rep("\3", 300),
["/meshcomod/ui_threads_v1.bin"] = string.rep("\1", 300),
}
for x = 130, 133 do for y = 80, 83 do f[string.format("/tiles/8/%d/%d.png", x, y)] = PNG end end
cfg.sdtree = mktree(f)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert(screen_has("2 threats found"), label_dump())
assert(rows_mention("autorun file: /autorun.inf"))
assert(rows_mention("Windows program: /xlfqf.pif"))
press("Remove all")
press("Remove")
scan_until(app, "Removed 2 files.")
assert(not sd_exists("/autorun.inf") and not sd_exists("/xlfqf.pif"))
for _, keep in ipairs({ "/copyright.png", "/bl/list", "/meshcomod/contacts3",
"/meshcomod/ui_threads_v1.bin", "/tiles/8/130/80.png" }) do
assert(sd_exists(keep), "removed something that is not the worm: " .. keep)
end
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
end
scenarios.sdscan_m9 = function()
cfg = sd_cfg(320, 196)
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(widgets.timer_ms and widgets.timer_ms <= 100, "the scan needs a steady tick")
press("Scan")
assert(cfg.awake, "the screen must stay on while scanning")
scan_until(app, "threats found")
assert(not cfg.awake, "keep_awake must be released when the scan ends")
assert(screen_has("4 threats found"), "expected exactly 4 threats; " .. label_dump())
assert(rows_mention("autorun file: /autorun.inf"))
assert(rows_mention("Windows program: /" .. NBSP .. "/svchost.exe"))
assert(rows_mention("Windows shortcut: /tiles.lnk"))
assert(rows_mention("renamed Windows program"), "a program under another name was missed")
assert(not rows_mention("contacts3"), "a data file that starts with MZ is not a program")
assert(screen_has("map tiles skipped"), "a quick scan should say it skipped the tile folders")
for _, c in ipairs(cfg.sd_calls) do assert(c.max <= 24, "pages must stay small: " .. c.max) end
press("Remove all")
assert(screen_has("Remove 4 files?"))
assert(buttons[1].text == "Cancel", "Cancel must come first so a stray Enter deletes nothing")
press("Cancel")
assert(screen_has("4 threats found") and not cfg.sd_removed, "Cancel must not remove anything")
press("Remove all")
press("Remove")
scan_until(app, "Removed 4 files.")
assert(#cfg.sd_removed == 4)
for _, keep in ipairs({ "/copyright.png", "/test file.txt", "/wadamesh/contacts3",
"/wadamesh/ui_threads_v1.bin", "/wadamesh/lang/hu.lang",
"/maps/osm/8/130/80.png", "/autorun" }) do
assert(sd_exists(keep), "removed something that is not a threat: " .. keep)
end
assert(not sd_exists("/autorun.inf") and not sd_exists("/tiles.lnk"))
press("Scan again")
press("Scan")
scan_until(app, "No known Windows malware found.")
end
scenarios.sdscan_paging_and_full = function()
cfg = sd_cfg(320, 196)
local extra = { ["/maps/osm/8/130/evil.exe"] = pe_bytes(200), ["/wadamesh/history/zz.vbs"] = "WScript.Echo 1" }
for i = 1, 100 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end
cfg.sdtree = infected_card(extra)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
assert(rows_mention("zz.vbs"), "the 101st file of a folder was never reached: paging broken")
assert(not rows_mention("evil.exe"), "a quick scan should not walk the tiles")
local paged = 0
for _, c in ipairs(cfg.sd_calls) do if c.path == "/wadamesh/history" and c.start > 1 then paged = paged + 1 end end
assert(paged >= 4, "expected the history folder to be read in pages, got " .. paged)
press("Scan again")
press("Full scan")
scan_until(app, "threats found")
assert(rows_mention("evil.exe"), "a full scan must walk the tile folders")
assert(not screen_has("map tiles skipped"))
end
scenarios.sdscan_old_firmware = function()
cfg = sd_cfg(320, 196, { sd_clean = false })
cfg.sdtree = infected_card()
wada = build_wada()
assert(not wada.sd.remove and not wada.sd.check)
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has("can only find them"), "the intro should say removal needs newer firmware")
press("Scan")
scan_until(app, "threats found")
assert(screen_has("3 threats found"), "by name: autorun, program, shortcut; " .. label_dump())
assert(not has_button("Remove all"), "no Remove button without firmware support")
assert(has_button("Scan again"))
assert(screen_has("format the card"))
end
scenarios.sdscan_no_access = function()
cfg = sd_cfg(320, 196, { sd_list = false, sd_clean = false })
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(screen_has("no SD card access"), label_dump())
assert(not has_button("Scan"))
end
scenarios.sdscan_no_card = function()
cfg = sd_cfg(320, 196)
cfg.sd_nocard = true
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "No SD card found.")
assert(has_button("Scan again"))
end
scenarios.sdscan_remove_fails = function()
cfg = sd_cfg(320, 196)
cfg.sdtree = infected_card()
cfg.sd_remove_fail = { ["/tiles.lnk"] = "remove failed" }
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
press("Remove all")
press("Remove")
scan_until(app, "could not be removed")
assert(screen_has("Removed 3, 1 could not be removed."), label_dump())
assert(rows_mention("/tiles.lnk") and rows_mention("remove failed"))
end
scenarios.sdscan_portrait = function()
cfg = sd_cfg(240, 276, { keyboard = false, touch = true })
cfg.sdtree = infected_card()
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Scan")
scan_until(app, "threats found")
for _, l in ipairs(lists) do assert(l.y + l.h <= cfg.h - 40, "the list runs under the buttons") end
end
scenarios.sdscan_cost = function()
cfg = sd_cfg(320, 196)
local extra = {}
for i = 1, 400 do extra[string.format("/wadamesh/history/seg%03d", i)] = string.rep("\2", 90) end
cfg.sdtree = infected_card(extra)
wada = build_wada()
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
press("Full scan")
local worst, ticks = 0, 0
while not screen_has("threats found") and ticks < 5000 do
local n = 0
debug.sethook(function() n = n + 100 end, "", 100)
clock_ms = clock_ms + 50
local ok, err = pcall(app.on_tick, 50)
debug.sethook()
assert(ok, err)
if n > worst then worst = n end
ticks = ticks + 1
end
print(string.format(" full scan of %d files: %d ticks, worst tick ~%d instructions (budget %d)",
400 + 60, ticks, worst, BUDGET))
assert(worst < BUDGET / 4, "tick too expensive")
end
local order = APP_PATH:find("/sdscan/", 1, true)
and { "sdscan_real_m9", "sdscan_m9", "sdscan_layouts", "sdscan_paging_and_full", "sdscan_old_firmware", "sdscan_no_access",
"sdscan_no_card", "sdscan_remove_fails", "sdscan_portrait", "sdscan_cost" }
or APP_PATH:find("/wardrive/", 1, true)
and { "wardrive_utf8" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" }
for _, name in ipairs(order) do
+113 -6
View File
@@ -53,6 +53,8 @@ extern void luaHostAudioStatus(uint32_t owner, char* state, size_t s
#if CAP_LUA_SD_LIST
extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null
extern bool luaHostSdReadFailed(); // failed open was a dead card
extern bool luaHostSdClearAttributes(const char* path); // drop read-only/hidden/system
#include "SdThreat.h" // the SD malware policy wada.sd.check/remove enforce
#endif
extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
double* lat, double* lon, char* pk_hex, size_t pk_cap,
@@ -931,6 +933,7 @@ int sysCaps(lua_State* L) {
lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch");
lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd");
lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_list");
lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_clean"); // wada.sd.check/remove + list paging
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio");
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_wav");
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_mp3");
@@ -1704,13 +1707,21 @@ static int sdListError(lua_State* L, const char* error) {
return 2;
}
// wada.sd.list(path) -> entries | nil,error
// entries is an array of { name, type = "file"|"dir", size, mtime } and gains
// entries.truncated = true only when the directory exceeds kSdMaxEntries.
// wada.sd.list(path [, start [, max]]) -> entries | nil,error
// entries is an array of { name, type = "file"|"dir", size, mtime }. A directory
// bigger than one page is returned a page at a time: entries.truncated = true and
// entries.next is the start index of the next page. `max` sets the page size
// (1..kSdMaxEntries, default the maximum). Paging exists because the SD Scan app
// has to see every file in a folder, and small pages because every entry costs a
// file open, so a full 192-entry page of a tile folder stalls the screen.
int sdList(lua_State* L) {
const char* requested = "/";
size_t requested_len = 1;
if (!lua_isnoneornil(L, 1)) requested = luaL_checklstring(L, 1, &requested_len);
const lua_Integer start = luaL_optinteger(L, 2, 1);
luaL_argcheck(L, start >= 1, 2, "start must be 1 or more");
const lua_Integer page = luaL_optinteger(L, 3, kSdMaxEntries);
luaL_argcheck(L, page >= 1 && page <= kSdMaxEntries, 3, "max must be 1..192");
char path[kSdMaxPath];
if (!sdSafePath(requested, requested_len, path, sizeof path))
return sdListError(L, "bad path");
@@ -1722,6 +1733,16 @@ int sdList(lua_State* L) {
if (!dir) return sdListError(L, luaHostSdReadFailed() ? "no sd" : "not found");
if (!dir.isDirectory()) { dir.close(); return sdListError(L, "not a directory"); }
// Skip to the requested page by reading names only: openNextFile() opens and
// stats every entry, which on a tile folder is most of the cost of a page.
for (lua_Integer seen = 1; seen < start; ) {
const String next = dir.getNextFileName();
if (!next.length()) break;
const char* base = strrchr(next.c_str(), '/');
base = base ? base + 1 : next.c_str();
if (base[0]) ++seen;
}
lua_newtable(L);
int count = 0;
bool truncated = false;
@@ -1730,7 +1751,7 @@ int sdList(lua_State* L) {
const char* base = full ? strrchr(full, '/') : nullptr;
base = base ? base + 1 : full;
if (!base || !base[0]) { entry.close(); continue; }
if (count >= kSdMaxEntries) { truncated = true; entry.close(); break; }
if (count >= page) { truncated = true; entry.close(); break; }
const bool is_dir = entry.isDirectory();
lua_createtable(L, 0, 4);
@@ -1744,7 +1765,91 @@ int sdList(lua_State* L) {
entry.close();
}
dir.close();
if (truncated) { lua_pushboolean(L, 1); lua_setfield(L, -2, "truncated"); }
if (truncated) {
lua_pushboolean(L, 1); lua_setfield(L, -2, "truncated");
lua_pushinteger(L, start + count); lua_setfield(L, -2, "next");
}
return 1;
}
// ---- wada.sd.check / wada.sd.remove: Windows malware on the card -----------
// Written for the ThinkNode M9 SD-card worm (Elecrow advisory, Sept 2026). The
// policy lives in SdThreat.h and is applied HERE, in firmware: remove() deletes
// a file only when the firmware classifies it as a Windows malware carrier, so
// an app can find and offer, but can never use this to delete anything else.
// Classify one file; *err is set (and None returned) when it cannot be read.
static SdThreat::Kind sdClassify(fs::FS* fs, const char* path, const char** err) {
*err = nullptr;
File f = fs->open(path, "r");
if (!f) { *err = luaHostSdReadFailed() ? "no sd" : "not found"; return SdThreat::None; }
if (f.isDirectory()) { f.close(); *err = "not a file"; return SdThreat::None; }
const char* base = strrchr(path, '/');
base = base ? base + 1 : path;
SdThreat::Kind kind = SdThreat::byName(base);
if (kind == SdThreat::None) {
// A Windows program under a harmless name. Stack buffers on purpose: SD
// transfers need DMA-capable memory, which PSRAM is not.
uint8_t head[SdThreat::kHeadBytes];
const size_t size = f.size();
if (size >= sizeof head && f.read(head, sizeof head) == sizeof head &&
SdThreat::mzHeader(head, sizeof head)) {
const uint32_t off = SdThreat::peOffset(head);
uint8_t sig[4];
if (SdThreat::peOffsetPlausible(off, (uint32_t)size) && f.seek(off) &&
f.read(sig, sizeof sig) == sizeof sig && SdThreat::peSignature(sig, sizeof sig))
kind = SdThreat::RenamedProgram;
}
}
f.close();
return kind;
}
static bool sdFilePath(lua_State* L, char* path, size_t cap) {
size_t len = 0;
const char* requested = luaL_checklstring(L, 1, &len);
return sdSafePath(requested, len, path, cap) && strcmp(path, "/") != 0;
}
// wada.sd.check(path) -> reason | false | nil,error
// reason names the threat ("autorun file", "Windows program", "renamed Windows
// program", ...); false means the file is not one.
int sdCheck(lua_State* L) {
char path[kSdMaxPath];
if (!sdFilePath(L, path, sizeof path)) return sdListError(L, "bad path");
bool busy = false;
fs::FS* fs = luaHostSdFs(&busy);
if (!fs) return sdListError(L, busy ? "busy" : "no sd");
const char* err = nullptr;
const SdThreat::Kind kind = sdClassify(fs, path, &err);
if (err) return sdListError(L, err);
if (kind == SdThreat::None) lua_pushboolean(L, 0);
else lua_pushstring(L, SdThreat::label(kind));
return 1;
}
// wada.sd.remove(path) -> reason | nil,error
// Deletes the file only if check() would name it; "not a threat" otherwise.
// Directories are never removed. Worms mark their files read-only, hidden and
// system, and FAT refuses to delete a read-only file, so those attributes are
// cleared first -- on a file already judged a threat, never on anything else.
int sdRemove(lua_State* L) {
char path[kSdMaxPath];
if (!sdFilePath(L, path, sizeof path)) return sdListError(L, "bad path");
bool busy = false;
fs::FS* fs = luaHostSdFs(&busy);
if (!fs) return sdListError(L, busy ? "busy" : "no sd");
const char* err = nullptr;
const SdThreat::Kind kind = sdClassify(fs, path, &err);
if (err) return sdListError(L, err);
if (kind == SdThreat::None) return sdListError(L, "not a threat");
if (!fs->remove(path)) {
luaHostSdClearAttributes(path);
if (!fs->remove(path))
return sdListError(L, luaHostSdReadFailed() ? "no sd" : "remove failed");
}
Serial.printf("[lua] sd.remove %s (%s)\n", path, SdThreat::label(kind));
lua_pushstring(L, SdThreat::label(kind));
return 1;
}
#endif
@@ -2471,7 +2576,9 @@ void openWada(lua_State* L) {
#if CAP_LUA_SD_LIST
lua_newtable(L); // wada.sd (read-only physical card)
lua_pushcfunction(L, sdList); lua_setfield(L, -2, "list");
lua_pushcfunction(L, sdList); lua_setfield(L, -2, "list");
lua_pushcfunction(L, sdCheck); lua_setfield(L, -2, "check"); // SD malware policy
lua_pushcfunction(L, sdRemove); lua_setfield(L, -2, "remove"); // threats only, see SdThreat.h
lua_setfield(L, -2, "sd");
#endif
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
// Windows malware carriers on a removable card.
//
// Written for the ThinkNode M9 SD-card worm (Elecrow security advisory,
// September 2026): cards shipped with a hidden autorun.inf and Windows programs
// disguised as shortcuts, dormant until the card goes into a Windows PC. A mesh
// radio never reads any of these files, so on this card a match is malware or,
// at best, something no device workflow put there.
//
// This is the ONLY policy wada.sd.remove() honours. A store app may ask for a
// file to be deleted, but the firmware decides whether it is a threat, so no app
// can use that call to delete map tiles, backups, chat history or an identity.
namespace SdThreat {
enum Kind : uint8_t {
None = 0,
Autorun, // autorun.inf: tells Windows what to launch from the card
Program, // .exe, .scr, .com, ...: runs when double-clicked
Script, // .vbs, .js, .bat, ...: run by Windows' own script hosts
Shortcut, // .lnk, .url, .scf: how the worm disguises itself as a folder
RenamedProgram, // a Windows program under a harmless-looking name
};
inline const char* label(Kind k) {
switch (k) {
case Autorun: return "autorun file";
case Program: return "Windows program";
case Script: return "Windows script";
case Shortcut: return "Windows shortcut";
case RenamedProgram: return "renamed Windows program";
default: return nullptr;
}
}
inline bool eqNoCase(const char* a, const char* b) {
for (;; ++a, ++b) {
char x = *a, y = *b;
if (x >= 'A' && x <= 'Z') x = (char)(x - 'A' + 'a');
if (y >= 'A' && y <= 'Z') y = (char)(y - 'A' + 'a');
if (x != y) return false;
if (!x) return true;
}
}
// By name alone. `name` is the last path component. Only the final extension
// counts, which is also how Windows decides what a double-click runs, so the
// "photo.jpg.exe" and right-to-left-override tricks are caught by the ".exe".
inline Kind byName(const char* name) {
if (!name || !name[0]) return None;
if (eqNoCase(name, "autorun.inf")) return Autorun;
const char* dot = strrchr(name, '.');
if (!dot || !dot[1]) return None;
const char* ext = dot + 1;
static const char* const kProgram[] = {
"exe", "scr", "com", "pif", "cpl", "msi", "msp", "dll", "jar",
};
static const char* const kScript[] = {
"bat", "cmd", "vbs", "vbe", "js", "jse", "wsf", "wsh", "hta", "ps1", "reg", "msc",
};
static const char* const kShortcut[] = { "lnk", "url", "scf" };
for (const char* e : kProgram) if (eqNoCase(ext, e)) return Program;
for (const char* e : kScript) if (eqNoCase(ext, e)) return Script;
for (const char* e : kShortcut) if (eqNoCase(ext, e)) return Shortcut;
return None;
}
// By content: a Windows program is "MZ" at offset 0 plus a "PE\0\0" signature
// where the DOS header's e_lfanew (a 32-bit little-endian value at 0x3C) points.
// Two bytes of "MZ" alone are not enough: a random binary blob starts with them
// once in 65,536 files, and on this card that blob could be chat history.
constexpr size_t kHeadBytes = 64;
inline bool mzHeader(const uint8_t* head, size_t n) {
return head && n >= kHeadBytes && head[0] == 'M' && head[1] == 'Z';
}
inline uint32_t peOffset(const uint8_t* head) {
return (uint32_t)head[0x3C] | ((uint32_t)head[0x3D] << 8) |
((uint32_t)head[0x3E] << 16) | ((uint32_t)head[0x3F] << 24);
}
// Real linkers put the PE header within the first few hundred bytes; bound it so
// a corrupt or hostile e_lfanew cannot send the check seeking across the card.
inline bool peOffsetPlausible(uint32_t off, uint32_t file_size) {
return off >= kHeadBytes && off <= 65536u && file_size >= 4 && off <= file_size - 4;
}
inline bool peSignature(const uint8_t* sig, size_t n) {
return sig && n >= 4 && sig[0] == 'P' && sig[1] == 'E' && sig[2] == 0 && sig[3] == 0;
}
} // namespace SdThreat
+97
View File
@@ -129,6 +129,7 @@ static void* wadaMp3Scratch() { return s_wada_mp3_scratch; }
#include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution
#include "ChannelSenderSplit.h" // host-tested "SenderName: body" split for channel/room posts
#include "PasteHexKey.h" // host-tested key extraction for pasted key fields (#526)
#include "SdThreat.h" // SD-card malware rules: the mount-time warning + wada.sd.remove
#include "SenderExtField.h" // host-tested split/rejoin of a sender across the two on-disk fields
// The split refuses a "SenderName: " prefix wider than the wire can carry, so that cap
// must never sit BELOW what UIMessage::sender can hold — otherwise a name the field
@@ -47357,6 +47358,75 @@ static void luaStoreOpenLanguages() {
}
#endif // CAP_LUA_APPS (store page)
#if CAP_LUA_APPS && CAP_LUA_SD_LIST
// ===== SD-card malware warning =============================================
// Some ThinkNode M9 cards shipped with a dormant Windows worm (Elecrow security
// advisory, September 2026): a hidden autorun.inf and programs disguised as
// shortcuts. Harmless on this radio, live on the first Windows PC the card goes
// into. The SD Scan store app finds and removes them; this makes sure somebody
// who never read the advisory hears about it, by looking at the card's top
// folder every time a card is mounted, at boot or when one is inserted.
//
// Names only, one directory read and no file opens. The thorough check, every
// folder and programs under other names, is SD Scan's job.
static bool s_sdmal_mounted = false; // mount state seen on the last tick
static uint32_t s_sdmal_due_ms = 0; // when the check is due (0 = none)
static int sdRootThreatCount() {
File root = SD.open("/");
if (!root) return 0;
if (!root.isDirectory()) { root.close(); return 0; }
int n = 0;
bool is_dir = false;
for (String p = root.getNextFileName(&is_dir); p.length(); p = root.getNextFileName(&is_dir)) {
if (is_dir) continue; // a FOLDER named autorun.inf is the vaccine trick, not the worm
const char* base = strrchr(p.c_str(), '/');
base = base ? base + 1 : p.c_str();
if (SdThreat::byName(base) != SdThreat::None) ++n;
}
root.close();
return n;
}
// The warning's button: SD Scan itself when it is installed, else the Store,
// where it is one tap away.
static void sdMalwareOpenScan() {
luaStoreScanInstalled();
for (int i = 0; i < s_lua_inst_n; ++i) {
if (strcmp(s_lua_inst[i].id, "sdscan") != 0) continue;
const char* emb = luaBuiltinSrc(s_lua_inst[i].id);
luaAppLaunchFile(s_lua_inst[i].id, s_lua_inst[i].name, emb, emb ? strlen(emb) : 0);
return;
}
openLuaStorePage();
}
static void sdMalwareTick(uint32_t now) {
if (s_sd_mounted != s_sdmal_mounted) {
s_sdmal_mounted = s_sd_mounted;
// Let a fresh mount settle first: boot restores and the insert toast.
s_sdmal_due_ms = s_sd_mounted ? ((now + 5000u) | 1u) : 0;
}
if (!s_sdmal_due_ms || (int32_t)(now - s_sdmal_due_ms) < 0) return;
// Never over another dialog, an open app or a card lifecycle change.
if (sdRuntimeLifecycleBusy() || s_confirm_modal || luaAppIsOpen()) {
s_sdmal_due_ms = (now + 3000u) | 1u;
return;
}
s_sdmal_due_ms = 0;
if (!s_sd_mounted) return;
markSdIo();
const int n = sdRootThreatCount();
if (n <= 0) return;
Serial.printf("[sd] Windows malware on the card: %d file(s) in the top folder\n", n);
showConfirm(TR("This SD card has Windows malware on it, like the worm some ThinkNode M9 cards "
"shipped with. It cannot run on this radio, but it will on a Windows PC. "
"Before the card goes in a computer, remove it with SD Scan or, safest, "
"format the card."),
TR("Open SD Scan"), sdMalwareOpenScan);
}
#endif
// ===== App drawer (full-screen grid launcher) =============================
// Toggle-in alternative to the command-centre home: a grid of every tool. The
// home's "Apps" button opens it; its own "Home" button (and the back key) close
@@ -54591,6 +54661,28 @@ fs::FS* luaHostSdFs(bool* busy) {
return &SD;
}
bool luaHostSdReadFailed() { return sdReadFailedCardDead(); }
// Clear read-only/hidden/system on one card file so it can be deleted (FAT
// refuses to delete a read-only file, and worms set all three). Only called by
// wada.sd.remove() on a file the SD malware policy already named a threat.
// fs::FS has no attribute call, so this goes to FatFs directly: the Arduino SD
// library mounts drive "<pdrv>:" and keeps pdrv in a protected member, read here
// through the standard pointer-to-member route rather than by guessing drive 0.
// Declared by hand like f_mkfs above: ff.h drags FatFs' BYTE/WORD typedefs into
// this file. FRESULT is an int-sized enum and FR_OK is 0.
extern "C" int f_chmod(const char* path, uint8_t attr, uint8_t mask);
struct SdPdrvAccess : fs::SDFS {
static uint8_t of(const fs::SDFS& sd) { return sd.*(&SdPdrvAccess::_pdrv); }
};
bool luaHostSdClearAttributes(const char* path) {
if (!s_sd_mounted || !path || path[0] != '/') return false;
char ffpath[200];
const int n = snprintf(ffpath, sizeof ffpath, "%u:%s", (unsigned)SdPdrvAccess::of(SD), path);
if (n <= 0 || n >= (int)sizeof ffpath) return false;
markSdIo();
constexpr uint8_t kAmRdo = 0x01, kAmHid = 0x02, kAmSys = 0x04; // FatFs AM_RDO/AM_HID/AM_SYS
return f_chmod(ffpath, 0, kAmRdo | kAmHid | kAmSys) == 0;
}
#endif
// ---- wada.mesh read-only bridges (no mesh types cross into the host TU) ----
// Hex-encode the first n bytes of a public key. 4 bytes (8 hex chars) is what the
@@ -61512,6 +61604,11 @@ void UITask::loop() {
}
if (now >= _next_refresh) {
refreshStatusLabels();
#if CAP_LUA_APPS && CAP_LUA_SD_LIST
// Here rather than below the HAS_TOUCH_UI gate: the ThinkNode M9, the board
// this warning exists for, does not define HAS_TOUCH_UI.
sdMalwareTick((uint32_t)now);
#endif
#if defined(HAS_THINKNODE_M9)
if (s_m9_mail_indicator && s_m9_contact_indicator && s_m9_update_indicator) {
lv_obj_t* top = lv_layer_top();
+117
View File
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// Host test for the SD-card malware policy (SdThreat.h), the rule wada.sd.remove()
// enforces for the ThinkNode M9 SD-card worm.
//
// c++ -std=c++17 -Wall -Wextra -I src test/test_sd_threat.cpp -o /tmp/sdthreat && /tmp/sdthreat
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "ui-touch/SdThreat.h"
using namespace SdThreat;
// A minimal 128-byte file that carries a real DOS + PE header, and nothing else:
// no code, just the two signatures the check looks for.
static void makePe(uint8_t* buf, size_t n, uint32_t e_lfanew) {
memset(buf, 0, n);
buf[0] = 'M'; buf[1] = 'Z';
buf[0x3C] = (uint8_t)(e_lfanew & 0xFF);
buf[0x3D] = (uint8_t)((e_lfanew >> 8) & 0xFF);
buf[0x3E] = (uint8_t)((e_lfanew >> 16) & 0xFF);
buf[0x3F] = (uint8_t)((e_lfanew >> 24) & 0xFF);
if (e_lfanew + 4 <= n) { buf[e_lfanew] = 'P'; buf[e_lfanew + 1] = 'E'; }
}
// The whole content check, the way LuaAppHost runs it against a file.
static bool renamedProgram(const uint8_t* file, uint32_t size) {
if (!mzHeader(file, size)) return false;
const uint32_t off = peOffset(file);
if (!peOffsetPlausible(off, size)) return false;
return peSignature(file + off, size - off);
}
int main() {
// What the advisory names, in whatever case Windows wrote it.
assert(byName("autorun.inf") == Autorun);
assert(byName("AUTORUN.INF") == Autorun);
assert(byName("AutoRun.Inf") == Autorun);
// The real thing, from an infected M9 card (2026-09-18): autorun.inf in the
// root runs xlfqf.pif on open, explore and autoplay. The Sality autorun
// pattern: a random five-letter name with .pif, mixed-case shell verbs and
// random-junk comment lines in between.
assert(byName("xlfqf.pif") == Program);
assert(byName("XLFQF.PIF") == Program);
assert(byName("xlfqf.PiF") == Program);
// The payload shapes of a shortcut worm.
assert(byName("setup.exe") == Program);
assert(byName("TF card.EXE") == Program);
assert(byName("photo.jpg.exe") == Program); // double extension: the last one wins
assert(byName("screensaver.scr") == Program);
assert(byName("payload.dll") == Program);
assert(byName("install.vbs") == Script);
assert(byName("run.bat") == Script);
assert(byName("x.js") == Script);
assert(byName("tweak.reg") == Script);
assert(byName("maps.lnk") == Shortcut); // a folder's name with a shortcut behind it
assert(byName("tiles.LNK") == Shortcut);
assert(byName("site.url") == Shortcut);
assert(byName("desktop.scf") == Shortcut);
// Everything a real card holds must stay untouched: Elecrow's own content,
// wadamesh's data, the tile trees, and the "vaccine" folder some tools create.
const char* keep[] = {
"copyright.png", "test file.txt", "tiles", "maps", "15", "8.png", "123.jpg",
"contacts3", "ui_threads_v1.bin", "identity", "prefs.kv", "wallpaper.jpg",
"sdscan.lua", "sdscan.json", "hu.lang", "backup-2026-09-17.bin", "log.txt",
"autorun", "autorun.inf.bak", "autorun.ini", "info", ".exe.txt", "exe",
"program.exe~", "README", "", "notes.md", "track.gpx", "run.wav", "song.mp3",
};
for (const char* n : keep) {
if (byName(n) != None) { printf("false positive: '%s'\n", n); return 1; }
}
assert(byName(nullptr) == None);
assert(byName("trailingdot.") == None);
// Labels exist for every threat kind and not for None.
assert(label(None) == nullptr);
assert(label(Autorun) && label(Program) && label(Script) && label(Shortcut) && label(RenamedProgram));
// Content: a Windows program under an innocent name.
uint8_t pe[128];
makePe(pe, sizeof pe, 0x40);
assert(renamedProgram(pe, sizeof pe));
makePe(pe, sizeof pe, 0x78); // a typical real e_lfanew
assert(renamedProgram(pe, sizeof pe));
// "MZ" alone is not a program: 1 in 65,536 random blobs start with it.
uint8_t mz_only[128];
memset(mz_only, 0xA5, sizeof mz_only);
mz_only[0] = 'M'; mz_only[1] = 'Z';
assert(!renamedProgram(mz_only, sizeof mz_only));
// e_lfanew pointing inside the DOS header, past the end, or far across the
// card is rejected before anything is read there.
makePe(pe, sizeof pe, 0x10);
assert(!renamedProgram(pe, sizeof pe));
makePe(pe, sizeof pe, 126); // no room for the 4-byte signature
assert(!renamedProgram(pe, sizeof pe));
assert(!peOffsetPlausible(0x40, 3)); // tiny file: size - 4 must not wrap
assert(!peOffsetPlausible(70000, 1u << 20)); // plausible file, implausible offset
assert(peOffsetPlausible(0x80, 1u << 20));
// Too short to hold a DOS header at all.
assert(!mzHeader(pe, 10));
assert(!mzHeader(nullptr, 64));
// Media and wadamesh data do not start with MZ.
const uint8_t png[64] = { 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A };
assert(!mzHeader(png, sizeof png));
printf("test_sd_threat: all assertions passed\n");
return 0;
}