Skip to content

Instantly share code, notes, and snippets.

@Ap0dexMe0
Created June 4, 2026 05:47
Show Gist options
  • Select an option

  • Save Ap0dexMe0/c7070042a8560ef2939205df1b72b720 to your computer and use it in GitHub Desktop.

Select an option

Save Ap0dexMe0/c7070042a8560ef2939205df1b72b720 to your computer and use it in GitHub Desktop.
Loewe TLI Dumper
#!/usr/bin/env python3
"""
Loewe TLI Firmware Dumper / Extractor
======================================
Reverse-engineered format analysis and extraction tool for Loewe .tli firmware
update files, commonly found at https://cdn.loewe.tv/software_new/
TLI File Format:
The .tli format consists of two parts:
1. TLV Header (variable length, typically <1KB):
- Custom BER-TLV encoded partition table describing flash layout
- Partition entries with names, flash addresses, sizes, and CRC32 checksums
- Uses tag-length-value encoding where:
* 0x81 = 1-byte length follows
* 0x82 = 2-byte length follows
* 0x83 = 3-byte length follows (value = N bytes)
* 0x05, 0x01, 0x0b, 0x21 = structural tags
* 0x83 0x0c 0x21 = partition block descriptor (12 bytes):
- 4 bytes: start address in flash
- 4 bytes: size or end address
- 4 bytes: CRC32 checksum
2. Tar Archive (rest of file):
- Standard POSIX tar archive (ustar format)
- Each tar entry is a firmware partition (Main, Boot, STB, FRC, etc.)
- Partition names match the TLV header partition table
Partition types found:
- Main: Contains flash.img (NAND flash image, may have inner structure)
- Boot: Contains uboot.bim (U-Boot bootloader)
- STB: Set-top box firmware (raw binary)
- FRC: uImage/ARM format (Linux kernel for FRC board)
- FRCDB: uImage/ARM format (Linux kernel for FRC DB board)
- HDMI: uImage/ARM format (HDMI firmware)
- MAP: MAPX format (memory map / partition layout)
- Subw300: Subwoofer DSP firmware
- ImgCaH: xz-compressed image cache (H panel)
- ImgCaI: xz-compressed image cache (I panel)
Usage:
python3 tli_dumper.py <file.tli> [output_dir] [options]
Options:
--info Show partition table and metadata only (no extraction)
--extract Extract all partitions (default)
--extract NAME Extract only the named partition(s), comma-separated
--raw Save raw partition data without further processing
--unpack Try to unpack inner formats (uImage, gzip, xz, etc.)
--verbose Show detailed progress information
Examples:
python3 tli_dumper.py firmware.tli
python3 tli_dumper.py firmware.tli output/ --info
python3 tli_dumper.py firmware.tli output/ --extract Main,Boot
python3 tli_dumper.py firmware.tli output/ --unpack
"""
import os
import sys
import struct
import tarfile
import io
import hashlib
import zlib
import gzip
import argparse
from pathlib import Path
from datetime import datetime
# ─── Constants ───────────────────────────────────────────────────────────────
KNOWN_PARTITION_TYPES = {
"Main": "NAND flash image (contains flash.img)",
"Boot": "U-Boot bootloader (uboot.bim)",
"STB": "Set-top box firmware (raw binary)",
"FRC": "uImage/ARM (Linux kernel - FRC board)",
"FRCDB": "uImage/ARM (Linux kernel - FRC DB)",
"HDMI": "uImage/ARM (HDMI firmware)",
"MAP": "MAPX memory map / partition layout",
"Subw300": "Subwoofer DSP firmware",
"ImgCaH": "xz-compressed image cache (H panel)",
"ImgCaI": "xz-compressed image cache (I panel)",
}
SIGNATURES = {
b'\x27\x05\x19\x56': 'uImage',
b'\x1f\x8b': 'gzip',
b'HSQS': 'SquashFS',
b'\xfd7zXZ\x00': 'xz',
b'BZh': 'bzip2',
b'\x89PNG': 'PNG',
b'\x67\x45\x23\x01': 'uImage-ARM',
b'ELF': 'ELF',
}
# ─── TLV Parser ──────────────────────────────────────────────────────────────
class TLVEntry:
"""Represents a parsed TLV entry from the TLI header."""
def __init__(self, tag, length, value, offset):
self.tag = tag
self.length = length
self.value = value
self.offset = offset
def __repr__(self):
return f"TLVEntry(tag=0x{self.tag:02x}, len={self.length}, offset=0x{self.offset:04x})"
class PartitionInfo:
"""Represents a firmware partition from the TLV header."""
def __init__(self, name, flash_blocks=None, tar_size=0, tar_offset=0, description=""):
self.name = name
self.flash_blocks = flash_blocks or [] # List of (start, size, checksum) tuples
self.tar_size = tar_size
self.tar_offset = tar_offset
self.description = description or KNOWN_PARTITION_TYPES.get(name, "Unknown")
def __repr__(self):
return f"Partition({self.name}, size={self.tar_size}, blocks={len(self.flash_blocks)})"
def parse_tli_header(data):
"""Parse the TLV header of a .tli file.
Returns a list of PartitionInfo objects.
"""
partitions = []
pos = 0
# Skip the initial file-level metadata (first few bytes)
# The structure starts with some global info, then partition entries
# Find all partition names by looking for length-prefixed ASCII strings
# within the TLV header region
seen_names = set()
while pos < len(data):
# Look for a length byte followed by an ASCII string
if pos >= len(data):
break
name_len = data[pos]
if 2 <= name_len <= 16 and pos + 1 + name_len <= len(data):
name_bytes = data[pos + 1:pos + 1 + name_len]
if all(32 <= b < 127 for b in name_bytes):
name = name_bytes.decode('ascii')
if name not in seen_names and name.isalnum():
seen_names.add(name)
# Parse flash block descriptors that follow
# Look for 0x83 0x0c 0x21 patterns (block descriptor)
blocks = []
search_start = pos + 1 + name_len
search_end = min(search_start + 200, len(data))
sp = search_start
while sp + 12 <= search_end:
# Look for block descriptor: 83 0c 21 <4:start> <4:size> <4:crc>
if data[sp] == 0x83 and data[sp + 1] == 0x0c and data[sp + 2] == 0x21:
start_addr = struct.unpack('>I', data[sp+3:sp+7])[0]
size_or_end = struct.unpack('>I', data[sp+7:sp+11])[0]
checksum = struct.unpack('>I', data[sp+11:sp+15])[0]
blocks.append({
'flash_start': start_addr,
'flash_size_or_end': size_or_end,
'crc32': checksum,
'raw_offset': sp,
})
sp += 15
else:
sp += 1
# Also look at the preceding entry structure for flash addresses
# Pattern before name: ... 81 LL TT SS AAAA BBBB ...
ctx_start = max(0, pos - 30)
ctx = data[ctx_start:pos]
# Try to find 0x21 sub-tag entries before the name
for i in range(len(ctx)):
if ctx[i] == 0x21 and i + 9 <= len(ctx):
addr1 = struct.unpack('>I', ctx[i+1:i+5])[0]
addr2 = struct.unpack('>I', ctx[i+5:i+9])[0]
# Only add if looks like valid flash address
if addr1 > 0 or addr2 > 0:
# Check if not already in blocks
already = any(b['flash_start'] == addr1 for b in blocks)
if not already:
blocks.insert(0, {
'flash_start': addr1,
'flash_size_or_end': addr2,
'crc32': 0,
'raw_offset': ctx_start + i,
})
pinfo = PartitionInfo(name=name, flash_blocks=blocks)
pinfo.description = KNOWN_PARTITION_TYPES.get(name, "Unknown")
partitions.append(pinfo)
pos += 1
return partitions
def detect_signature(data):
"""Detect the file format signature of the given data."""
for sig, name in SIGNATURES.items():
if data[:len(sig)] == sig:
return name
return None
def parse_uimage_header(data):
"""Parse a uImage header and return metadata dict."""
if len(data) < 64 or data[:4] != b'\x27\x05\x19\x56':
return None
magic = struct.unpack('>I', data[0:4])[0]
hdr_crc = struct.unpack('>I', data[4:8])[0]
timestamp = struct.unpack('>I', data[8:12])[0]
data_size = struct.unpack('>I', data[12:16])[0]
load_addr = struct.unpack('>I', data[16:20])[0]
entry_addr = struct.unpack('>I', data[20:24])[0]
data_crc = struct.unpack('>I', data[24:28])[0]
os_type = data[28]
arch = data[29]
img_type = data[30]
comp = data[31]
name = data[32:64].split(b'\x00')[0].decode('ascii', errors='replace')
comp_names = {0: 'none', 1: 'gzip', 2: 'bzip2', 3: 'lzma', 4: 'lzo', 5: 'lz4'}
type_names = {1: 'Standalone', 2: 'Kernel', 3: 'RAMDisk', 4: 'Multi', 5: 'Firmware',
6: 'Script', 7: 'Filesystem', 8: 'Flat Device Tree'}
arch_names = {2: 'Alpha', 5: 'ARM', 6: 'x86', 7: 'IA-64', 8: 'MIPS', 9: 'MIPS64',
10: 'PowerPC', 12: 'SH', 15: 'x86_64', 16: 'ARC'}
return {
'magic': f'0x{magic:08x}',
'timestamp': datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S'),
'data_size': data_size,
'load_addr': f'0x{load_addr:08x}',
'entry_addr': f'0x{entry_addr:08x}',
'data_crc': f'0x{data_crc:08x}',
'os_type': os_type,
'arch': arch_names.get(arch, f'Unknown({arch})'),
'image_type': type_names.get(img_type, f'Unknown({img_type})'),
'compression': comp_names.get(comp, f'Unknown({comp})'),
'name': name,
'header_size': 64,
}
def format_size(size):
"""Format a byte size as human-readable string."""
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} TB"
# ─── Main Dumper ─────────────────────────────────────────────────────────────
class TLIDumper:
"""Main TLI firmware dumper class."""
def __init__(self, filepath):
self.filepath = filepath
self.data = None
self.tar_start = 0
self.partitions = []
self.tar_members = []
def load(self):
"""Load the TLI file into memory (reads header + tar index only for large files)."""
with open(self.filepath, 'rb') as f:
# Read header
self.data = f.read()
# Find tar archive start (look for first ustar magic)
self.tar_start = self._find_tar_start()
# Parse TLV header
header_data = self.data[:self.tar_start]
self.partitions = parse_tli_header(header_data)
# Parse tar index
self._parse_tar_index()
def _find_tar_start(self):
"""Find the start offset of the embedded tar archive."""
for i in range(0, min(len(self.data), 0x1000)):
if i + 263 < len(self.data):
if self.data[i + 257:i + 262] == b'ustar':
return i
return 0
def _parse_tar_index(self):
"""Parse the tar archive and populate partition info."""
try:
tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:]))
for m in tf.getmembers():
if m.isfile():
# Match tar member to partition
for p in self.partitions:
if p.name == m.name:
p.tar_size = m.size
p.tar_offset = self.tar_start + m.offset + 512
break
self.tar_members.append({
'name': m.name,
'size': m.size,
'offset': m.offset,
'mode': m.mode,
'uid': m.uid,
'gid': m.gid,
'mtime': m.mtime,
})
tf.close()
except Exception as e:
print(f"Warning: tar parsing issue: {e}")
def show_info(self):
"""Display firmware information and partition table."""
print("=" * 80)
print(f" Loewe TLI Firmware - {os.path.basename(self.filepath)}")
print("=" * 80)
print(f"\n File size: {format_size(len(self.data))} ({len(self.data)} bytes)")
print(f" TLV header: 0x0000 - 0x{self.tar_start:04x} ({self.tar_start} bytes)")
print(f" Tar archive: 0x{self.tar_start:04x} - EOF")
print(f" Partitions: {len(self.partitions)}")
print("\n" + "-" * 80)
print(f" {'#':>3} {'Name':<12} {'Size':>12} {'Format':<16} {'Description'}")
print("-" * 80)
for i, p in enumerate(self.partitions):
# Detect format
fmt = "unknown"
if p.tar_offset > 0 and p.tar_offset < len(self.data):
sig = detect_signature(self.data[p.tar_offset:p.tar_offset + 6])
if sig:
fmt = sig
else:
# Check for inner tar
first_64 = self.data[p.tar_offset:p.tar_offset + 64]
null_pos = first_64.find(b'\x00')
if null_pos and 0 < null_pos < 32:
maybe_name = first_64[:null_pos].decode('ascii', errors='replace')
if '.' in maybe_name and maybe_name.isprintable():
fmt = f"tar ({maybe_name})"
desc = p.description if len(p.description) < 30 else p.description[:27] + "..."
size_str = format_size(p.tar_size) if p.tar_size else "?"
print(f" {i+1:3} {p.name:<12} {size_str:>12} {fmt:<16} {desc}")
# Show flash layout
if any(p.flash_blocks for p in self.partitions):
print("\n" + "-" * 80)
print(" Flash Memory Layout (from TLV header):")
print("-" * 80)
for p in self.partitions:
for j, blk in enumerate(p.flash_blocks):
start = blk['flash_start']
size_end = blk['flash_size_or_end']
crc = blk['crc32']
crc_str = f"0x{crc:08x}" if crc else "N/A"
print(f" {p.name:<12} block {j}: start=0x{start:08x} end/size=0x{size_end:08x} crc={crc_str}")
# Show uImage details for applicable partitions
for p in self.partitions:
if p.tar_offset > 0 and p.tar_offset + 64 <= len(self.data):
uimg = parse_uimage_header(self.data[p.tar_offset:p.tar_offset + 64])
if uimg:
print(f"\n [{p.name}] uImage Details:")
print(f" Name: {uimg['name']}")
print(f" Created: {uimg['timestamp']}")
print(f" Data size: {format_size(uimg['data_size'])}")
print(f" Load addr: {uimg['load_addr']}")
print(f" Entry addr: {uimg['entry_addr']}")
print(f" Architecture: {uimg['arch']}")
print(f" Type: {uimg['image_type']}")
print(f" Compression: {uimg['compression']}")
def extract(self, output_dir, partition_names=None, raw=False, unpack=False, verbose=False):
"""Extract partitions to the output directory.
Args:
output_dir: Directory to save extracted files
partition_names: List of partition names to extract (None = all)
raw: Save raw data without processing
unpack: Try to unpack inner formats
verbose: Show detailed progress
"""
os.makedirs(output_dir, exist_ok=True)
tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:]))
for p in self.partitions:
if partition_names and p.name not in partition_names:
continue
if p.tar_size == 0:
if verbose:
print(f" Skipping {p.name} (no data)")
continue
if verbose:
print(f" Extracting {p.name} ({format_size(p.tar_size)})...")
# Extract from tar
try:
member = tf.getmember(p.name)
f = tf.extractfile(member)
if not f:
print(f" Warning: Could not extract {p.name}")
continue
part_data = f.read()
except Exception as e:
print(f" Error extracting {p.name}: {e}")
continue
# Save raw partition data
part_dir = os.path.join(output_dir, p.name)
os.makedirs(part_dir, exist_ok=True)
raw_path = os.path.join(part_dir, f"{p.name}.bin")
with open(raw_path, 'wb') as out:
out.write(part_data)
if verbose:
sig = detect_signature(part_data[:6]) or "raw"
print(f" Saved raw: {raw_path} [{sig}]")
if raw:
continue
# Try to unpack inner formats
if unpack:
self._unpack_partition(part_dir, p.name, part_data, verbose)
tf.close()
print(f"\n Extraction complete! Files saved to: {output_dir}")
def _unpack_partition(self, part_dir, name, data, verbose=False):
"""Try to unpack the inner format of a partition."""
sig = detect_signature(data[:6])
if sig == 'uImage' or sig == 'uImage-ARM':
# Parse uImage header and extract payload
uimg = parse_uimage_header(data[:64])
if uimg:
if verbose:
print(f" uImage: {uimg['name']}, {uimg['compression']}, {format_size(uimg['data_size'])}")
# Save uImage info
info_path = os.path.join(part_dir, f"{name}_uimage_info.txt")
with open(info_path, 'w') as f:
for k, v in uimg.items():
f.write(f"{k}: {v}\n")
# Extract payload (skip 64-byte header)
payload = data[64:]
payload_path = os.path.join(part_dir, f"{name}_payload.bin")
with open(payload_path, 'wb') as f:
f.write(payload)
# If gzip compressed, decompress
if uimg['compression'] == 'gzip' and payload[:2] == b'\x1f\x8b':
try:
decompressed = gzip.decompress(payload)
dec_path = os.path.join(part_dir, f"{name}_kernel.bin")
with open(dec_path, 'wb') as f:
f.write(decompressed)
if verbose:
print(f" Decompressed kernel: {format_size(len(decompressed))}")
except Exception as e:
if verbose:
print(f" gzip decompress failed: {e}")
# Try to find embedded filesystem
for offset in range(0, min(len(payload), 0x10000), 256):
if offset + 4 < len(payload):
if payload[offset:offset+4] == b'HSQS':
if verbose:
print(f" Found SquashFS at offset 0x{offset:04x}")
sqsh_path = os.path.join(part_dir, f"{name}_squashfs.bin")
with open(sqsh_path, 'wb') as f:
f.write(payload[offset:])
break
elif sig == 'gzip':
try:
decompressed = gzip.decompress(data)
dec_path = os.path.join(part_dir, f"{name}_decompressed.bin")
with open(dec_path, 'wb') as f:
f.write(decompressed)
if verbose:
print(f" Decompressed: {format_size(len(decompressed))}")
except Exception as e:
if verbose:
print(f" gzip decompress failed: {e}")
elif sig == 'xz':
try:
import lzma
decompressed = lzma.decompress(data)
dec_path = os.path.join(part_dir, f"{name}_decompressed.bin")
with open(dec_path, 'wb') as f:
f.write(decompressed)
if verbose:
print(f" Decompressed: {format_size(len(decompressed))}")
# Check what the decompressed data is
inner_sig = detect_signature(decompressed[:6])
if inner_sig and verbose:
print(f" Inner format: {inner_sig}")
# If it's a cpio archive
if decompressed[:6] == b'070701' or decompressed[:6] == b'070702':
cpio_path = os.path.join(part_dir, f"{name}.cpio")
with open(cpio_path, 'wb') as f:
f.write(decompressed)
if verbose:
print(f" Saved as cpio archive")
except Exception as e:
if verbose:
print(f" xz decompress failed: {e}")
elif sig == 'SquashFS':
sqsh_path = os.path.join(part_dir, f"{name}.squashfs")
with open(sqsh_path, 'wb') as f:
f.write(data)
if verbose:
print(f" Saved as SquashFS image")
else:
# Check if it's a tar archive with an inner file
null_pos = data[:64].find(b'\x00')
if null_pos and 0 < null_pos < 64:
maybe_name = data[:null_pos].decode('ascii', errors='replace')
if '.' in maybe_name and maybe_name.isprintable():
# It contains an inner file - look for ustar
for off in range(0, min(len(data), 0x400), 512):
if off + 263 < len(data) and data[off+257:off+262] == b'ustar':
# This IS a tar - extract the inner file
try:
inner_tar = tarfile.open(fileobj=io.BytesIO(data))
for m in inner_tar.getmembers():
if m.isfile():
inf = inner_tar.extractfile(m)
if inf:
inner_data = inf.read()
inner_path = os.path.join(part_dir, m.name.replace('/', '_'))
os.makedirs(os.path.dirname(inner_path) or part_dir, exist_ok=True)
with open(inner_path, 'wb') as f:
f.write(inner_data)
if verbose:
inner_sig = detect_signature(inner_data[:6]) or "raw"
print(f" Inner file: {m.name} ({format_size(len(inner_data))}) [{inner_sig}]")
# Recursively unpack
self._unpack_partition(part_dir, f"{name}_{m.name}", inner_data, verbose)
inner_tar.close()
except:
pass
break
def compute_hashes(self, output_file=None):
"""Compute MD5/SHA1/SHA256/CRC32 hashes of each partition."""
print("\n Partition Hashes:")
print(" " + "-" * 76)
print(f" {'Name':<12} {'Size':>10} {'MD5':<34} {'CRC32':<10}")
print(" " + "-" * 76)
tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:]))
results = []
for p in self.partitions:
if p.tar_size == 0:
continue
try:
member = tf.getmember(p.name)
f = tf.extractfile(member)
if not f:
continue
part_data = f.read()
md5 = hashlib.md5(part_data).hexdigest()
crc = zlib.crc32(part_data) & 0xffffffff
print(f" {p.name:<12} {p.tar_size:>10} {md5:<34} {crc:08x}")
results.append({
'name': p.name,
'size': p.tar_size,
'md5': md5,
'crc32': f"{crc:08x}",
})
except:
pass
tf.close()
return results
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description='Loewe TLI Firmware Dumper / Extractor',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s firmware.tli # Show info
%(prog)s firmware.tli output/ --extract # Extract all partitions
%(prog)s firmware.tli output/ --extract Main # Extract only Main
%(prog)s firmware.tli output/ --unpack # Extract and unpack inner formats
%(prog)s firmware.tli --hashes # Show partition hashes
"""
)
parser.add_argument('input', help='Path to .tli firmware file')
parser.add_argument('output', nargs='?', default=None, help='Output directory')
parser.add_argument('--info', action='store_true', help='Show partition table and metadata only')
parser.add_argument('--extract', nargs='?', const='all', default=None,
help='Extract partitions (comma-separated names, or "all")')
parser.add_argument('--raw', action='store_true', help='Save raw data without processing')
parser.add_argument('--unpack', action='store_true', help='Try to unpack inner formats (uImage, gzip, xz)')
parser.add_argument('--hashes', action='store_true', help='Compute and display partition hashes')
parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output')
args = parser.parse_args()
# Load file
if not os.path.isfile(args.input):
print(f"Error: File not found: {args.input}")
sys.exit(1)
print(f"\n Loading {args.input}...")
dumper = TLIDumper(args.input)
dumper.load()
# Default action: show info
if not args.extract and not args.hashes:
dumper.show_info()
if args.hashes:
dumper.compute_hashes()
if args.extract:
if not args.output:
args.output = os.path.splitext(args.input)[0] + "_extracted"
partition_names = None
if args.extract != 'all':
partition_names = [n.strip() for n in args.extract.split(',')]
print(f"\n Extracting to: {args.output}")
dumper.extract(
args.output,
partition_names=partition_names,
raw=args.raw,
unpack=args.unpack,
verbose=args.verbose,
)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment