Skip to content

Instantly share code, notes, and snippets.

@svanheule
Last active November 6, 2025 19:16
Show Gist options
  • Select an option

  • Save svanheule/9f82e156a3601d4a726639eb7400ec97 to your computer and use it in GitHub Desktop.

Select an option

Save svanheule/9f82e156a3601d4a726639eb7400ec97 to your computer and use it in GitHub Desktop.
safeloader patching script for OpenWrt
#!/usr/bin/python3
# This file allows modifying a a safeloader factory image.
# It can be used to transplant a partition from one image into another, or to update the
# firmware version info.
# The procedure to calculate the hash and the required salt are described at:
# https://github.com/openwrt/openwrt/blob/master/tools/firmware-utils/src/tplink-safeloader.c
import argparse
import binascii
import collections
import hashlib
import struct
class SafeloaderFirmware:
DESCRIPTION_OFFSET = 0x14
DESCRIPTION_SIZE = 0x1000
PART_TABLE_OFFSET = 0x1014
PART_TABLE_SIZE = 0x800
def __init__(self, description=b'', padding=None):
self.parts = collections.OrderedDict()
self.description = description
self.padding = padding
def set_part(self, name, data):
if name in self.parts:
self.parts[name] = data
else:
self.parts[name] = data
self.parts.move_to_end(name)
def get_part(self, name):
return self.parts[name]
def part_table(self):
template = 'fwup-ptn {name:s} base 0x{offset:05x} size 0x{size:05x}\t\r\n'
offset = self.PART_TABLE_SIZE
table = b''
for part_name,part_data in self.parts.items():
table += template.format(name=part_name, offset=offset, size=len(part_data)).encode('ascii')
offset += len(part_data)
table += b'\0'
data = bytearray([0xff]*self.PART_TABLE_SIZE)
data[0:len(table)] = table
return bytes(data)
def description_data(self):
data = bytearray([0xff]*self.DESCRIPTION_SIZE)
desc = self.description
if desc is not None:
data[0:4] = struct.pack('>I', len(desc))
data[4:4+len(desc)] = desc
return bytes(data)
def checksum(self):
# The calculated hash is seeded with a salt, followed by the FW image body
m = hashlib.md5()
m.update(bytes([
0x7a, 0x2b, 0x15, 0xed, 0x9b, 0x98, 0x59, 0x6d,
0xe5, 0x04, 0xab, 0x44, 0xac, 0x2a, 0x9f, 0x4e
]))
m.update(self.description_data())
m.update(self.part_table())
for name,part in self.parts.items():
m.update(part)
return m.digest()
def save_firmware(self, output_file):
checksum = self.checksum()
data = self.description_data()
data += self.part_table()
for name,part in self.parts.items():
data += part
data = self.checksum() + data
data = struct.pack('>I', len(data) + 4) + data
if self.padding is not None:
data += self.padding
output_file.write(data)
@staticmethod
def unpack_metadata_partition(part):
return (int.from_bytes(part[0:4], 'big'), part[8:])
@staticmethod
def pack_metadata_partition(data):
return len(data).to_bytes(4, 'big') + int(0).to_bytes(4) + data
def set_version(self, new_version):
part_soft_version = self.get_part('soft-version')
part_len, pdata = self.unpack_metadata_partition(part_soft_version)
if pdata.isascii():
# Must add NULL termination
pdata = (new_version + '\0').encode('ascii')
elif part_len < 12:
print("Cannot parse partition as structured version info")
return
else:
v_maj, v_min, v_patch = new_version.split('.', 3)
if not (v_maj.isdigit() and v_min.isdigit() and v_patch.isdigit()):
print(f'failed to parse new version "{new_version}" as "MAJ.MIN.PATCH"')
return
pdata = bytearray(pdata)
pdata[1] = int(v_maj)
pdata[2] = int(v_min)
pdata[3] = int(v_patch)
self.set_part('soft-version', self.pack_metadata_partition(pdata))
def set_compatibility(self, compatibility):
part_soft_version = self.get_part('soft-version')
part_len, pdata = self.unpack_metadata_partition(part_soft_version)
if pdata.isascii() or part_len < 16:
print("Cannot parse partition as structured version info")
return
pdata = bytearray(pdata)
pdata[12:16] = compatibility.to_bytes(4, 'big')
self.set_part('soft-version', self.pack_metadata_partition(pdata))
@classmethod
def from_file(cls, input_file):
def read_fw_ptn_list(fw):
fwup_end = fw.find(b'\0', 0)
if fwup_end > 0:
return [p.strip() for p in fw[:fwup_end].decode('ascii').splitlines()]
else:
return None
def parse_ptn(ptn):
tokens = ptn.split()
if len(tokens) != 6:
return None
elif tokens[0] != 'fwup-ptn' or tokens[2] != 'base' or tokens[4] != 'size':
return None
else:
return (tokens[1], int(tokens[3], base=16), int(tokens[5], base=16))
(fw_size,) = struct.unpack('>I', input_file.read(4))
checksum = input_file.read(0x10)
input_file.seek(cls.DESCRIPTION_OFFSET)
(desc_size,) = struct.unpack('>I', input_file.read(4))
if desc_size <= cls.DESCRIPTION_SIZE - 4:
desc = input_file.read(desc_size)
else:
desc = None
input_file.seek(cls.PART_TABLE_OFFSET)
bulk = input_file.read(fw_size-cls.PART_TABLE_OFFSET)
padding = input_file.read()
if len(padding) == 0:
padding = None
fw = cls(desc, padding)
part_list = read_fw_ptn_list(bulk)
for ptn in part_list:
name, base, size = parse_ptn(ptn)
fw.set_part(name, bulk[base:base+size])
if fw.checksum() != checksum:
print('invalid file checksum')
return fw
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Patch OpenWrt safeloader factory image')
parser.add_argument('-f', '--factory',
type=argparse.FileType('rb'),
required=True,
help='OpenWrt factory image to be patched')
parser.add_argument('-o', '--output',
type=argparse.FileType('wb'),
help='output path for the patched file')
subparsers = parser.add_subparsers(dest='mode')
parser_transplant = subparsers.add_parser('transplant', help='copy a partition')
parser_transplant.add_argument('-i', '--input',
type=argparse.FileType('rb'),
required=True,
help='Safeloader image to read partition from')
parser_transplant.add_argument('-p', '--partition',
type=str,
required=True,
help='Partition to patch into OpenWrt factory image')
parser_version = subparsers.add_parser('version', help='modify firmware version numbers')
parser_version.add_argument('-v', '--version',
type=str,
help="New firmware version")
parser_version.add_argument('-c', '--compatibility',
type=int,
help="New compatibility level (if supported)")
args = parser.parse_args()
# Read OpenWrt factory image
factory = SafeloaderFirmware.from_file(args.factory)
if args.mode == 'transplant':
# Read patch source input file
patch_input = SafeloaderFirmware.from_file(args.input)
if args.patch is not None:
if args.patch in patch_input.parts:
print(f'Patching "{args.patch}" into OpenWrt factory image...')
factory.set_part(args.patch, patch_input.get_part(args.patch))
else:
print(f'Could not find firmware part "{args.patch}" in input image')
elif args.mode == 'version':
if args.version is not None:
factory.set_version(args.version)
if args.compatibility is not None:
factory.set_compatibility(args.compatibility)
# Write updated factory image
if args.output:
factory.save_firmware(args.output)
else:
print('Patched factory image not saved, resulting partition table:')
for name, data in factory.parts.items():
print(f'\t{name:s} +0x{len(data):06x}')
@scribblemaniac
Copy link

Thanks for this script. One of the last changes made broke it though. The argument parser call to add --partition needs to have dest='patch'. I also found it helpful to print the valid values for that argument if an invalid one is passed. You can find the script with my minor changes here: https://gist.github.com/scribblemaniac/d6de4dd958886a9df35e5a9e83d7c7ea.

@svanheule Please feel free to integrate any of these changes into this script.

@workingmanrob
Copy link

workingmanrob commented Jan 4, 2025

Having ended up here after my adventures through TP-Link's Canadian firmware hell - https://community.tp-link.com/en/business/forum/topic/731980 - thanks to openwrt info I had serial console access or I would have figured out none of this.

TP-Link provided half the solution and I figured out the other half on my own.

The device I have been smashing with a hammer for a few days now is an EAP225v3 (US) that had the CA firmware loaded on it at some point but thankfully with serial console access to u-boot I was able to fix his wagon.

Once I had a stock firmware (5.0.7-US) running on the AP I connected via ssh and ran this:

echo US841 > /tmp/logdump/region; nvrammanager -w /tmp/logdump/region -p region << thank you TP-Link for that one

Before running that command I had this message on the console:
[NM_Debug](nm_region_getRegionName) 00192: Flash region info, code: 124, name: CA.
After running it became:
[NM_Debug](nm_region_getRegionName) 00192: Flash region info, code: 841, name: US.

This allowed me to get v5.1.1 (US) running but attempts to run the latest (5.1.6) blew up in my face with some same old same old errors..
[NM_Debug](loadRegionFromProductInfo) 00074: Product-info region CA

So I took a look at what the nvrammanager command could do and what do you know - it can rewrite partitions - so I did. All done via ssh.

nvrammanager -r /tmp/logdump/info.dump -p product-info
chmod 644 /tmp/logdump/info.dump
cat /tmp/logdump/info.dump
echo "CONTENT OF CAT - MINUS region=CA line" > /tmp/logdump/fixed.dump

Then write your fixed.dump file back to the product-info partition and Robert's your father's brother..
nvrammanager -w /tmp/logdump/fixed.dump -p product-info

I hope this helps future internet archaeologists defeat a completely unnecessary restriction on their hardware.

Cheers 🍻

@BadAtMathOkAtPC
Copy link

Hello, I apologize if this a total dumb question but i have been struggling to use this code seen from the OpenWRT thread you answered: https://forum.openwrt.org/t/tp-link-eap245-v3-bad-file-when-attempting-to-flash/89111/9 . Admittedly im a bit new to python, i know some extremely basic scripting in bash and command prompt and i learned basic java script years ago, so i hopefully this is not to stupid. Essentially i put all 3 files in one directory (the OEM firmware, the OpenWRT firmware, and the python script) and i tried to run the script you provided:

patch-safeloader.py --factory openwrt-ath79-generic-tplink_eap245-v3-squashfs-factory.bin --input EAP245v3_5.0.0_\[20201022-rel59827\]_up_signed.bin --patch product-info --output factory-ca.bin

i was just testing it out in a non persistent version of linux called tails (In a VM) to see what happened. To be clear i was running in bash and started your code with "python3" to make it realize i was trying to run a python script. That partially worked but now its seems to be getting hung up on the EAP245v3_5.0.0_[20201022-rel59827]_up_signed.bin so here is a transcribed version of the error
usage: patch-safeloader.py [-h] -f FACTORY [-o OUTPUT] {transplant, version}... patch-safeloader.py: error: argument mode: invalid choice 'EAP245v3_5.0.0_\[20201022-rel59827\]_up_signed.bin' (choose from 'transplant', ,version')

again sorry if this is a dumb question but at this point i think you are my last option to get openWRT install on my TP link eap245 v3. Oh and in case it matters i downloaded the "snapshot build" you linked in the openWRT thread and i used the firmware version 5.0.0 as that was what seemed to be referenced in the script. I ended up trying to ask someone that know python better, and chat GPT, but had no luck so any suggestions would be greatly appreciated

@scribblemaniac
Copy link

@BadAtMathOkAtPC The arguments --factory and --output are global arguments, so put them first. Then there is a positional argument determining the mode, which should be either 'transplant' or 'version'. And then based on the mode, there could be one or more mode-specific arguments after it. In your case, you probably want something like:

patch-safeloader.py --factory openwrt-ath79-generic-tplink_eap245-v3-squashfs-factory.bin --output factory-ca.bin transplant --input EAP245v3_5.0.0_\[20201022-rel59827\]_up_signed.bin --partition product-info

Note: --patch has been changed to --partition in recent changes to this script.

@BadAtMathOkAtPC
Copy link

@scribblemaniac Thank you so much for the fast reply. I didn't expect something so fast. What you suggested seems to have worked really well for the most part and it even created the output file factory-ca.bin. However it the script is still giving me a bit of a strange error
Traceback (most recent call last): File "/home/amnesia/Documents/patch-safeloader.py", line 211, in <module> if args.patch is not None: ^^^^^^^^^^^ AttributeError: 'Namespace' object has not attribute 'patch'

as far as i can tell that's referring the the python script from patch-safeloader.py around line 211:

if args.patch is not None: if args.patch in patch_input.parts: print(f'Patching "{args.patch}" into OpenWrt factory image...') factory.set_part(args.patch, patch_input.get_part(args.patch)) else: print(f'Could not find firmware part "{args.patch}" in input image')

sorry i am not very good at getting the formatting to work correctly on this site. I just wanted to make sure, is this a problem with the actual patch-safeloader.py script, or could it be that i did something wrong again when trying to run it. Perhaps i can also ignore the error as the output did appear. Any suggestions would be greatly appreciated

To be clear i ran exactly what you posted but preceded it with python3

python3 patch-safeloader.py --factory openwrt-ath79-generic-tplink_eap245-v3-squashfs-factory.bin --output factory-ca.bin transplant --input EAP245v3_5.0.0_\[20201022-rel59827\]_up_signed.bin --partition product-info

Here is a picture of the directory/file i was working with in case that helps
image

@scribblemaniac
Copy link

scribblemaniac commented Mar 3, 2025

@BadAtMathOkAtPC The problem is with the script. Adding dest='patch', after line 187, just like it is in the fixes I proposed earlier should fix your problem. I don't recommend use my gist directly though as it is based on a slightly older version of the script.

@BadAtMathOkAtPC
Copy link

Thanks again @scribblemaniac You are a light in the darkness that is trying to flash my WAP! I wasn't able to get your suggestion to work with the latest version due to a similar error:
image

To be clear i think followed your directions correctly and added the new code to line 188 and saved the work
image

I also ran the same code as in my last comment:
python3 patch-safeloader.py --factory openwrt-ath79-generic-tplink_eap245-v3-squashfs-factory.bin --output factory-ca.bin transplant --input EAP245v3_5.0.0_\[20201022-rel59827\]_up_signed.bin --partition product-info

With all that said when i just ran the version you created in the revision you linked i didn't have any issues, and it appears the file was created. Admittedly every bone in my body is hesitant about running code i don't understand, but at this point I don't have many options if i want to flash the WAP. Do you know if there would be any potential issues in using this factory-ca.bin file as opposed to one created with newer version of the patch-safeloader.py
image

@scribblemaniac
Copy link

Oops, I gave you the wrong line number 😅 , that should have been after line 191.

Now that I take a closer look at the latest changes, they appear to only be for the "version" mode and not transplant, so there should not be any difference in the output between running my revision and the latest revision here for your particular use case. As long as the script didn't have any errors or warnings, and an output bin file is created, I would go ahead and try flashing with that.

For what it's worth, my version of the script is what I used personally, and I was able to use the output to flash and run a router for several months without issue.

@BadAtMathOkAtPC
Copy link

BadAtMathOkAtPC commented Mar 15, 2025

@scribblemaniac OMG OMG OMG OMG Thank you times a billion. i cant express how many hours ive been banging my head against a wall trying to flash this WAP but you finally made it work. i have asked Chat GPT, 2 other smarter people and made a forum on the Open WRT thread that was useles but i cant express my gratitude enough that you kept on responding and helped me solve it !!!!!!!!!

For future reference for anyone else having my nightmare i ended running that code that scribblemaniac linked above (fixes I proposed). I also ran the latest script that scribblemaniac helped me with
`python3 patch-safeloader.py --factory openwrt-ath79-generic-tplink_eap245-v3-squashfs-factory.bin --output factory-ca.bin transplant --input EAP245v3_5.0.0_[20201022-rel59827]_up_signed.bin --partition product-info
this made a file that works when i ran it in Linux i then had to email it to my windows pc. from there i was still getting a bad file error but i realized i had to change the secuirty of the WAP as per the wiki https://openwrt.org/toh/tp-link/eap245_v3 so i SSH'd in and ran https://openwrt.org/toh/tp-link/eap245_v3. after that i was able to do a bunch of trouble shooting and install luci via SSH. i dont fully understand why but in the end i had to be connected to my least customized port in my Router running Open WRT and change the IP address via https://openwrt.org/docs/guide-quick-start/ssh_connect_to_the_internet_and_install_luci and then installed luci via
https://medium.com/openwrt-iot/openwrt-adding-a-web-interface-4bcdf1279a6f . I dont unserstand why luci was so hard to install but im im beyond exstatic now

@beneficadoramocaba
Copy link

@workingmanrob Re: https://gist.github.com/svanheule/9f82e156a3601d4a726639eb7400ec97?permalink_comment_id=5372648#gistcomment-5372648

The device I have been smashing with a hammer for a few days now is an EAP225v3 (US) that had the CA firmware loaded on it at some point but thankfully with serial console access to u-boot I was able to fix his wagon.

Thanks for this contribution, I learned a few important things, including some additional capabilities provided by Omada Software Controller:

  • The controller may allow direct upgrades of firmware to those for different regions. I was able to flash the much more current US firmware for EAP225-Outdoor v3 with no additional hurdles.
  • That particular device supports a remote CLI directly in the controller service, so changes can be done without having to fight with SSH or a serial interface. I haven't been able to find a list of which other devices can do this, but EAP245 v3 does not.
  • The region partition, rather than the firmware, appears to control the available radio bands. For example, where a band is restricted outdoors, TP-Link provides no method to re-enable that band when used indoors, even if the product is marketed for indoor or outdoor use. This just increases 5GHz congestion for most use cases, but is a larger issue for their mesh system. The root AP must be prevented from using a restricted channel and knocking the downlink APs offline, and the only inbuilt way to do that at present is to disable channel auto-selection and lose the interference mitigation that it provides.

@argonne-cbs
Copy link

Hi there. I followed this and I do get the output file but I still get the "bad file" error. What am I missing? thanks

@argonne-cbs
Copy link

nevermind. Got it working. Thanks

@Octanum
Copy link

Octanum commented Nov 6, 2025

I'll preface this with the fact that I have no idea what I'm doing when it comes to python, but I couldn't get the original file to generate the "Canada" version of the OpenWRT file. So I used AI to strip out everything except what I need to do that. This may brick the AP completely, I don't have enough knowledge to confirm it's all good (maybe someone in the comments eventually will).

EDIT: I ended up back on the official firmware. Speeds were around 100Mbps download on OpenWRT while official is around 600Mbps. One weird thing is after the flash through the OpenWRT UI, I did have to flash again through the TP-Link UI because otherwise the AP would reset every reboot. Not sure what went wrong there, but good now. This was handy for getting back to official: https://argsnd.github.io/tp-link-stock-firmware-converter/

Anyways, the command I used with the below script is:

python3 patch-transplant-ca.py -i EAP245v3_ca_5.0.5_[20220323-rel68784]_up_signed.bin -f openwrt-24.10.4-ath79-generic-tplink_eap245-v3-squashfs-factory.bin -o factory-ca.bin

#!/usr/bin/python3
import argparse
import struct
import hashlib
import collections

class SafeloaderFirmware:
    DESCRIPTION_OFFSET = 0x14
    DESCRIPTION_SIZE = 0x1000
    PART_TABLE_OFFSET = 0x1014
    PART_TABLE_SIZE = 0x800

    def __init__(self, description=b'', padding=None):
        self.parts = collections.OrderedDict()
        self.description = description
        self.padding = padding

    def set_part(self, name, data):
        self.parts[name] = data

    def get_part(self, name):
        return self.parts[name]

    def description_data(self):
        data = bytearray([0xff]*self.DESCRIPTION_SIZE)
        if self.description:
            data[0:4] = struct.pack('>I', len(self.description))
            data[4:4+len(self.description)] = self.description
        return bytes(data)

    def part_table(self):
        template = 'fwup-ptn {name:s} base 0x{offset:05x} size 0x{size:05x}\t\r\n'
        offset = self.PART_TABLE_SIZE
        table = b''
        for name, data in self.parts.items():
            table += template.format(name=name, offset=offset, size=len(data)).encode('ascii')
            offset += len(data)
        table += b'\0'
        buf = bytearray([0xff]*self.PART_TABLE_SIZE)
        buf[0:len(table)] = table
        return bytes(buf)

    def checksum(self):
        salt = bytes([
            0x7a, 0x2b, 0x15, 0xed, 0x9b, 0x98, 0x59, 0x6d,
            0xe5, 0x04, 0xab, 0x44, 0xac, 0x2a, 0x9f, 0x4e
        ])
        m = hashlib.md5()
        m.update(salt)
        m.update(self.description_data())
        m.update(self.part_table())
        for _, data in self.parts.items():
            m.update(data)
        return m.digest()

    def save(self, f):
        chk = self.checksum()
        data = self.description_data() + self.part_table()
        for _, d in self.parts.items():
            data += d
        data = chk + data
        data = struct.pack('>I', len(data) + 4) + data
        if self.padding:
            data += self.padding
        f.write(data)

    @classmethod
    def from_file(cls, f):
        def parse_ptn(line):
            parts = line.split()
            if len(parts) == 6 and parts[0] == 'fwup-ptn':
                return (parts[1], int(parts[3], 16), int(parts[5], 16))
            return None

        (fw_size,) = struct.unpack('>I', f.read(4))
        checksum = f.read(0x10)
        f.seek(cls.DESCRIPTION_OFFSET)
        (desc_len,) = struct.unpack('>I', f.read(4))
        desc = f.read(desc_len) if desc_len <= cls.DESCRIPTION_SIZE - 4 else None

        f.seek(cls.PART_TABLE_OFFSET)
        bulk = f.read(fw_size - cls.PART_TABLE_OFFSET)
        padding = f.read() or None

        fw = cls(desc, padding)

        pt_end = bulk.find(b'\0', 0)
        if pt_end > 0:
            lines = [l.strip() for l in bulk[:pt_end].decode('ascii').splitlines()]
            for line in lines:
                info = parse_ptn(line)
                if info:
                    name, base, size = info
                    fw.set_part(name, bulk[base:base+size])
        return fw


def main():
    parser = argparse.ArgumentParser(description='Transplant CA partition from TP-Link firmware to OpenWrt image.')
    parser.add_argument('-i', '--input', type=argparse.FileType('rb'), required=True,
                        help='Official TP-Link firmware with CA data')
    parser.add_argument('-f', '--factory', type=argparse.FileType('rb'), required=True,
                        help='OpenWrt factory image to patch')
    parser.add_argument('-o', '--output', type=argparse.FileType('wb'), required=True,
                        help='Output patched factory image')
    args = parser.parse_args()

    src = SafeloaderFirmware.from_file(args.input)
    dst = SafeloaderFirmware.from_file(args.factory)

    if 'product-info' not in src.parts:
        print("Error: 'product-info' partition not found in input firmware.")
        return

    print("Copying 'product-info' (CA region) from official firmware...")
    dst.set_part('product-info', src.get_part('product-info'))

    print("Saving patched image...")
    dst.save(args.output)
    print("Done. Patched firmware written successfully.")


if __name__ == '__main__':
    main()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment