Bypassing TP-Link WDR3600 v1 "Error 18005" to flash OpenWrt
TL;DR: TP-Link's stock 3.14.3 firmware (Build 151104, Nov 2015) added an MD5 checksum verification that rejects all custom firmware with Error code: 18005. Same firmware also has a broken/locked TFTP recovery on some hardware variants. This writeup shows how to bypass the check by patching the MD5 in a hybrid firmware image, allowing the standard web GUI upload to flash OpenWrt successfully.
Symptoms
-
Router: TP-Link TL-WDR3600 v1 (any sub-revision — confirmed working on v1.2)
-
Stock firmware:
3.14.3 Build 151104 Rel.44824nor similar from Nov 2015 onward -
Web GUI rejects all firmware uploads (including legitimate older TP-Link firmware) with:
Error code: 18005 Upgrade unsuccessfully because the version of the upgraded file was incorrect. Please check the file name. -
TFTP recovery (WPS button + power cycle) executes the transfer but the bootloader silently rejects all images and boots back to stock — including legitimate signed TP-Link firmware
Root cause
The 18005 error is triggered by a TP-Link "safeloader" V2 header MD5 verification. The algorithm (decoded by reverse-engineering on a related TP-Link RE200 device, confirmed working here):
- The firmware has a 16-byte MD5 hash at offset
0x4C - To verify, the router copies bytes
0x4C..0x5Cto memory - Replaces those 16 bytes in the file with a known salt
- Computes MD5 of the entire (modified) file
- Compares with the saved bytes — mismatch = error 18005
There are two salts depending on whether the firmware contains the bootloader:
md5key_with_bootloader = 8C EF 33 5B D5 C5 CE FA A7 9C 28 DA B2 E9 0F 42
md5key_without_bootloader = DC D7 3A A5 C3 95 98 FB DD F9 E7 F4 0E AE 47 38
A flag at offset 0x94 indicates whether the firmware has bootloader (1 = yes, 0 = no), determining which salt is used.
OpenWrt's factory.bin uses the without_bootloader salt and computes a valid MD5 over its own contents — but won't pass the GUI check because the stock firmware has additional validations (filename pattern, file size, version comparison) that reject anything that's not in the expected _boot_ format.
The hybrid solution
Build a frankenstein firmware that:
- Looks like a legitimate TP-Link
_boot_file at the byte level (passes all stock GUI checks) - Contains OpenWrt's actual kernel and rootfs in the payload region
- Has a freshly-computed MD5 at offset
0x4Cmatching the new content
File layout
TP-Link _boot_ firmware structure (8,192,512 bytes total):
[ 0x000000 - 0x020200 ] 131,584 bytes u-boot + factory data + first TP-Link header
[ 0x020200 - 0x020400 ] 512 bytes Second TP-Link header (firmware payload header)
[ 0x020400 - end ] LZMA kernel + SquashFS rootfs + 0xFF padding
OpenWrt's factory.bin is structured the same way as a TP-Link "stripped" firmware (no bootloader): a TP-Link header at offset 0, then kernel+rootfs, then padding. So we can take the OpenWrt file as-is and slot it into the payload region.
Build steps
You need three files:
- Stock TP-Link firmware with bootloader:
wdr3600v1_en_us_3_14_3_up_boot(151104).bin(8,192,512 bytes)- Download from
https://static.tp-link.com/res/down/soft/TL-WDR3600_V1_151104_US.zip
- Download from
- OpenWrt factory image:
openwrt-25.12.3-ath79-generic-tplink_tl-wdr3600-v1-squashfs-factory.bin(8,126,464 bytes)- Download from
https://downloads.openwrt.org/releases/25.12.3/targets/ath79/generic/
- Download from
- The Python script below.
Save as build-wdr3600-hybrid.py:
#!/usr/bin/env python3
"""
Build hybrid firmware for TP-Link WDR3600 v1 to bypass error 18005.
Combines stock u-boot section + OpenWrt payload + correct MD5 checksum.
"""
import hashlib
import sys
if len(sys.argv) != 4:
print("Usage: build-wdr3600-hybrid.py <stock_boot.bin> <openwrt_factory.bin> <output.bin>")
sys.exit(1)
STOCK_PATH, OPENWRT_PATH, OUTPUT_PATH = sys.argv[1:4]
UBOOT_SIZE = 131584 # 0x20200 - u-boot section size in stock _boot_ firmware
OPENWRT_DATA_SIZE = 7039544 # 0x6B6A38 - actual data in OpenWrt before padding
TARGET_SIZE = 8192512 # 0x7D0000 - full firmware size
# TP-Link safeloader V2 MD5 salts (decoded via Ghidra analysis)
SALT_WITH_BOOT = bytes.fromhex('8CEF335BD5C5CEFAA79C28DAB2E90F42')
with open(STOCK_PATH, 'rb') as f:
stock = f.read()
with open(OPENWRT_PATH, 'rb') as f:
openwrt = f.read()
assert len(stock) == TARGET_SIZE, f"Stock file should be {TARGET_SIZE} bytes"
# Build hybrid: stock u-boot + OpenWrt actual data + 0xFF padding
uboot_section = stock[:UBOOT_SIZE]
openwrt_data = openwrt[:OPENWRT_DATA_SIZE]
padding = b'\xff' * (TARGET_SIZE - UBOOT_SIZE - OPENWRT_DATA_SIZE)
hybrid = bytearray(uboot_section + openwrt_data + padding)
assert len(hybrid) == TARGET_SIZE
# Compute MD5 with WITH-bootloader salt (since result is a _boot_ image)
hybrid[0x4C:0x5C] = SALT_WITH_BOOT
new_md5 = hashlib.md5(bytes(hybrid)).digest()
hybrid[0x4C:0x5C] = new_md5
print(f"New MD5 at 0x4C: {new_md5.hex()}")
with open(OUTPUT_PATH, 'wb') as f:
f.write(bytes(hybrid))
print(f"✓ Wrote {OUTPUT_PATH} ({len(hybrid)} bytes)")
Run it:
python3 build-wdr3600-hybrid.py \
'wdr3600v1_en_us_3_14_3_up_boot(151104).bin' \
'openwrt-25.12.3-ath79-generic-tplink_tl-wdr3600-v1-squashfs-factory.bin' \
'hybrid-openwrt.bin'
Flashing
-
Rename
hybrid-openwrt.binto match TP-Link's expected naming pattern (the GUI also checks filename):mv hybrid-openwrt.bin 'wdr3600v1_en_us_3_14_3_up_boot(151104).bin' -
Connect laptop directly to a TP-Link LAN port via Ethernet
-
Browse to
http://192.168.0.1(default TP-Link admin) -
Login with admin credentials
-
System Tools → Firmware Upgrade → Choose File → select your renamed hybrid file
-
Click Upgrade
-
The GUI will accept the file and start flashing — DO NOT INTERRUPT
-
After ~2-3 minutes, the router reboots into OpenWrt at
http://192.168.1.1
After first boot
The hybrid is functional but structurally non-standard. Immediately do a sysupgrade to a clean OpenWrt build:
- Download the sysupgrade image (different from factory):
openwrt-25.12.3-ath79-generic-tplink_tl-wdr3600-v1-squashfs-sysupgrade.bin
- Set a root password, log into LuCI at
http://192.168.1.1 - System → Backup / Flash Firmware → Flash new firmware image
- UNCHECK "Keep settings"
- Upload the sysupgrade.bin
- Confirm and wait for reboot
You're now running a clean OpenWrt 25.12.3 install with the proper flash layout.
Why this works
The bootloader's verification routine treats the entire 8,192,512-byte file as one image and validates the single MD5 at offset 0x4C. By:
- Keeping the stock u-boot section bit-perfect (validates bootloader signature)
- Replacing the firmware payload with OpenWrt's bytes (gives us the OS we want)
- Recomputing the MD5 over the modified file (passes integrity check)
…we satisfy every check the firmware upgrade code performs. The TP-Link header at offset 0x20200 happens to also pass the structural format expectations because OpenWrt's factory image uses the same TP-Link header format.
Why TFTP recovery doesn't work the same way
The bootloader's TFTP recovery routine appears to perform additional cryptographic validation beyond the MD5 — possibly RSA signature verification on the boot block itself. Even legitimate signed TP-Link firmware fails this check on some hardware variants (the documented "broken 4FC-1" boards on the OpenWrt-devel mailing list). Using the GUI sidesteps the bootloader recovery entirely — the upgrade happens in user-space inside the running stock firmware, where only the simpler MD5 check applies.
Credits
- MD5 algorithm decoded by reverse-engineering of TP-Link RE200 firmware via Ghidra: https://malware.news/t/tp-link-re200-aka-ac750-unpack-repack-validate-image-by-md5-hashing-and-upload-your-own-version/38502
- File structure analysis from OpenWrt-devel mailing list discussion of WDR3600 hardware variants
- Tested working on WDR3600 v1.2 with stock 3.14.3 Build 151104 → OpenWrt 25.12.3