Skip to content

Instantly share code, notes, and snippets.

@Abyss-W4tcher
Last active September 3, 2024 17:44
Show Gist options
  • Save Abyss-W4tcher/f8a1c578c8408a3c4d404e092034bd29 to your computer and use it in GitHub Desktop.
Save Abyss-W4tcher/f8a1c578c8408a3c4d404e092034bd29 to your computer and use it in GitHub Desktop.

HP printer firmware unpacking

The following instructions guides a reverser into unpacking an HP firmware. Take care of choosing a complete firmware, and not a firmware incremental update. The unpacking differs from https://vegamay.li/printer-part2, as it takes advantage of existing tools : pcldumper and srecords.

Prerequisites :

  • sudo apt-get install srecord

Create a new directory, and place your FUL firmware inside.

Use pcldumper to decode the PCL commands :

docker run --rm -it -v $(realpath [FIRMWARE_FUL_FILE]):/firmware_ful anapsix/alpine-java bash -c '{ apk update && apk add wget && wget https://github.com/kevLmurphy/pcldumper/releases/download/1.4/pcldumper-1.4-all.jar; } &>/dev/null && java -jar pcldumper-1.4-all.jar /firmware_ful -v' > firmware.pcldumper

Then, use this script to unpack the decompressed firmware, take care of editing "# Full path to "firmware.pcldumper" without prefix !" following line :

from enum import Enum
import re
import struct
import subprocess


class SRecordType(Enum):
    S0 = 2  # S0: Header, 16-bit address
    S1 = 2  # S1: Data, 16-bit address
    S2 = 3  # S2: Data, 24-bit address
    S3 = 4  # S3: Data, 32-bit address
    S4 = 0  # S4: Reserved, no address bytes
    S5 = 2  # S5: Count, 16-bit count (not address)
    S6 = 3  # S6: Count, 24-bit count (not address)
    S7 = 4  # S7: Start Address (Termination), 32-bit address
    S8 = 3  # S8: Start Address (Termination), 24-bit address
    S9 = 2  # S9: Start Address (Termination), 16-bit address


def srec_crc(raw: bytes) -> int:
    return 0xFF - (sum(raw) & 0xFF)


def from_twos_complement(value, bits=8):
    """
    Convert a two's complement number to its original positive integer.

    :param value: The two's complement number (as an integer).
    :param bits: The bit-width of the number (default is 8).
    :return: The original positive integer.
    """
    # Check if the sign bit is set (i.e., if the number is negative)
    if (value & (1 << (bits - 1))) != 0:
        # Perform two's complement to find the positive representation
        value = value - (1 << bits)
    return abs(value)


def convert_custom_srec(raw: bytes, index):
    i_1_str = f"{raw[index]:x}".zfill(2)

    if not i_1_str.startswith("3"):
        return None
    rec_type = "S" + i_1_str[1]

    try:
        SRecordType[rec_type].value
    except:
        return None
    byte_count = int.from_bytes(raw[index + 1 : index + 2], "little")
    want = raw[index + 1 : index + byte_count + 2]
    size = 1 + byte_count + 1

    crc = want[-1]
    check_crc = srec_crc(want[:-1])

    if crc != check_crc:
        return None

    toret = rec_type + want.hex() + "\n"
    return toret, size


def tiff_uncompress(data):
    """
    #res = decode_tiff_packbits(b"\x08JSOFrulez\xFB!\xF81")
    #assert(res == b"JSOFrulez!!!!!!111111111")
    """
    decoded = bytearray()
    i = 0
    while i < len(data):
        control_byte = data[i]
        i += 1
        # Literal
        if control_byte < 128:
            for _ in range(control_byte + 1):
                decoded.append(data[i])
                i += 1
        # Run-length encoded
        elif control_byte > 128:
            # Twos complement
            length = from_twos_complement(control_byte)
            for _ in range(length + 1):
                decoded.append(data[i])

            i += 1
        else:
            print("NO OP DETECTED")
            exit()
    return decoded


def delta_row_uncompress(data: bytes, seed_row: bytes):
    seed = bytearray()
    seed.extend(seed_row)
    seedOffset = 0
    decoded = bytearray()

    while True:
        if len(data) == 0:
            break

        command = struct.unpack("B", data[:1])[0]
        bytesToReplace = ((command & 0b11100000) >> 5) + 1
        deltaOffset = command & 0b00011111

        if deltaOffset == 31:
            print("Unhandled 31 offset")
            exit(-1)

        deltaBytes = data[1 : 1 + bytesToReplace]

        for i in range(bytesToReplace):
            seed[seedOffset + deltaOffset + i] = deltaBytes[i]
        seedOffset += bytesToReplace + deltaOffset

        data = data[1 + bytesToReplace :]

    decoded.extend(seed)
    return decoded


def pcldumper_parser(filepath: str, output_filepath: str):
    with open(filepath, "r") as f:
        content = f.read()

    decompress_func = "Unencoded"
    is_transfer_raster = False
    raster_data = b""
    decompressed = b""
    specs = {}
    for line in content.splitlines():
        line = line.strip()
        spec_check = re.findall("[*]r(\\d+)\\w \\s*Source Raster (\\S+)", line)
        if spec_check != []:
            specs[spec_check[0][1]] = int(spec_check[0][0])

        if is_transfer_raster and line.startswith(">>>"):
            raster_data += bytes.fromhex(
                re.findall(" ((?:[A-F0-9]{2} ){1,16})", line)[0]
            )
        elif is_transfer_raster and not line.startswith(">>>"):
            is_transfer_raster = False
            if decompress_func == "Unencoded":
                pass
            elif decompress_func == "Tagged Imaged File Format":
                raster_data = tiff_uncompress(raster_data)
            elif decompress_func == "Delta row compression":
                raster_data = delta_row_uncompress(raster_data, seed_row)
            else:
                print(f"Unhandled compression method : {decompress_func}")
                exit(1)

            # Pad (section 6-33 of PCL doc)
            if transfer_type == "Plane" and len(raster_data) != specs["Width"]:
                raster_data += b"\x00" * (specs["Width"] - len(raster_data))

            decompressed += raster_data
            seed_row = raster_data
            raster_data = b""

        compression_check = re.findall("Set Compression Method [(]([^)]+)[)]", line)
        if compression_check != []:
            decompress_func = compression_check[0]

        if not line.startswith(">>>"):
            transfer_raster = re.findall(
                "[*]b(\\d+)\\w \\s*Transfer Raster Data By (\\S+)", line
            )

            if transfer_raster == [] and "Transfer Raster Data" in line:
                exit("Regex mismatch")

            if transfer_raster != []:
                transfer_size, transfer_type = transfer_raster[0]
                is_transfer_raster = True

    with open(output_filepath, "wb") as f:
        f.write(decompressed)

    return decompressed


def parse_srec_from_decompressed(srec_raw: bytes, output_filepath: str):

    index = 0
    cust_srec = ""
    while index < len(srec_raw):
        try:
            srec, read = convert_custom_srec(srec_raw, index)
        except Exception as e:
            print(f"Errored out at {index} : {srec_raw[index:index+32].hex()}")
            break
        cust_srec += srec.upper()
        index += read

    with open(output_filepath, "w+") as f:
        f.write(cust_srec)


# Full path to "firmware.pcldumper" without prefix !
fn = "[PATH_FIRMWARE_PCLDUMPER_WITHOUT_PREFIX]"

# Decompress the firmware
decompressed = pcldumper_parser(
    f"{fn}.pcldumper",
    f"{fn}.decompressed",
)

# Separate the bootloader and the binary srec records
ascii_bootloader_srec, raw_bin_srec = re.split(
    b"F[A-Z0-9]+\x0aP[A-Z0-9]+\x0a", decompressed, 1
)

# Write plain SREC bootloader records to disk
# Inspect or manipulate with srec_info or srec_cat
# Strip the "SA" record at the beginning
with open(f"{fn}.bootloader_srec", "w+") as f:
    f.write(ascii_bootloader_srec.decode())

# Convert custom binary srec to classic srec records
parse_srec_from_decompressed(raw_bin_srec, f"{fn}.flash_srec")

srec_info = subprocess.run(
    ["srec_info", f"{fn}.flash_srec"], stdout=subprocess.PIPE, text=True
)
print(srec_info.stdout)
data_start = re.findall("Data:\\s*([0-9A-Fa-f]+) - [0-9A-Fa-f]+", srec_info.stdout)[0]

# Convert raw flash srecords to binary
subprocess.run(
    [
        "srec_cat",
        f"{fn}.flash_srec",
        "-offset",
        f"-0x{data_start}",
        "-o",
        f"{fn}.flash_with_oob",
        "-binary",
    ]
)

# Strip OOB after each page
with open(f"{fn}.flash_with_oob", "rb") as f:
    flash_with_oob = f.read()

flash = bytearray()
for i in range(0, len(flash_with_oob), 0x840):
    flash.extend(flash_with_oob[i : i + 0x800])

# Save the nand flash to disk
# Beware, it needs to be window slide decompressed
# And sections needs to be load (see "Sirius Hacking")
with open(f"{fn}.flash", "wb") as f:
    f.write(flash)

Then, you will need to load the flash in RAM, through a custom IDA loader for example (see https://vegamay.li/printer-part2).

Ressources

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