1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
|
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0-only
#
# Wrap an OpenWrt UBI image in a Mercusys "fwup" container that the stock
# web UI (nvrammanager) accepts via firmware upgrade.
#
# The stock loader selects its verification algorithm from the `fw-type`
# string in the header: only "Cloud" maps to RSA-2048; any other value falls
# back to an MD5-only integrity check (salt-seeded, keyless). We use that
# fallback, so no vendor signing key is required. Structure and MD5 range were
# reverse-engineered from /usr/bin/nvrammanager.
#
# Header (0x1014 bytes):
# [0x000] BE u32 file_len [0x004..0x13] MD5 [0x014] "fw-type:<name>"
# Payload (@0x1014): interleaved 44-byte records {name[32], base BE, next_off
# BE, size BE}, data inline after each record; rootfs UBI appended after the
# last record.
# MD5: over file[4:] with [0x04:0x14] set to the salt, stored back into
# [0x04:0x14].
import argparse
import hashlib
import struct
import sys
MD5_SALT = bytes.fromhex("7a2b15ed9b98596de504ab44ac2a9f4e")
HDR_LEN, REC_LEN = 0x1014, 0x2c
MIN_LEN, MAX_LEN = 0x1814, 0x3701814
def unescape(text):
"""Turn the literal \\r\\n / \\n escapes used in image Makefiles into
real line breaks, dropping the line-continuation padding around them."""
return text.replace("\\r\\n ", "\\r\\n").replace("\\r\\n", "\r\n") \
.replace("\\n ", "\\n").replace("\\n", "\n")
def record(name, base, next_off, size):
return name.ljust(0x20, b"\0") + struct.pack(">III", base, next_off, size)
def build(rootfs, fw_type, sections):
payload = b""
for i, (name, data) in enumerate(sections):
base = len(payload) + REC_LEN
next_off = 0 if i == len(sections) - 1 else base + len(data)
payload += record(name, base, next_off, len(data)) + data
payload += rootfs
img = bytearray(HDR_LEN) + payload
img[0x14:0x14 + 8 + len(fw_type)] = b"fw-type:" + fw_type
struct.pack_into(">I", img, 0, len(img))
img[0x04:0x14] = MD5_SALT
img[0x04:0x14] = hashlib.md5(bytes(img[4:])).digest()
return bytes(img)
def main():
ap = argparse.ArgumentParser(
description="Create a Mercusys fwup image from an OpenWrt UBI image")
ap.add_argument("rootfs", help="UBI image holding kernel and rootfs")
ap.add_argument("-o", "--out", required=True, help="output image")
ap.add_argument("--fw-type", default="MR80X",
help='fw-type string; anything but "Cloud" skips RSA')
ap.add_argument("--support", required=True,
help="contents of the support-list section")
ap.add_argument("--soft-ver", default="2.0.0 Build OpenWrt",
help="version reported in the soft-version section")
a = ap.parse_args()
if len(a.fw_type) > 0x20:
sys.exit("fwup: fw-type too long")
sections = [
(b"support-list", unescape(a.support).encode()),
(b"soft-version", ("soft_ver:%s\n\n" % a.soft_ver).encode()),
]
with open(a.rootfs, "rb") as f:
rootfs = f.read()
img = build(rootfs, a.fw_type.encode(), sections)
if not MIN_LEN <= len(img) <= MAX_LEN:
sys.exit("fwup: image length %d out of accepted range" % len(img))
with open(a.out, "wb") as f:
f.write(img)
if __name__ == "__main__":
main()
|