diff --git a/scripts/ubuntu-iso-to-uboot.py b/scripts/ubuntu-iso-to-uboot.py
new file mode 100755
index 00000000000..ec32ac7b0dd
--- /dev/null
+++ b/scripts/ubuntu-iso-to-uboot.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+"""Turn an Ubuntu live ISO into one that boots via U-Boot + BLS.
+
+BLS (Boot Loader Specification) is a freedesktop.org standard for
+describing boot menu entries as small files under /loader/entries/ on
+the EFI system partition. Each entry names a kernel, initrd and command
+line; U-Boot's BOOTMETH_BLS scans the ESP and presents them in its boot
+menu, replacing the shim/grub chain that Ubuntu ships by default.
+
+The ISO's appended EFI System Partition is replaced with a fresh ESP
+containing:
+
+    /EFI/BOOT/BOOTX64.EFI   U-Boot EFI app (built with CONFIG_BOOTMETH_BLS=y)
+    /casper/vmlinuz         copied from the input ISO
+    /casper/initrd          copied from the input ISO
+    /loader/entry.conf      BLS Type #1 entry pointing at the above
+
+The ISO9660 tree is preserved unchanged, so casper still finds its squashfs
+by disc label at runtime. All other boot records (BIOS El Torito, grub2 MBR,
+GPT layout) are preserved by xorriso's -boot_image any replay.
+
+Quick start (run from the root of the U-Boot tree)::
+
+    # 1. Install host tools
+    sudo apt install xorriso mtools dosfstools qemu-system-x86 ovmf
+
+    # 2. Download an Ubuntu live ISO (desktop or server both work)
+    curl -LO https://releases.ubuntu.com/24.04.1/ubuntu-24.04.1-desktop-amd64.iso
+
+    # 3. Build U-Boot as an x86_64 EFI application. The defconfig enables
+    #    BOOTMETH_BLS, FS_ISOFS and JOLIET. If rustc is not installed,
+    #    also pass -d RUST_EXAMPLES -d EXAMPLES to scripts/config and
+    #    re-run olddefconfig before building.
+    make O=/tmp/b/efi-x86_app64 efi-x86_app64_defconfig
+    make O=/tmp/b/efi-x86_app64 -j$(nproc)
+    # produces /tmp/b/efi-x86_app64/u-boot-app.efi
+
+    # 4. Rewrite the ISO to boot via U-Boot
+    scripts/ubuntu-iso-to-uboot.py ubuntu-24.04.1-desktop-amd64.iso \\
+        -u /tmp/b/efi-x86_app64/u-boot-app.efi \\
+        -o ubuntu-uboot.iso
+
+    # 5. Try it under QEMU + OVMF
+    cp /usr/share/OVMF/OVMF_VARS_4M.fd /tmp/OVMF_VARS.fd
+    qemu-system-x86_64 -machine q35 -m 4096 -smp 2 \\
+        -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd \\
+        -drive if=pflash,format=raw,file=/tmp/OVMF_VARS.fd \\
+        -drive if=virtio,file=ubuntu-uboot.iso,format=raw,readonly=on
+
+Assumptions:
+    - Ubuntu-style live ISO with casper/vmlinuz and casper/initrd.
+    - Input ISO has an appended GPT EFI System Partition
+      (-append_partition 2 in xorriso's report).
+    - U-Boot EFI app is built with CONFIG_BOOTMETH_BLS=y,
+      CONFIG_CMD_ZBOOT=y and FAT support (efi-x86_app64_defconfig is
+      the reference config).
+    - xorriso, mtools and dosfstools are installed on the host
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+# Add the tools directory to the path for u_boot_pylib
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
+
+# pylint: disable=wrong-import-position,import-error
+from u_boot_pylib import command
+from u_boot_pylib import tout
+
+DEFAULT_CMDLINE = 'console=ttyS0,115200 console=tty0 --- quiet'
+REQUIRED_TOOLS = ('xorriso', 'mcopy', 'mmd', 'mkfs.vfat')
+MIB = 1024 * 1024
+
+
+def check_tools() -> None:
+    missing = [t for t in REQUIRED_TOOLS if not shutil.which(t)]
+    if missing:
+        tout.fatal(
+            f'missing tools: {" ".join(missing)}\n'
+            f'try: apt install xorriso mtools dosfstools'
+        )
+
+
+def parse_boot_report(iso: Path) -> tuple[str, str]:
+    """Return (volume_label, esp_partition_guid) from xorriso's mkisofs report.
+
+    Raises SystemExit if the ISO does not have an appended EFI System
+    Partition on slot 2
+    """
+    # xorriso prints the report on stdout; stderr carries progress/status.
+    report = command.output(
+        'xorriso', '-indev', str(iso), '-report_el_torito', 'as_mkisofs',
+    )
+
+    m_vol = re.search(r"^-V '([^']*)'", report, re.MULTILINE)
+    m_esp = re.search(
+        r'^-append_partition 2 ([0-9a-fA-F]+) ', report, re.MULTILINE,
+    )
+    if not m_vol:
+        tout.fatal('could not find volume label in xorriso report')
+    if not m_esp:
+        tout.fatal(
+            'could not find appended partition 2 in xorriso report '
+            '(is this an Ubuntu-style hybrid ISO?)'
+        )
+    return m_vol.group(1), m_esp.group(1)
+
+
+def extract_from_iso(iso: Path, src: str, dst: Path) -> None:
+    """Pull a single file out of the ISO9660 tree via xorriso -osirrox"""
+    dst.parent.mkdir(parents=True, exist_ok=True)
+    command.run(
+        'xorriso', '-osirrox', 'on', '-indev', str(iso),
+        '-extract', f'/{src.lstrip("/")}', str(dst),
+    )
+
+
+def build_esp(
+    esp_path: Path,
+    size_mib: int,
+    uboot_efi: Path,
+    vmlinuz: Path,
+    initrd: Path,
+    entry_conf: str,
+) -> None:
+    """Create a fresh FAT32 ESP at esp_path populated with BLS + kernel.
+
+    Args:
+        esp_path (Path): Output file to hold the new ESP image
+        size_mib (int): Size of the ESP in mebibytes
+        uboot_efi (Path): U-Boot EFI app to install as /EFI/BOOT/BOOTX64.EFI
+        vmlinuz (Path): Kernel image to install under /casper/vmlinuz
+        initrd (Path): Initrd image to install under /casper/initrd
+        entry_conf (str): Contents for /loader/entry.conf (BLS Type #1)
+    """
+    with esp_path.open('wb') as f:
+        f.truncate(size_mib * MIB)
+    command.output('mkfs.vfat', '-F32', '-n', 'ESP', str(esp_path))
+    command.run('mmd', '-i', str(esp_path),
+                '::EFI', '::EFI/BOOT', '::loader', '::casper')
+
+    def put(src: Path, dst: str) -> None:
+        command.run('mcopy', '-i', str(esp_path), str(src), f'::{dst}')
+
+    put(uboot_efi, 'EFI/BOOT/BOOTX64.EFI')
+    put(vmlinuz, 'casper/vmlinuz')
+    put(initrd, 'casper/initrd')
+
+    entry = Path(esp_path.parent) / 'entry.conf'
+    entry.write_text(entry_conf)
+    put(entry, 'loader/entry.conf')
+
+
+def auto_esp_size(files: list[Path], headroom_mib: int = 16) -> int:
+    """Sum file sizes + headroom, round up to MiB, floor at 64 MiB (FAT32)"""
+    total = sum(f.stat().st_size for f in files) + headroom_mib * MIB
+    mib = (total + MIB - 1) // MIB
+    return max(mib, 64)
+
+
+def repack_iso(
+    in_iso: Path, out_iso: Path, esp_img: Path, esp_guid: str,
+) -> None:
+    """Stream the input ISO to a new ISO, replacing partition 2's data.
+
+    -boot_image any replay preserves every other boot record (BIOS El Torito,
+    grub2 MBR, GPT layout); only the bytes behind partition 2 are rewritten
+    """
+    command.run(
+        'xorriso',
+        '-indev', str(in_iso),
+        '-outdev', str(out_iso),
+        '-boot_image', 'any', 'replay',
+        '-append_partition', '2', esp_guid, str(esp_img),
+        '-commit',
+    )
+
+
+def main() -> None:
+    p = argparse.ArgumentParser(
+        description='Rewrite an Ubuntu live ISO to boot via U-Boot + BLS.',
+    )
+    p.add_argument('iso', type=Path, help='input Ubuntu live ISO')
+    p.add_argument('-u', '--uboot', type=Path, required=True,
+                   help='U-Boot EFI app (e.g. u-boot-app.efi)')
+    p.add_argument('-o', '--out', type=Path, required=True,
+                   help='output ISO path')
+    p.add_argument('-k', '--kernel', default='casper/vmlinuz',
+                   help='kernel path inside the input ISO')
+    p.add_argument('-i', '--initrd', default='casper/initrd',
+                   help='initrd path inside the input ISO')
+    p.add_argument('-a', '--cmdline', default=DEFAULT_CMDLINE,
+                   help='kernel command line written into loader/entry.conf')
+    p.add_argument('-t', '--title', default=None,
+                   help='BLS entry title (default: derived from volume label)')
+    p.add_argument('-s', '--esp-size', type=int, default=None,
+                   help='ESP size in MiB (default: auto-size to fit)')
+    args = p.parse_args()
+
+    if not args.iso.is_file():
+        tout.fatal(f'ISO not found: {args.iso}')
+    if not args.uboot.is_file():
+        tout.fatal(f'EFI app not found: {args.uboot}')
+    check_tools()
+
+    print(f'=> Reading boot config from {args.iso}')
+    # Extract the volume label and ESP partition GUID from xorriso's report
+    vol_id, esp_guid = parse_boot_report(args.iso)
+    title = args.title or f'U-Boot BLS boot ({vol_id})'
+    print(f'   Volume label: {vol_id}')
+    print(f'   ESP GUID:     {esp_guid}')
+
+    with tempfile.TemporaryDirectory(prefix='iso2uboot.') as td:
+        work = Path(td)
+        vmlinuz = work / 'vmlinuz'
+        initrd = work / 'initrd'
+
+        print(f'=> Extracting /{args.kernel} and /{args.initrd}')
+        extract_from_iso(args.iso, args.kernel, vmlinuz)
+        extract_from_iso(args.iso, args.initrd, initrd)
+
+        esp_mib = args.esp_size or auto_esp_size([vmlinuz, initrd, args.uboot])
+        print(f'=> Building {esp_mib} MiB ESP')
+
+        entry_conf = f'''\
+title {title}
+linux /casper/vmlinuz
+initrd /casper/initrd
+options {args.cmdline}
+'''
+        esp = work / 'esp.img'
+        build_esp(esp, esp_mib, args.uboot, vmlinuz, initrd, entry_conf)
+
+        print(f'=> Repacking to {args.out}')
+        repack_iso(args.iso, args.out, esp, esp_guid)
+
+    size_mib = args.out.stat().st_size / MIB
+    print(f'=> Done: {args.out} ({size_mib:.1f} MiB)')
+
+
+if __name__ == '__main__':
+    main()
