_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

Wecon V-Net: the cloud that runs the factory, signed with one MD5 key

2026-05-31 · weconicscloudmobileapiscada

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 m1m17: 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