Cracking Wecon HMI firmware: the decryptor was the DLL all along
.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.