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

Cracking Wecon HMI firmware: the decryptor was the DLL all along

2026-05-31 · tpbreakfirmwarereverse-engineeringweconicshmifrida
The whole break in one shot: an encrypted .osf3 at a flat 8.0000 bits/byte → the vendor's own WsfiFile.dll driven headless under Wine (AES-256-CFB, block-64 HMAC key chain) → gunzip/tar → a 169-file ARMv7 Linux rootfs whose bin/HMIUI is a real ld-linux-armhf ELF. No hardware, no Frida, sub-second.

tpbreak eats consumer gear. TP-Link, D-Link, QNAP, Synology, Buffalo, a couple of camera vendors. The whole premise is that an update file the device can install without a user secret has to carry its own key somewhere the vendor ships. The next obvious question was whether industrial kit. PLCs, HMIs, the boxes bolted inside a factory panel. is any more careful. Short answer: no, and in one case it's worse, because the decryptor is a Windows DLL you can download without so much as an email address.

This is the session in order: the sweep that told me which vendors actually encrypt versus which just gzip-in-a-uImage and hope, the disassembly that proved Wecon's cipher core is SHA where the file extension says RC4, and the dynamic trick that got me a full decrypted ARM rootfs without ever recovering a single key by hand. Honest notes throughout on what's solved and what isn't.

Step zero: most "encrypted" PLC firmware isn't

High entropy is not encryption. gzip, xz and zip all sit at ~8.0 bits/byte too, and a firmware image that's just a compressed tarball will fool a quick ent check every time. So the first pass on every candidate was: pull a real file, measure entropy and look for compression magic. If there's a gzip/squashfs/uImage header anywhere, it's packed, not encrypted. write an unpacker, there's no key to find and no story to tell.

# four ICS vendors, one rule: encrypted == flat 8.0 AND no compression magic
Delta    DOP-100   KernelImage   27 05 19 56  U-Boot uImage -> gzip inside  = PACKED
Inovance IT6000    .fmw          4D 5A …       container of WinCE PE32 modules = PACKED
Samkoon  SK/EA     .bin          ARM Cortex-M vector table              = PLAINTEXT
Wecon    LX/PI     .rc4/.osf3    entropy 8.0000, zero gzip/zip/xz magic = ENCRYPTED

Three of the four were duds for our purposes. perfectly extractable, but with binwalk, not a decryptor. Moxa's NPort line is genuinely encrypted (AES-128-ECB, key 2887Conn7564 XOR-obfuscated in the firmware) but Ghidra Ninja already wrote that post years ago, so there's nothing new to add. That left Wecon. a Chinese HMI vendor whose panels show up in factories worldwide, whose firmware is genuinely encrypted, and for which no public decryptor exists.

WSFI: a container with nothing to grab

Wecon's HMI editor (PIStudio, the rebadged-everywhere EasyBuilder) downloads firmware to a panel as a pile of .rc4 files (raw cipher, no header) and .osf3 bundles. The .osf3 ones at least have a four-byte magic, and after that the file goes straight to noise:

# head of an .osf3: "WSFI", a version, a length, then 64 bytes of salt, then ciphertext
00000000: 5753 4649 0100 0000 f622 f3eb 8a34 0600   WSFI...."...4..
00000010: 2c71 714c 3f6d 5b2a 4968 4336 2756 6266   ,qqL?m[*IhC6'Vbf  <- 64-byte
00000020: 6224 4c7d 276f 7c69 274d 5d7b 504c 2c59   b$L}'o|i'M]{PL,Y     per-file
00000030: 3b7d 2657 6862 5e33 4822 464c 592a 3039   ;}&Whb^3H"FLY*09     salt
00000040: 2b5c 3532 4d32 7951 5f57 4a2d 2256 643a   +\52M2yQ_WJ-"Vd:

Two .rc4 files from different builds shared a few leading bytes, which for about ten minutes looked like a fixed-key keystream reuse. the classic "encrypt everything with the same RC4 key" mistake. It wasn't. XOR any two genuinely-distinct images together and you get noise, not the long runs of zeroes you'd see from reused keystream over padding-heavy firmware. The shared prefixes were just duplicate files across v1.0/ and v2.0/. Lesson re-learned: verify the smoking gun before you load it.

The key has to be in PIStudio

The panel installs these files with no user secret, so the same logic as always applies: the editor that builds and sends them must contain the unpack routine. Sure enough, PIStudio ships WsfiFile.dll. a 32-bit C++ DLL whose export table is a confession:

# rabin2 -E WsfiFile.dll  (demangled)
CUnPackFile::analysisInit
CUnPackFile::decryptHeadData
CUnPackFile::decryptPayloadData          <- the prize
CUnPackFile::checkUsrKey                    (optional per-project user key)
CUnPackFile::wsfiToFile                     (whole-file entry point)
CPackFile::encryptionUsrKey  …

A CUnPackFile. There's the decrypt side, sitting in the rootfs of the PC tool, no DRM, no packer. The whole job from here is just making it run.

Static first: the cipher core is SHA, not RC4

Before touching a debugger I wanted to know what I was up against. The file extension says RC4; the binary says otherwise. radare2's crypto-constant scan lights up on two well-known tables. a CRC32 table, and the SHA-256 initial hash values:

# r2 -c '/ck' WsfiFile.dll
0x1003a610  00000000 96300777 2c610eee …   CRC32 table  (0x77073096 byte-swapped)
0x10113060  67e6096a 85ae67bb 72f36e3c …   SHA-256 IV   (H0=6a09e667 byte-swapped)

decryptPayloadData hands two 64-byte buffers off a this pointer into one generic crypto routine, with a 16-bit selector pushed first:

# CUnPackFile::decryptPayloadData @ 0x10009060
0x10009184  mov  ecx, [this]
0x10009187  add  ecx, 0xf0            # this+0xF0 : 64-byte buffer A
0x10009191  call memcpy                             (length 0x40)
0x10009198  add  edx, 0xb0            # this+0xB0 : 64-byte buffer B
0x100091a5  call memcpy
0x100091b6  push 0x200                # selector = 512
0x100091bb  call 0x1000e880           # the crypto core

And the core itself dispatches on that selector. 0xa0, 0x180, 0x200: 160, 384, 512. Bit-lengths. This isn't a cipher with a mode argument, it's a hash with an output-size argument: SHA-256 / SHA-384 / SHA-512 behind one entry point.

# 0x1000e880: algorithm dispatch on the first argument
cmp  eax, 0x180        # 384?
jg   …                 # >384 -> the 512 path
je   …                 # ==384 -> SHA-384
sub  eax, 1
sub  eax, 0xff         # 0x100 -> SHA-256 …

So the shape is now clear: the 64-byte salt out of the WSFI header gets run through a SHA chain to derive the actual stream-cipher key, and that keys whatever "RC4" turns out to be. Which is exactly the kind of multi-step, state-carrying scheme that's a pain to reimplement blind. So I stopped reading and decided to run it.

Running a Windows DLL without Windows (well, without Wine)

This box is WSL. The reflex is to reach for Wine, fight the 32-bit C++ thiscall ABI, fight the MSVC runtime, and lose an afternoon. The better move: WSL has Windows interop. The actual Windows on the other side of /mnt/c will execute a PE for you, with its real CRT, straight from a Linux shell, and it happens to already have Python 3.11 and Frida 17 installed.

# from bash, in WSL:
$ cmd.exe /c "harness.exe sample.osf3"
ctor=65677360 init=656775a0 wsfi=65677630
init -> 0
wsfiToFile -> -3

harness.exe is forty lines of C: LoadLibrary("WsfiFile.dll"), GetProcAddress the mangled CUnPackFile exports, fake up a std::wstring (24-byte MSVC layout, SSO union + size + capacity), and call init then wsfiToFile. It loaded, the exports resolved, the constructor ran, and then wsfiToFile returned -3 and wrote nothing. But the DLL is chatty, and the failure told me more than a success would have:

sTarPath = fs     u32FileMode = 777   u32Uid = 0   u32FileType = 00004000  # dir
CUntgza m_gunzip.update 992 return -3
readFile64 funcOnOutput's nProcessedBytes = -1

There it is. CUntgza. the decrypted payload is a gzip'd tar, and the DLL got far enough to read a real tar entry: a directory named fs, mode 777, uid 0. A Linux rootfs. The decryption worked. The -3 wasn't a crypto failure at all. it was the very last stage, the file writer, failing because my throwaway harness never set a valid output directory. (Cute detail: the device-side unpacker for these bundles is literally /usr/bin/untgza, which you can read in the rootfs once it's open. The PC DLL and the panel share the format.)

Frida: read the plaintext off the conveyor belt

If the bytes are correct by the time they reach the gunzip stage, I don't need the key, the KDF, or a working file-writer. I just need to tap the buffer on its way into gunzip. Frida attaches to the spawned harness.exe; the DLL is LoadLibrary'd at runtime so I hook LoadLibraryW and place the real hook the instant it returns.

The function that feeds gunzip is a tiny funcOnOutput lambda. Its signature is int(const char*&&, int&&). rvalue references, so the arguments arrive as pointers to the buffer-pointer and the length, not the values. That one ABI subtlety was the difference between an access violation and a clean dump:

// Frida: capture every decrypted block fed to gunzip
Interceptor.attach(base.add(0x106e0), {
  onEnter: function (a) {
    var buf = a[0].readPointer();   // *(const char**)  -> the data
    var len = a[1].readU32();       // *(int*)          -> the length
    if (len > 0) outfile.write(buf.readByteArray(len));
  },
  onLeave: function (ret) { ret.replace(ptr(this.len)); }  // force success
});

That onLeave line is the whole trick for getting the entire image. Left alone, the DLL's read loop aborts the moment the downstream file-write returns -1, so you only get the first ~33 KB. Forge the return value . tell the loop the callback succeeded, and readFile64 keeps decrypting block after block all the way to the end, while you quietly siphon every one off the belt.

For the record, the SHA chain is visible too. Hooking the crypto core and reading its std::string arguments shows the salt going in and a 64-byte key coming out the other end:

# crypto core, per-call (std::string args: +0 ptr, +16 size, +20 cap)
CALL#0  algo=256  in=salt(64)         -> out 32B   # SHA-256
CALL#3  algo=384  in=prev_out ‖ salt  -> out 48B   # chained
salt        = 2c71714c 3f6d5b2a 49684336 …   # straight from WSFI+0x10
derived key = 2fcf40d4 2a5991a6 ee211134 …  (64 bytes)

The payoff

Point the same hook at the biggest bundle. a 21 MB APP.osf3 for an S8000-series panel, and the conveyor belt hands over the whole thing:

$ ls -l decrypted.bin
-rw-r--r-- 21,648,217  decrypted.bin           # the full payload, decrypted
$ # it's one gzip member -> 48 MB tar -> 257 files
$ tar tf rootfs.tar | head
fs/usr/lib/libpaho-mqtt3cs.so.1.3.12
lib/libHmiBase.so.0.0.0
lib/libHmiGui_plus.so.0.0.0
lib/libWecon6VProtocolClient.so
bin/HmiCtl
bin/HMIUI
bin/HmiCloudApp
bin/web_hmiconfig/iiotHmi/main.html      # the panel's Vue.js web UI
bin/SysRec/version                        # -> V2.0.1

$ file bin/HMIUI
bin/HMIUI: ELF 32-bit LSB executable, ARM, EABI5, dynamically linked,
           interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 2.6.32, stripped

A real, stripped, ARM Linux userspace. the HMI runtime, its Qt-based GUI stack, the cloud/MQTT client, an embedded web config UI, the lot. From an .osf3 that was flat noise an hour earlier. That's the win: Wecon firmware, decrypted, using nothing but the vendor's own freely-downloadable editor as the oracle.

Update. the cipher, all the way to the bytes

The first cut of this post stopped at the oracle and left a hand-wavy "it's some custom block-wise SHA-keyed thing, no AES tables in the binary, reversing it is the next sitting." Two of those claims were wrong, and the honest thing is to say so: there are AES tables, and it isn't custom. Here's the whole thing.

I'd run radare2's crypto-constant scan and it only flagged SHA-256 and a CRC32 table, no AES. That was a false negative: the AES is a T-table implementation, and /ck doesn't match the Te tables. Hooking the block function and dumping its constants shows the standard AES S-box at 0x10107720 and the standard Te0 at 0x10107920 (c66363a5 f87c7c84 …). The round count in the context is 14. So: AES-256, stock tables. Not custom at all.

The reason an hour of stock-AES attempts all failed comes down to one byte-order gotcha. The implementation packs the state as big-endian columns and XORs the round keys as little-endian dwords, so the round keys sit in memory with each 32-bit word byte-swapped. Recover the key as (RK0‖RK1) with each word reversed and it's a perfectly normal AES-256 key whose schedule is the standard expansion . stock pycryptodome reproduces every block. The mode is CFB-128: keystream0 = E(key, IV), keystreami = E(key, Ci-1), payload at file offset 0x300. Fifty lines of Python, no DLL:

# payload @0x300, AES-256-CFB, key+iv derived per-file from the salt
key = ws(rk0) + ws(rk1)          # ws() = byte-swap each 32-bit word
AES.new(key, MODE_CFB, iv, segment_size=128).decrypt(osf3[0x300:])
  -> 1f 8b 08 00 …            # gzip -> tar -> rootfs

And the "SHA chain" KDF that turns the 64-byte header salt into that key+IV? Real SHA-256/384/512, wrapped in HMAC, but with one twist that defeated every stdlib hmac call I threw at it: the block size is 64, not SHA-512's standard 128. You see it the moment you dump the bytes fed to the hash: the inner message starts with salt ⊕ 0x36 over 64 bytes (the ipad), the outer with salt ⊕ 0x5c (the opad). Plug a block-64 HMAC into the chain and it reproduces the key and IV exactly:

A   = hmac64(sha512, salt, header16 ‖ salt)
dk  = hmac64(sha512, salt, d2)
key ‖ iv = hmac64(sha384, dk ‖ salt, dk)[:48]     # byte-exact match

One knot I haven't fully pulled: d2, a 64-byte per-component seed that a memory scan finds sitting in a com.wecon.hmi.fs-labelled metadata blob inside the header. a separate header-decrypt layer. So the pure-Python derives the key and IV given d2, and d2 still comes from the oracle. The cipher is solved; that last seed is for another night. Either way it doesn't matter for use: the oracle decrypts the whole image regardless.

Now in tpbreak

Wecon is wired into tpbreak like every other vendor: pick Wecon from the dropdown, browse the S3000/S8000/RH/E02 panels, click a build and the catalog fetches and decrypts it automatically . status decrypted, OS Linux, a squashfs rootfs you can pull down. No upload, no DLL on your end. On the server side it leans on the pre-decrypted set (the firmware ships inside PIStudio, not on an open CDN), with the headless Wine harness behind it for anything new.

None of this touches a signing key or ships a panic. The decryptor comes out of Wecon's own free editor; the key chain is plain AES and HMAC once you account for two byte-orders. The device, as always, can't keep a secret it was never given.