Bypassing TP-Link WDR3600 v1 "Error 18005" to flash OpenWrt

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.44824n or 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):

  1. The firmware has a 16-byte MD5 hash at offset 0x4C
  2. To verify, the router copies bytes 0x4C..0x5C to memory
  3. Replaces those 16 bytes in the file with a known salt
  4. Computes MD5 of the entire (modified) file
  5. 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:

  1. Looks like a legitimate TP-Link _boot_ file at the byte level (passes all stock GUI checks)
  2. Contains OpenWrt's actual kernel and rootfs in the payload region
  3. Has a freshly-computed MD5 at offset 0x4C matching 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:

  1. 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
  2. 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/
  3. 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

  1. Rename hybrid-openwrt.bin to 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'
    
  2. Connect laptop directly to a TP-Link LAN port via Ethernet

  3. Browse to http://192.168.0.1 (default TP-Link admin)

  4. Login with admin credentials

  5. System Tools → Firmware Upgrade → Choose File → select your renamed hybrid file

  6. Click Upgrade

  7. The GUI will accept the file and start flashing — DO NOT INTERRUPT

  8. 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:

  1. Download the sysupgrade image (different from factory):
    • openwrt-25.12.3-ath79-generic-tplink_tl-wdr3600-v1-squashfs-sysupgrade.bin
  2. Set a root password, log into LuCI at http://192.168.1.1
  3. System → Backup / Flash Firmware → Flash new firmware image
  4. UNCHECK "Keep settings"
  5. Upload the sysupgrade.bin
  6. 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:

  1. Keeping the stock u-boot section bit-perfect (validates bootloader signature)
  2. Replacing the firmware payload with OpenWrt's bytes (gives us the OS we want)
  3. 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