Wecon V-Net: the cloud that runs the factory, signed with one MD5 key
The last post cracked Wecon's HMI firmware open. The panel is only half the story, though. every one of these things can phone home to V-Net, Wecon's remote-access cloud, so an operator can watch and drive the plant from a phone hundreds of miles away. You bind a panel (or a PLC behind a V-Box gateway) by its "machine code", the V-Box punches a VPN tunnel out to the cloud relay, and the app talks to your HMI through it. That's a lot of authority sitting behind one mobile app. So I pulled the app.
$ curl http://cdn_apps.v-iec.com/V-Net2.0.apk -o V-Net2.0.apk # 29 MB, HTTP 200
$ aapt dump badging V-Net2.0.apk | head
package: name='com.wecon.iiot' versionName='3.1.1.25060603'
sdkVersion:'21' targetSdkVersion:'33'
application: label='V-NET'
The shape of it: a web app in a trench coat
V-Net is a DCloud uni-app (Weex JS runtime) wrapped around Ant Financial's mPaaS SDK. The DEX is just framework; the native libs are Weex, image codecs, and mPaaS. no tunnel, MQTT, TLS or crypto code of Wecon's own. All the business logic. every endpoint, the auth, the signing. lives in one bundled, minified file:
assets/apps/__UNI__2F37E69/www/app-service.js 1.9 MB # the whole product, in JS
lib/{arm64-v8a,armeabi-v7a,x86}/ libweexcore, libmpaascpu, libsecsdk, … # generic SDKs only
Which is good news, because a minified JS bundle hides nothing from
grep. The remote-access tunnel itself is implemented server-side (the
V-Box hardware builds it; the app just drives a REST + WebSocket control plane), so
the whole attack surface from the client's side is: how does it authenticate, and
over what.
Over what: cleartext, all of it
The base URL and the TLS switch are right there as build constants. The switch is off.
# app-service.js
VUE_APP_REQUEST_SSL = 0 # 0 -> http:// (1 would be https)
VUE_APP_THREE_API_TRANSFER = 1 # every signed call -> http://v-iec.com/m<N>/<endpoint>
bussinessBaseUrl = "v-iec.com" # China
bussinessBaseUrlAsean = "asean.v-iec.com" # ASEAN
Not a legacy default that gets upgraded at runtime. the production servers answer on port 80 and never redirect you to TLS:
$ curl -sI http://v-iec.com/
HTTP/1.1 200 OK
Server: nginx/1.23.0 # no Location:, no 301-to-https
$ curl -o /dev/null -w "redirect=%{redirect_url}\n" http://v-iec.com/
redirect= # empty. http is the real transport.
So login, the session id, device bind, the box/device passwords, the machine codes
. everything across the API's 16 numbered modules (ModuleIdEnum
runs m1…m17: real-time, history, alarms, user-center,
device-base, SCADA, maintenance…). rides in the clear. Anyone on the path
keeps the whole session.
One key to sign them all
Every call carries a wcommon header with a sign, which is
the only thing standing between a request and the server. Here is how it's built:
// app-service.js: the signer, deminified
const SIGN_KEY = "5cee621329f24e5cbdc43daa959ce9a1"; // hardcoded, same for EVERY install
// merge header + params, sort keys, join k=v&, then append the key:
let i = "";
for (const t of Object.keys(merged).sort())
if (v(t) != null && v(t) !== "") i += t + "=" + v(t) + "&";
i += "key=" + SIGN_KEY;
sign = Md5.init(i); // plain MD5. not HMAC. no per-user secret.
Read that again: the "secret" that signs your API calls is a constant compiled into
every copy of the app, and it's run through bare MD5 with no per-user or per-device
component. The cuid field is the literal string "123456789".
So the signature proves nothing about who you are. authentication
collapses entirely to "do you hold a valid sid", and the sid
just rode past you in cleartext. The signer is twenty lines, and it reproduces the
exact sign the app itself emits, with a real sid
plugged in, the output is byte-identical to the legitimate client's:
$ python3 wecon_sign.py
canonical string MD5'd:
cuid=123456789&deviceId=27155120760000&lan=zh&mt=1&pageNo=1&pageSize=20&pid=1&
sid=<any-valid-session>&sv=1.0&ts=1780259253135&key=5cee621329f24e5cbdc43daa959ce9a1
sign = 5e5d45ee64015d4a8a6e29065776ac32 # the value the app would send for these params
target: POST http://v-iec.com/m5/device/getDeviceInfoByDeviceId # computed locally, NOT replayed against the server
The rest of the loot
While we're in the bundle and the manifest, the other baked-in secrets:
# location value
app-service.js SIGN_KEY 5cee621329f24e5cbdc43daa959ce9a1 # the universal signer
AndroidManifest DCloud appkey 6ce362d4b92aabaa54dc4e9ba6f994db
AndroidManifest mPaaS appid ALIPUB5197AAA181646
AESUtil.smali local-store key IM['KJK'XCK[=Xillafo # DCloud framework default AES key
CERT.RSA signing cert CN=wecon … valid 2022 .. 2122 # 100-year self-signed
And two manifest flags that finish the thought: android:usesCleartextTraffic="true"
(of course) and android:allowBackup="true". The app stashes its cached
sid via the DCloud storage helper, which encrypts with that
fixed framework AES key. The pieces line up. backups are enabled and the
key is known, so an adb backup plus that key should recover
the cached session; I verified the two preconditions in the artifact but did not run the
extraction against a live install.
The device model, and the shape of the next bug
A device is a V-Box / V-NET gateway identified by a machine code (机器码, run
through a client-side verifyMachineCode check). You add one with
device/bind; you hand it to a colleague with
device/setBoxShareCode / accountInvitation/*. sharing
is by code, not by re-authenticating to the box. The realtime plane is a
single hardcoded socket:
APP_WS_ADDRESS = "wss://api.pd.weconcloud.net/m1/actdata-websocket"
auth = sid + ts + sign(... key=SIGN_KEY) # same universal-key scheme
device addressing: device/getDeviceInfoByDeviceId, device/getBoxShareCode, device/bind
keyed by: machine-code (structured, finite, enumerable)
That's the shape of an IDOR: objects addressed by a structured, guessable identifier, with a client-side "signature" that anyone can forge, which means the only thing keeping you out of someone else's plant is the server's own ownership check on each call. Whether those checks hold is the one thing you can't tell from the client, and the one thing I'm not going to find out against strangers' live machinery. That's where the writeup stops: the design is the finding.
Honest notes
What's shown here is all client-side and self-contained: the cleartext transport (a HEAD to the public root, nothing more), the signing algorithm lifted from the shipped JS, and a signature forged locally. I did not log into anyone's account, enumerate machine codes, or touch a device that isn't mine. the IDOR is a hypothesis the design invites, not a thing I exploited.
The takeaway isn't one bug, it's the posture: an industrial remote-access product that drives real PLCs and HMIs, shipping a plaintext API authenticated by a single shared MD5 constant. The firmware couldn't keep a secret it was never given; the cloud decided not to keep one at all.