Decrypt TP-Link Omada Controller .cfg backup files into readable JSON — and
re-encrypt them back into a valid .cfg — no controller required.
I needed to look up a few settings from an old Omada backup, but the .cfg
file is encrypted, and the only official way to read it is to restore it.
Restoring would have rolled my live controller back to that older state, which
I didn't want. I just wanted to read the configuration out of the file.
So I reverse-engineered the backup format from the controller's own Java
classes and built this decryptor. It turns a .cfg backup into the plain JSON
the controller stores internally, so you can inspect any backup — current or
years old — without touching your running controller.
- Decrypts Omada
.cfgbackups to formatted JSON. - Also decrypts the individual
v2#-encrypted fields inside the JSON. - Re-encrypts edited JSON back into a valid
.cfg(omada_encrypt.py). - Lossless round-trip — decrypt records which fields it decrypted, so encrypt restores them automatically with no flags or guesswork.
- No dependencies — pure Python 3 standard library (RC4, TEA, AES-128-CBC and PBKDF2 are all included or come from the stdlib).
- Works offline; the keys are embedded in the file format itself, not tied to your controller.
python omada_decrypt.py backup.cfg
# -> writes backup.json (pretty-printed, with inner "v2#" fields decrypted)
python omada_decrypt.py backup.cfg out.json # explicit output path
python omada_decrypt.py backup.cfg --keep-encrypted # leave "v2#" fields as-is
python omada_decrypt.py backup.cfg --raw stream.gz # stop after RC4 (raw gzip)python omada_encrypt.py backup.json
# -> writes backup.cfg (re-encrypts "v2#" fields, then GZIP + RC4)
python omada_encrypt.py backup.json out.cfg # explicit output path
python omada_encrypt.py --selftest backup.cfg # round-trip validationThe default decrypt appends a small __omada_encrypted_fields__ marker to the
JSON listing the paths of every field it decrypted. omada_encrypt.py reads
that marker, re-encrypts exactly those fields, and strips the marker again — so
the normal workflow needs no flags:
python omada_decrypt.py backup.cfg backup.json # decrypt
# ...edit backup.json...
python omada_encrypt.py backup.json new.cfg # re-encrypt, fields restoredIf you decrypted with --keep-encrypted, there's no marker and the v2# values
are already present, so encrypt simply passes them through.
Byte-identical round-trip. Re-encrypting an unmodified decrypt reproduces
the original .cfg exactly — same length, same SHA-256 (verified on multiple
v6.2.10.17 backups). See Byte-identical output for why
this works. Restoring a rebuilt .cfg into a live controller has not been
tested, so use at your own risk.
Requires Python 3.6+.
The output JSON mirrors the controller's internal "SDN backup" structure, e.g.:
mainInfo, systemSetting, sites, deviceBriefInfo, role, tenant,
radiusServerSetting, firmwareUpgradeConfig, globalNotification, ...
— site settings, WLAN/SSID config, wired networks, device lists, profiles, schedules, and so on.
Reverse-engineered from Omada Controller v6.2.10.17 (classes
com.tplink.smb.omada.common.util.b.{j,m,a,i}, decompiled from
backup-core-*.jar / omada-common-*.jar). Tested against a v6.2.10.17
controller backup; other 6.x versions are likely compatible but unverified.
.cfg file = RC4( GZIP( JSON ) )
- RC4 (BouncyCastle
RC4Engine) over the entire file. - GZIP decompression.
- UTF-8 JSON.
The RC4 key is not a user password — it is hard-coded in the controller. It
is a 224-byte array (c in j.class) whose first 8-byte block is
TEA-decrypted (Tiny Encryption Algorithm, 64 rounds, key a from m.class);
the remaining 216 bytes are used unchanged. The script reproduces this exactly,
so it works without the controller. Because the key is static and embedded in
the software, every controller of this version uses the same key.
Encryption (omada_encrypt.py) is the exact inverse: GZIP the JSON, then RC4
with the same key (RC4 is symmetric). The inner v2# fields are re-encrypted
with the forward AES-128/CBC path; because the IV is derived (not random),
re-encrypting a value reproduces its original ciphertext byte-for-byte.
Re-encrypting an unmodified decrypt reproduces the original .cfg exactly.
Three details make this work:
- JSON serialization. The controller emits compact JSON (no spaces after
:or,), raw UTF-8 (non-ASCII not\u-escaped), and preserves object key order. Python'sjson.dumps(doc, ensure_ascii=False, separators=(",", ":"))matches it byte-for-byte. - DEFLATE. Java's
Deflateris a JNI wrapper around the same zlib that Python'szlibuses, so at the default level 6 the compressed stream is identical bit-for-bit. - GZIP framing. Java's
GZIPOutputStreamwritesMTIME = 0and the OS byte as0xFF("unknown"). Python'sgzipmodule writes a different OS byte, soomada_encrypt.pyframes the gzip header/trailer by hand to match.
The inner v2# fields reproduce exactly because their AES IV is derived, not
random (see above). Net result: encrypt(decrypt(x)) == x at the byte level.
The only assumption is that the controller compressed at zlib's default level 6 — which is what
GZIPOutputStreamuses unless told otherwise, and what every tested backup matches.
A handful of values inside the JSON (hardware/OEM identifiers and similar) are
additionally encrypted and carry a v2# prefix. These are decrypted in a
second pass (skippable with --keep-encrypted):
plaintext = AES-128/CBC/PKCS5( base64decode( value[3:] ) )
The key and IV come from systemSetting.pbkdf2KeySaltIv — a 96-hex-char string
stored inside the same backup (the controller's AES_KEY_IN_FILE), split
into three 32-char parts:
| chars | meaning |
|---|---|
0:32 |
key seed (used as a string, not hex-decoded) |
32:64 |
salt (hex → 16 bytes) |
64:96 |
IV (hex → 16 bytes) |
key = PBKDF2-HMAC-SHA256("v0hGiXNmbzJdhMvx8BRMrg==" + seed, salt,
iterations = 1000, keyLen = 128 bits)
"v0hGiXNmbzJdhMvx8BRMrg==" is a hard-coded prefix in class i.
These
v2#fields are really database at-rest encryption that happens to ride along into the export — not a backup-specific protection. Since the key lives in the same file, decrypting them adds no real secrecy; it's mainly tamper/obfuscation hardening of internal identifiers.
Legacy backups (pre-
v2#) encrypted fields with a static, TEA-derived key inAES/ECBmode, but those values carry no prefix, so they can't be distinguished from ordinary strings and aren't auto-decrypted.
The decrypted JSON can contain plaintext secrets — Wi-Fi PSKs, device admin passwords, RADIUS secrets — because the controller needs them in clear to push to devices. Treat the output as sensitive and don't commit real backups or their decrypted JSON to a repository.
The controller login password is not recoverable: it's stored as a
one-way Apache Shiro salted hash ($shiro1$SHA-256$...), not encrypted, so no
tool can turn it back into the original password.
This is an independent, unofficial project, not affiliated with or endorsed by TP-Link. "Omada" and "TP-Link" are trademarks of their respective owners. Provided for interoperability and personal data-recovery purposes — use it only on backups you own or are authorized to access. No warranty of any kind.
MIT