TL;DR
-
What: Gammu SMSD — the daemon behind a huge number of SMS gateways, alerting rigs and 2FA senders — runs an operator-configured hook every time a text arrives. With the Files backend and RunOnReceive enabled, the SMS sender ID was escaped for use as a filename but not for the shell, and then appended to a
/bin/sh -ccommand line. A sender ID containing shell metacharacters executed arbitrary commands as thegammu-smsduser. - Impact: Remote, unauthenticated code execution triggered by sending a text message. Commands run with the daemon's privileges. Missing neutralization of special elements in an OS command (CWE-78).
- Fixed in: Gammu 1.43.3. Advisory GHSA-9vjj-v46c-c5qf, published 25 July 2026, rated High (8.1), credited to me as reporter. CVE requested, pending GitHub assignment.
Why you should care
Most command-injection bugs need the attacker to already be talking to your HTTP API. This one needs a phone number.
Gammu SMSD sits on the receiving end of a modem or GSM dongle. Hospitals use it for on-call paging, monitoring systems use it for SMS alerts, and plenty of small shops use it as the cheap half of a 2FA setup. A very common configuration is: store incoming messages as files (the Files backend), and run a script whenever one arrives (RunOnReceive) — to forward it, log it, or trigger something.
The input to that script comes from the outside world over the cellular network. The sender doesn't authenticate to anything. And on many networks the sender ID is an arbitrary alphanumeric string, not a phone number — that's how banks send texts that say "HSBC" instead of a number. Alphanumeric sender IDs are attacker-controllable, and they can carry the exact characters a shell treats as syntax.
That is the whole bug: a value from a text message reaches /bin/sh.
The setup
Gammu SMSD's Files backend writes each received message to a file whose name includes the sender. To keep that filename legal, it runs the sender ID through an escaping function first. Here is what that function removes, verified at the shipped tag v1.43.2:
// smsd/services/files.c — SMSDFiles_EscapeNumber()
// Replaces with '_' : * < > : " / \ | ? and control chars
// Leaves untouched : $ ` ; & ( ) space — all valid in a GSM-7 sender ID
Look at the two sets. The characters it strips are the ones that would break a filename. The characters it leaves are the ones that matter to a shell: $, backtick, ;, &, (, ). The escaping was written to make a safe filename, and at that job it succeeds. It was never written to make a safe shell token, and nobody asked it to.
That distinction — safe for the filesystem, unsafe for the shell — is the entire finding.
The bug
The escaped sender ID goes into the filename:
// smsd/services/files.c — filename built from the escaped sender (buffer2)
sprintf(FileName, "IN%s_%s_%02d.txt", datetime, buffer2, msgcount);
The filename is then handed back to the core daemon as the "location" of the received message, and the core pastes it straight onto the hook command line:
// smsd/core.c — SMSD_RunOnCommand(), pre-fix
snprintf(result, len, "%s %s", command, locations); // command = your RunOnReceive script; locations = the filename
And that string is executed through the shell:
// smsd/core.c — pre-fix
execl("/bin/sh", "sh", "-c", cmdline, NULL);
sh -c parses its argument as a shell command. So every $(...), backtick, ; and & that survived the filename escaping is now shell syntax. The filename IN..._$(id)_00.txt doesn't name a file — it runs id.
The "aha"
The sender ID was escaped for the place it was going to be stored, and then used somewhere else entirely. A sanitizer is only correct relative to the sink it was written for. This one guarded a filename and was then trusted at a shell — two different grammars, one value, no re-checking at the boundary between them.
Proof of concept (benign)
You do not need a modem to see it. The unsafe path is SaveInboxSMS → RunOnReceive, and it can be exercised with gammu-smsd-inject or by dropping a crafted message into the spool that a live SMS would produce. The safe, non-destructive marker is a sender ID whose "command" just writes a file:
# gammu-smsd configured with: Service = files RunOnReceive = /path/to/hook.sh
# A received message whose alphanumeric sender ID is:
# x$(touch /tmp/gammu_poc)x
#
# The Files backend escapes it for the filename (the $, ( ) survive),
# the core appends the filename to the hook command, and /bin/sh evaluates it:
$ ls -la /tmp/gammu_poc
-rw-r--r-- 1 gammu-smsd gammu-smsd 0 ... /tmp/gammu_poc # the command ran
The marker is touch, not anything harmful — the point is only that a substring of an incoming text message reached /bin/sh and executed. Swap touch for anything and it runs with the daemon's privileges.
Preconditions, stated honestly. This is why the advisory is High rather than Critical, and why the CVSS carries AC:H:
- The daemon must use the Files backend and have RunOnReceive configured. Database backends use numeric row IDs, which have no shell metacharacters, so they are not affected.
- The attacker must be able to deliver an SMS whose sender ID carries the metacharacters. Alphanumeric sender IDs make this feasible; a deployment that restricts accepted senders (
IncludeNumbers) shrinks the surface, though sender IDs can be spoofed.
Neither precondition is exotic — Files + RunOnReceive is a documented, common setup — but they are real, and they belong in the writeup.
The fix
Fixed in 1.43.3 via PR #1129. The maintainer (Michal Čihař) did not add more escaping. He removed the shell's ability to see the value at all.
Before, the message identifiers were concatenated into the command string. After, the command runs with the identifiers passed as separate literal arguments, and the string the shell parses no longer contains them:
- snprintf(result, len, "%s %s", command, locations);
+ snprintf(result, len, "%s \"$@\"", command);
- execl("/bin/sh", "sh", "-c", cmdline, NULL);
+ /* argv = { "sh", "-c", command, "sh", location1, location2, ... } */
+ execv("/bin/sh", argv);
The command line the shell evaluates is now the fixed text your_command "$@". The received-message identifiers are handed to sh as positional parameters ($@) — data the shell expands into the argv of your hook, never text it parses as syntax. $(...) in a sender ID is now just characters in $1.
The locations string was also restructured from a hand-built space-joined buffer into a GSM_StringArray, so each identifier stays a distinct element instead of being flattened into one string that has to be re-split. Same idea, one layer down: keep the values as a list, never as a line to be re-parsed.
Windows gets a parallel fix — CreateProcess has no separate argv, so the patch rejects hook arguments containing cmd.exe metacharacters (" % ! ^ & | < > ( ) and control chars) rather than trying to quote them.
And the part I liked best: the PR ships a test that injects $(touch marker), backticks, ;, & and newlines through the real SMSD_RunOn path and asserts the marker file is never created. The fix comes with a regression test that fails on the original bug.
Takeaways
- A sanitizer is only valid for the sink it was written for. Escaping-for-a-filename and escaping-for-a-shell are different problems with different character sets. The moment a value crosses from one context to another, the old guarantee is void — re-establish it at the new boundary or don't cross.
-
Don't hand untrusted data to
sh -cas part of the command string. Pass it as arguments (execvwith a real argv, or"$@") so the shell treats it as data, not syntax. This removes the entire bug class instead of playing character-blacklist whack-a-mole. - Remember the non-HTTP input paths. SMS, email headers, filenames, DNS, QR codes — anything that becomes a string in your process is input. This value arrived over the cellular network from an unauthenticated sender, and it still reached a shell.
- The best fix narrows what the dangerous component can see. The maintainer didn't escape harder; he made sure the shell never received the value as code. Reducing what a sink is even capable of interpreting beats trying to enumerate everything bad.
Disclosure timeline
- 2026-07 — Reported to the Gammu maintainer via GitHub private vulnerability reporting, with the Files + RunOnReceive precondition and a benign marker PoC. Noted the escaping was correct for filenames and incomplete for the shell.
- 2026-07-25 — Advisory GHSA-9vjj-v46c-c5qf published, rated High 8.1, fix shipped in 1.43.3 (PR #1129), credited to me as reporter.
- CVE — requested; GitHub assignment pending (typically a few weeks).
Credit / CTA
If you run Gammu SMSD, upgrade to 1.43.3+. If you can't yet and you use the Files backend, disable RunOnReceive or switch to a database backend as an interim measure — both are in the advisory's workarounds.
If you write anything that shells out: grep your codebase for system(, popen(, and sh -c, and for every hit ask where the arguments came from and what escaped them for what. That mismatch is where these live.
Santosh Kumar Puppala — AI/ML Platform Architect and security researcher (multiple CVEs; creator of Norviq & Veridor). GitHub: @Santoshkumarpuppala

Top comments (1)
the sink specific escaping lesson is clear and useful. i would add a deployment check that confirms the installed version, backend, and run on receive setting before the service starts. the regression test through the real sms path is stronger than a helper test, and it could also assert the hook receives separate arguments with a low privilege user. that gives operators a safe way to verify both the fix and the remaining exposure.