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 half the story. every one of these things can phone home to V-Net, Wecon's remote-access cloud, and once it does 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 that. that's a lot of authority 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 all framework. the native libs are Weex, image codecs, mPaaS, and no tunnel, MQTT, TLS or crypto code of Wecon's own. every endpoint, the auth, the signing, all of it 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 suits me, because a minified JS bundle hides nothing from grep.
the tunnel itself is server-side (the V-Box hardware builds it, the app just drives a
REST + WebSocket control plane), so from the client side the whole attack surface is
two questions: how does it authenticate, and over what.
over what: cleartext, all of it
the base URL and the TLS switch sit 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
this isn't 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, and that
sign is the only thing standing between a request and the server. here's 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, run through bare MD5 with no per-user or per-device component. the
cuid field is the literal string "123456789". the signature
proves nothing about who you are. auth collapses to one question, do you hold
a valid sid, and the sid just rode past you in cleartext. my
signer is twenty lines and reproduces the exact sign the app emits.
plug in a real sid and 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 i'm 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 through the DCloud storage helper, which encrypts with that
fixed framework AES key. the pieces line up. backups are on 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 and 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" anyone can forge, so 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 read off the client, and i'm not testing it against strangers' live machinery. that's where the writeup stops. the design is the finding.
everything shown here is 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. an industrial remote-access product driving 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.
. _SiCk · afflicted.sh