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 premise is simple: an update file the device can install without a user secret has to carry its own key somewhere the vendor ships. the question was whether industrial kit, PLCs, HMIs, the boxes bolted inside a factory panel, is any more careful. no. in one case it's worse, because the decryptor is a Windows DLL you can download without so much as an email address.
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 fools a quick
ent check every time. so the first pass on every candidate: pull a
real file, measure entropy and look for compression magic. a
gzip/squashfs/uImage header anywhere means it's packed, not encrypted - you 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 me: 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 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. 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 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/. 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. 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 …
there's the decrypt side, CUnPackFile, sitting in the rootfs of the
PC tool, no DRM, no packer. the whole job from here is 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 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, 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
the core itself dispatches on that selector. 0xa0,
0x180, 0x200: 160, 384, 512. bit-lengths. so this is a
hash with an output-size argument, not a cipher with a mode 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 clear: the 64-byte salt out of the WSFI header runs through a SHA chain to derive the actual stream-cipher key, and that keys whatever "RC4" turns out to be. exactly the kind of multi-step, state-carrying scheme that's a pain to reimplement blind. so i stopped reading and ran 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 runs
a PE for you, with its real CRT, straight from a Linux shell, and it already has
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 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, it was the very last stage, the file
writer, failing because my throwaway harness never set a valid output
directory. (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 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 siphon every one off the belt.
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. 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, mea culpa. there are AES tables, and it isn't custom. here's the whole thing.
radare2's crypto-constant scan 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.
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, behind 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. 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.
the device, as always, can't keep a secret it was never given.
. _SiCk · afflicted.sh