Sitemap

I Reverse-Engineered an Enterprise VPN Client, and the “Security” Turned Out to Be Constants in a Binary

15 min readJun 22, 2026
Press enter or click to view image in full size

How a weekend project to build a nicer Linux client for H3C iNode turned into a
tour of forgeable posture checks, a pre-auth crash that took down my own laptop,
and a private cloud that handed out root to anyone who asked nicely.

Publishing metadata — copy into Medium’s fields, then delete this section

- SEO title: Breaking H3C iNode: When “Security” Is Just Constants in a Binary
- Subtitle / dek: Reverse-engineering an enterprise VPN/NAC client, forging its posture checks, and touring the unauthenticated cloud behind it.
- Meta description (~155 chars): I reverse-engineered H3C’s iNode VPN/NAC client and its “security” was hardcoded constants — then the cloud behind it handed out root. 119k are on Shodan.
- Tags (5): Cybersecurity · Reverse Engineering · Penetration Testing · Infosec · Vulnerability
- Estimated read: ~16 minutes
- Topic / kicker: Security research · Reverse engineering
- Hero image idea: a disassembly / hex-dump close-up, or a “127.0.0.1 = admin” visual

> Quick housekeeping before we start: this is my own authorized research, and I’ve
> stripped out anything that could point at a specific organization — no company
> names, no company-tagged hostnames, no password hashes, crypto keys, serials, or
> credentials, and no public/internet-facing IPs. I’ve kept the internal RFC 1918
> private addresses (`10.16.10.x` and friends) because they make the story
> clearer and they don’t identify anyone — they’re the same `10.x` ranges sitting
> in a million networks. Where a bug belongs to a third-party product, I describe
> the class of mistake rather than handing you a copy-paste exploit.

How I got here

If you’ve spent any time on a Chinese-vendor enterprise network, you’ve met H3C
iNode. It’s the all-in-one client that does your 802.1X login, your captive
portal, your SSL VPN, and your “is this laptop allowed on the network” posture
check. On Linux it’s a ~71 MB closed-source Qt5 blob that only runs on a handful
of blessed distros, ships with an ancient OpenSSL, and quietly installs a
USB-blocker, a DLP agent, and a posture collector along the way.

I just wanted something cleaner — a small, open-source client that could connect
without all the baggage. So I sat down to reverse-engineer the protocols and
reimplement them properly. Here’s the lucky break: the vendor shipped the
libraries unstripped, with full debug symbols. That turned what could’ve been
weeks of squinting at disassembly into something much closer to reading source.

And the more I read, the more one theme kept jumping out: a lot of what looks
like security in these protocols is really just constants baked into the
binary. Let me show you what I mean.

Before we dive in, a number to keep in the back of your head. Search Shodan for
`product:”H3C”` and you get back 119,103 devices hanging off the public
internet. This isn’t one oddly-configured network — it’s a vendor ecosystem that a
lot of organizations are running, often with the defaults still in place. So when
I say “the secret is the same in every client,” that’s not a hypothetical; it’s
six figures of devices sharing it.

Part 1 — Where’s the actual authentication?

The posture check is an honor system with a checksum

Start with EAD — “Endpoint Admission Defense.” The pitch is that it keeps
non-compliant machines off the network. Your client reports antivirus status,
patch level, firewall, disk encryption, and so on, and the server only lets you in
if you look healthy.

Here’s the thing: when I traced the posture library, the report has no real
signature. The only thing sealing it is a keyed-MD5 over a hardcoded seed
string, optionally wrapped in XTEA with a hardcoded key. Same values in every
client, every session — no nonce, no per-user key, nothing asymmetric.

So what does that buy an attacker? Everything. If you know those constants — and I
do now — you can hand-build a report that says “fully compliant” no matter what
shape the machine is actually in. A hostile box walks right through admission
control, and because there’s no nonce, you can even replay an old report.

The packet itself is almost funny once you see it:

28-byte header:
+0 magic (4 fixed bytes)
+4 total length
+6 16-byte keyed-MD5 checksum ← the entire “integrity” story
+22 packet id
+26 opcode
+28 XML body: <i n=”FIELD”>value</i> …

And the body has a convention that tells you everything about the threat model: a
check passes when its value is empty. The client only fills in a field to
report a failure. So a “perfectly healthy machine” is, almost literally, a blank
form with a checksum that anybody can compute. Treat EAD as a way to see honest
machines — it tells you nothing about dishonest ones. It is not a boundary.

Portal will happily log your coworkers off

Next up, the captive-portal protocol. It authenticates each packet with
`MD5(packet ‖ shared_secret)` — plain MD5, not even HMAC — and that shared secret
is a network-wide constant that, more often than not, ships as a well-known
default.

Once you know (or guess) that secret, a couple of nasty things open up. The one I
keep coming back to: you can forge a logout for anyone’s IP address and kick
them off the network whenever you like, because the victim’s IP is right there in
the header and logout packets are signed exactly the same way. There’s your clean
denial-of-service primitive. On top of that, the CHAP password field is just
`MD5(reqId ‖ password ‖ challenge)`, so a single captured login is enough to start
cracking it offline. The replay protection? A sequence number or two.

The “Zero-Trust” knock you can replay

The Zero-Trust flavor keeps the VPN port invisible until you send a single magic
UDP packet — a “knock” carrying an RFC-4226 one-time password. Sounds great, until
you notice the OTP counter is a random number the packet carries with it.
There’s no moving counter, no time window. The only real secret is a per-client
key, which lives in a config file that’s world-readable by default. Capture one
knock and you can very likely just send it again to pop the “hidden” port back
open.

Your VPN password goes to whoever holds the cert

The SSL VPN sends your password in cleartext inside the TLS tunnel. That’s
genuinely fine — if the TLS is verified. But these gateways ship self-signed
certs with no SAN, so normal validation fails, and operators get nudged into
ticking “ignore certificate errors.” The moment they do, anyone sitting on the
network path collects the password in the clear. The right move is to pin the
cert (trust the exact fingerprint), which is what I built into my client — but
“just turn off verification” should never have been the easy path.

The bug that crashed my laptop (pre-auth, from a tiny file)

This one’s my favorite, because it bit me. My backend kept dying with
`Killed … exit 137` — the kernel’s out-of-memory killer. Great reminder that the
client is attack surface too.

The login flow grabs a CAPTCHA image and decodes the BMP by reading the
`width` and `height` straight out of the header, then allocating a
`width × height` grid of pixels — before checking those numbers against the
file’s actual size. So a 60-byte BMP that claims to be 40000×40000 asks the
process for ~100 GB of RAM, and the OOM killer pulls the trigger.

The kicker: this is reachable before you’ve authenticated to anything, on the
default code path. A malicious gateway — or honestly just a flaky one, or anyone
on-path when verification is off — can crash the client with a tiny reply. The fix
is the boring kind that should’ve been there from day one: bound the dimensions
and confirm the pixel data actually fits before you allocate a byte. (I shipped
that fix; my laptop has forgiven me 😅.)

The pattern under all of it

Notice the thread running through every one of these: the local credential store,
the posture seal, the knock — they all lean on hardcoded keys, ECB-mode XTEA, and
MD5. That isn’t cryptography against anyone holding the binary, and the binary
ships to every customer. If a secret is baked into the client, it isn’t a
secret. Treat anything “protected” that way as plaintext.

Part 2 — Then I looked at the cloud behind it

The same vendor’s private-cloud stack was sitting behind this client —
virtualization managers, storage, a container platform, lights-out management. I
looked at it from an authorized pivot: my workstation → an SSH jump box → a SOCKS5
tunnel into the tenant network. And honestly? The themes rhymed with Part 1
exactly: stuff that answers without auth, “integrity” built on hardcoded
secrets, and a network with no internal walls.

Here’s the lay of the land:

Subnet Reachable from my pivot Role

`10.16.10.0/24` ✅ (TCP; ping filtered) The production tenant subnet — most of the live findings
`10.16.0.0/24` ❌ from here Legacy BMC / KVM / management plane
`10.10.0.0/24` ✅ (the jump box’s own LAN) Out of scope

And a rough map of that production subnet (roles only, no hostnames):

Host Role What it leaked

`10.16.10.1–.3` Switches `.1` exposes its web management UI to the tenant subnet
`10.16.10.10/.11/.12/.100` Container/PaaS masters end-of-life platform + runtime, unauth API discovery
`10.16.10.16/.17/.120` Virtualization managers unauthenticated metrics/actuator endpoints
`10.16.10.18` KVM/console host raw VNC (`5900/5901`) exposed to the whole subnet
`10.16.10.19` Backup appliance unauth root file read; database + SMB wide open

Let me walk you through the ones that mattered.

Lights-out management that talks to strangers

The baseboard management controllers — the little always-on computer bolted to
every server — were, by a distance, the worst of it. These run AMI MegaRAC
underneath, and the legacy `10.16.0.0/24` plane was a buffet.

The easy stuff first: the BMCs answered an unauthenticated WebSocket (`:8090`)
with a full Redfish system summary — manufacturer, serials, UUIDs, firmware
versions, account-lockout policy — no login required. And the ancient IPMI RAKP
weakness (CVE-2013–4786) on UDP/623 leaks admin password material to any anonymous
caller, ready for offline cracking. You only need one server to have a weak
password, and on a fleet this size, one of them always does.

Then the part that made me sit up: these MegaRAC controllers are exposed to
CVE-2024–54085, a Redfish authentication bypass that yields an administrator
session without credentials, and from there chains into the firmware-update
service. That’s not “leak some serials” — that’s owning the hardware underneath the
operating system. On top of the public CVE, the KVM login path had a
developer “free login” shortcut that issued a console token with no password,
and the access controls that did exist were enforced in the browser (flags in
`sessionStorage`, plus that five-second “lockout”) — so they weren’t really
enforced at all.

And a cluster of smaller-but-real issues on the same boxes, each its own finding:

- A path-traversal file read in a download endpoint — directory traversal
walked out of the intended folder to arbitrary files.
- An IDOR password reset — the reset API trusted a user ID from the request
body instead of the session, so a low-privilege user could reset the admin’s
password.
- Unauthenticated virtual-media (IUSB) injection on TCP 5124–5127 — enough to
map a virtual USB device and influence boot media.
- A custom H3C management service on TCP 7582 that answered unauthenticated and
exposed what looked like a memory-page read primitive (with a length field that
smelled like a heap overflow under fuzzing).

If you take one thing from this section: your BMC is a second, invisible computer
with total control over the real one. Here it was reachable, unauthenticated,
exploitable down to firmware, and one known CVE away from game over. It belongs on
an isolated management VLAN, patched past the AMI fix, with Redfish behind real
auth, IPMI off or locked down, and every “free-login”-style shortcut removed.

“Signed” requests that anyone can sign → root file read, no login

This is the hardcoded-secret pattern from Part 1, wearing a different hat. A backup
appliance (`10.16.10.19`) “protected” its API with a `sign` field — which turned
out to be AES-CBC using a key and IV baked straight into the client
JavaScript. So the signature isn’t proof of anything; it’s a value anyone can
fetch from the page and reproduce. Pair that with a `download?filepath=` endpoint
that never validated the path, and an unauthenticated attacker could read any
file on the box as root: the local account database, the admin UI’s TLS private
key, the full logs, every config. From “first packet” to “reading files I had no
business reading,” once I’d worked out the crypto, was about twenty minutes.

The fix is the same sentence every time: integrity has to be an HMAC with a
per-install secret that only the server knows, and file parameters have to be
allow-listed. A constant in the client is not a key.

The container platform answered to anyone

The masters on `10.16.10.10/.11/.12` were a Kubernetes/PaaS cluster, and they were
leaking control-plane reach in a few independent ways:

- The Docker Engine API was exposed on TCP 2375 — the plaintext, no-auth
variant. A reachable `dockerd` socket like that is, for practical purposes,
container-to-host-root: launch a privileged container, mount the host filesystem,
done.
- The kubelet API was reachable on TCP 10250. Depending on the
`anonymous-auth` setting that’s anywhere from info disclosure to running commands
inside pods.
- OpenStack Glance (9292), an unauthenticated Jaeger tracing UI (16686), and a
pile of other internal services (ZooKeeper, Ceph, raw ZeroMQ sockets, an
OpenStack image registry) were all answering on the tenant subnet.

And underneath all that, the platform itself was end-of-life — an
orchestrator build that stopped getting upstream patches years ago, on a language
runtime that’s also EOL, so every TLS/HTTP CVE since is permanently unfixed. An
open `/openapi/v2` (plus the OAuth discovery document) cheerfully published the
whole internal API schema and internal hostnames to anonymous visitors.

EOL isn’t a hygiene footnote — it’s a standing promise that known exploits will
never be fixed. Pair “permanently unpatched” with “Docker 2375 wide open” and you
don’t need a clever chain; you need a firewall rule that was never written.

A VNC console with no password (yes, really)

I’d filed “raw VNC reachable on the console host” as a surface issue — until I
actually connected. The QEMU VNC consoles on `10.16.10.17` (`5910–5913`) and
`10.16.10.18` (`5900/5901`) advertised security type None. No password, no
TLS. I could read the full framebuffer of a running VM’s desktop and drive its
keyboard and mouse. That’s not “exposed surface,” that’s live, interactive
takeover of running virtual machines by anyone on the subnet. Bind QEMU’s VNC to
localhost (it’s meant to sit behind the console proxy), or at minimum put a
password and TLS on it.

The Samba share that hands out the whole filesystem

Same backup appliance, different door. Its `smb.conf` set `force user = root`,
`hosts allow = 0.0.0.0/0`, and — the cherry — `wide links = yes` with
`allow insecure wide links = yes`. So an authenticated SMB user (and remember, I’d
already had a foothold on that box) gets root-level read/write of the
entire filesystem via symlinks, not just the intended share. The earlier “anon
SMB session works” was the doorbell; this is the unlocked door behind it.

The rest of the iceberg

Beyond the headliners there was a long tail, and it all rhymed: an unauthenticated
Spring Boot actuator (`/cas/actuator`, including Prometheus metrics) on the
virtualization managers leaking JVM/heap/queue internals; standalone
Prometheus/Grafana instances answering without auth; Telnet (cleartext
management) still enabled on several hosts; the backup appliance’s TLS private
key and the server-side crypto key/IV sitting in files I could read; weak legacy
TLS (RC4/LOGJAM/export ciphers); and a switch web-UI reachable from the tenant
network. None of these is “game over” on its own — but the whole subnet was
flat, with databases, SMB, raw VNC, BMCs and management UIs all reachable from
everywhere. A flat network is the thing that quietly turns a dozen separate
findings into one attack chain. Segmentation was the cheapest control on the table,
and the most absent.

I’ve put the full inventory — every finding with its host, port and severity — in
an appendix at the end so this section can stay readable.

So what actually generalizes?

If you skim nothing else, here’s what I’d want you to walk away with:

- Constants aren’t secrets. The EAD seed, the knock key, the appliance’s AES
key — each one shipped to every customer and was treated like a gate. If it’s in
the binary or the JavaScript, it’s public. Integrity lives on the server, keyed
per install.
- Your client is attack surface. A pre-auth out-of-memory crash from a 60-byte
image needs zero credentials. Validate what an untrusted peer sends you before
you allocate or trust it.
- “It’s behind TLS” only counts if TLS is verified. Self-signed gateways that
train people to click through warnings are handing credentials to anyone on the
wire. Pin the cert; don’t disable the check.
- Posture/NAC is visibility, not a wall. If the report is forgeable, it tells
you about the honest machines and nothing about the dishonest ones.
- EOL is a vulnerability, not a footnote — and your management planes (BMC,
IPMI, switch UIs, actuators) are the crown jewels. Wall them off.

And if you’re on the defending side, a five-minute checklist:

- [ ] BMC/IPMI on an isolated VLAN; Redfish behind auth; IPMI off or
cipher-restricted; real lockout windows (not five seconds).
- [ ] No management surface — Redfish, actuators, switch UIs, VNC, databases, SMB
— reachable from user/tenant subnets.
- [ ] Retire EOL platforms and runtimes; treat “EOL” as an open-CVE backlog.
- [ ] Replace any client-side “sign”/signature scheme with a server-keyed HMAC.
- [ ] Pin VPN/gateway certs instead of turning verification off.
- [ ] Don’t lean on NAC/posture as your only control for anything sensitive.

One note on disclosure

All of this was authorized, and I’ve deliberately kept it at the level of
vulnerability classes — no live targets, no working exploit for anything that
might still be unpatched, nothing that identifies the org. Design flaws that affect
every deployment of a product (the protocol weaknesses, the hardcoded-key
patterns) deserve coordinated disclosure to the vendors; anything customer-specific
stays in the customer’s private report. Publish the lessons, not the keys.

The clean-room, GPL-licensed iNode client that came out of all this — Qt6/KF6,
with the documented wire formats and a full security write-up — is up in my public
repos. It speaks the protocols straight over the wire and ships none of the
vendor’s binaries. If you’ve got an H3C deployment and want to compare notes, I’d
love to hear from you.

Appendix — full findings inventory

For completeness, here’s everything, not just the stories above. Severities are my
own estimate. Hosts use the internal private addresses; ports are TCP unless noted.

A word on confidence, because it matters: some of these I drove end-to-end
(✅ confirmed); others I observed as an exposed service or in vendor/research
material but did not push to full exploitation (🔶 needs live verification). I’ve
tagged the infrastructure-critical ones below so nobody mistakes “the port was
open” for “I got a shell.” If you’re reproducing this, treat the 🔶 items as leads
to confirm, not facts.

Protocol / client (the iNode reverse-engineering)

Sev Finding

Critical EAD posture report forgeable — no signature, hardcoded keyed-MD5 seed + static XTEA key (NAC bypass, replayable)
High Portal auth = `MD5(packet+shared_secret)` with weak/default secret → logout/auth forgery; CHAP offline-crackable
High SSL-VPN password cleartext-in-TLS + self-signed certs (no SAN) → MITM when verification disabled
High CAPTCHA BMP decoder pre-auth OOM (unbounded `width×height` allocation) — fixed in my client
Medium Zero-Trust SPA knock replayable (random transmitted HOTP counter); client key world-readable
Medium Hardcoded keys / ECB-XTEA / MD5 used as “crypto” (obscurity, not protection)

Infrastructure — Critical

Status Finding Where

✅ confirmed Unauthenticated VNC (security type None) → live VM desktop takeover (read framebuffer, drove input) `10.16.10.17:5910–5913`, `10.16.10.18:5900/5901`
✅ confirmed Unauth root-level arbitrary file read (hardcoded-key “sign” + path) backup appliance `10.16.10.19`
✅ confirmed Unauth Redfish WebSocket full system summary BMCs `10.16.0.0/24:8090`
🔶 verify MegaRAC Redfish auth bypass (CVE-2024–54085) → admin token → firmware flash BMCs `10.16.0.17/.19/.21`
🔶 verify KVM “free-login” backdoor parameter → console token with no password BMC KVM `10.16.0.17/.19`
🔶 verify Unauthenticated Docker Engine API (port open; container→host root untested) `10.16.10.11:2375`
🔶 verify Kubelet API exposed (port open; anon-auth/exec untested) `10.16.10.11/.12:10250`

Infrastructure — High

Finding Where

IPMI 2.0 RAKP discloses admin password material to anonymous callers (CVE-2013–4786) `10.16.0.16/.18/.19/.22:623/udp`
BMC path-traversal arbitrary file read `10.16.0.17`
IDOR password-reset privilege escalation (userId from body) `10.16.0.17`
Custom H3C mgmt protocol — unauth info + memory-read primitive (possible heap overflow) `10.16.0.15/.16/.21:7582`
Unauthenticated virtual-media (IUSB) injection → boot-media swap `10.16.0.15/.21:5124–5127`
Samba share `force user=root` + `wide links` + `hosts allow=0.0.0.0/0` → root FS R/W `10.16.10.19:445`
Backup appliance TLS private key + server crypto key/IV readable `10.16.10.19`
EOL container/PaaS platform + EOL language runtime (permanent CVE backlog) `10.16.10.10/.11/.12`
Unauth OpenStack Glance image API `10.16.10.10/.11/.12:9292`
Unauthenticated Jaeger tracing UI masters `:16686`
Unauthenticated Prometheus / Grafana API `10.16.10.13/.15`, `10.16.0.17`
Telnet (cleartext management) enabled `10.16.10.13/.14/.15:23`

Infrastructure — Medium / Low / Info

Sev Finding

Medium Weak/legacy TLS — RC4, BEAST, RSA-EXPORT, LOGJAM (multiple CVEs) on storage/console hosts
Medium Unauth Spring Boot Actuator (`/cas/actuator`, Prometheus metrics) on virtualization managers `10.16.10.16/.120`
Medium Switch web-management UI reachable from tenant subnet (`10.16.10.1`)
Medium Backups routable / not air-gapped; microsegmentation off by default (ransomware exposure)
Medium BMC config-dump endpoints may leak SMTP/LDAP/SNMP secrets; LLMNR enabled
Medium Client-side-only KVM/virtual-media access flags + 5-second lockout
Low/Info Flat tenant network (DB 3306, SMB 445 anon, raw VNC, RPCBind, ZooKeeper, Ceph, ZeroMQ all reachable)
Low/Info Glass-box inventory disclosure (serials, UUIDs, MACs, firmware versions) via unauth Redfish
Low/Info Weak BMC password policy (min-len 8, dictionary disabled, default account present)
Low/Info ZAP web-app alerts on BMC UI — stack-trace disclosure, weak CSP (wildcard / unsafe-inline / unsafe-eval)
Low/Info Vendor default admin password documented; default-cred attempts ran (no recorded success)

Checked and not vulnerable (so nobody wastes time): anonymous FTP denied; no
DNS zone transfer (recursion-only, already remediated); MariaDB rejected
anonymous/empty creds; Kubernetes/OpenShift anonymous API discovery works but all
list/exec operations return 403.

--

--

harmless-teddy
harmless-teddy

Written by harmless-teddy

work: ethical hacker hobbies: hypnosis, vibe coding, lockpicking, anime, gaming not a skinwalker but a binwalker