# Ghidra from the official GitHub release, checked against the SHA-256 in the
# release notes: https://github.com/NationalSecurityAgency/ghidra/releases
# 12.1.4 because that is the version the GhidraMCP release below is built
# for; Ghidra only loads extensions built for its exact version.
ARG GHIDRA_VERSION=12.1.4
ARG GHIDRA_BUILD_DATE=20260921
ARG GHIDRA_SHA256=ddac49f903da9d5bac833e5cc79395098b9c33cfd3279be5f31bd00387d2d4db
# --- Stage: Ghidra distribution ----------------------------------------------
# Downloaded once, used by the final image and to build extensions against.
FROM ubuntu:24.04 AS ghidra-dist
ARG GHIDRA_VERSION
ARG GHIDRA_BUILD_DATE
ARG GHIDRA_SHA256
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSLo /tmp/ghidra.zip \
"https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_${GHIDRA_VERSION}_build/ghidra_${GHIDRA_VERSION}_PUBLIC_${GHIDRA_BUILD_DATE}.zip" \
&& echo "${GHIDRA_SHA256} /tmp/ghidra.zip" | sha256sum -c - \
&& unzip -q /tmp/ghidra.zip -d /opt \
&& rm /tmp/ghidra.zip
# --- Build stage: ESP32 Flash Loader for Ghidra --------------------------------
# https://github.com/dynacylabs/ghidra-esp32-flash-loader: loads ESP32 flash
# dumps / app images (partition table, segments at their addresses, chip
# ROM, peripherals from Espressif's SVD files). Its tags ship no binaries,
# so it's built against this exact Ghidra. Pinned to a master commit (Ghidra
# 12 API, newer than the v12.0 tag) and its submodules' commits. Gradle is
# downloaded directly, checked against the SHA-256 gradle.org publishes,
# because Ghidra's own wrapper doesn't verify its download.
FROM ubuntu:24.04 AS esp32-loader-build
ARG DEBIAN_FRONTEND=noninteractive
ARG GHIDRA_VERSION
ARG ESP32_LOADER_COMMIT=59d88034d07010fb3a3601053554baf02e7be2e4
ARG ESP32_SVD_COMMIT=be20aa12560889d6125d144cdb48cf615ac17628
ARG GHIDRA_SVD_LOADER_COMMIT=8fb5601c3444111159cf1e056cbe9d8457985237
ARG GRADLE_VERSION=9.7.1
ARG GRADLE_SHA256=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git unzip openjdk-21-jdk-headless \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghidra-dist /opt/ghidra_${GHIDRA_VERSION}_PUBLIC /opt/ghidra
RUN set -e \
&& fetch() { git init -q "$2"; git -C "$2" fetch -q --depth 1 "$1" "$3"; git -C "$2" checkout -q FETCH_HEAD; } \
&& fetch https://github.com/dynacylabs/ghidra-esp32-flash-loader.git /src/esp32 "${ESP32_LOADER_COMMIT}" \
&& rmdir /src/esp32/data/svd /src/esp32/ghidra_scripts \
&& fetch https://github.com/espressif/svd.git /src/esp32/data/svd "${ESP32_SVD_COMMIT}" \
&& fetch https://github.com/dynacylabs/ghidra-svd-loader.git /src/esp32/ghidra_scripts "${GHIDRA_SVD_LOADER_COMMIT}" \
&& curl -fsSLo /tmp/gradle.zip "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
&& echo "${GRADLE_SHA256} /tmp/gradle.zip" | sha256sum -c - \
&& unzip -q /tmp/gradle.zip -d /opt \
&& cd /src/esp32 \
&& GHIDRA_INSTALL_DIR=/opt/ghidra "/opt/gradle-${GRADLE_VERSION}/bin/gradle" --no-daemon -q buildExtension \
&& unzip -q dist/ghidra_${GHIDRA_VERSION}_PUBLIC_*_esp32.zip -d /out \
&& grep -qx "version=${GHIDRA_VERSION}" /out/esp32/extension.properties
# --- Build stage: binwalk and its helpers -------------------------------------
# Based on binwalk's own Dockerfile (https://github.com/ReFirmLabs/binwalk,
# Dockerfile at the pinned commit), adapted to Ubuntu 24.04 and with every
# source pinned. Only the results are copied into the final image, so
# compilers and the Rust toolchain stay out of it.
FROM ubuntu:24.04 AS binwalk-build
ARG DEBIAN_FRONTEND=noninteractive
# binwalk: last tag (v3.1.0) is from 2024; master is 3.1.1 with later fixes.
ARG BINWALK_COMMIT=26713972e3f9f52dc37d4a421c4554b0bd9e82ef
ARG RUST_VERSION=1.99.1
ARG DUMPIFS_COMMIT=b7bac90e8312eca2796f2003a52791899eb8dcd9
ARG LZFSE_COMMIT=e634ca58b4821d9f3d560cdc6df5dec02ffc93fd
ARG DMG2IMG_COMMIT=a3e413489ccdd05431401357bf21690536425012
ARG VMLINUX_TO_ELF_COMMIT=36da74ded9cd486f294c8852e0dcab9ef6a48c6b
ARG ESPTOOL_VERSION=5.6.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential clang pkg-config \
zlib1g-dev liblzma-dev liblzo2-dev libucl-dev liblz4-dev libbz2-dev \
libssl-dev libsrecord-dev libfontconfig1-dev \
python3 python3-venv python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Shallow-fetch one exact commit of a git repository.
RUN printf '%s\n' '#!/bin/sh' 'set -e' \
'git init -q "$2"' \
'git -C "$2" fetch -q --depth 1 "$1" "$3"' \
'git -C "$2" checkout -q FETCH_HEAD' > /usr/local/bin/fetch-commit \
&& chmod +x /usr/local/bin/fetch-commit
# binwalk itself, with a pinned Rust toolchain and the locked crate versions.
RUN fetch-commit https://github.com/ReFirmLabs/binwalk.git /src/binwalk "${BINWALK_COMMIT}" \
&& curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain "${RUST_VERSION}" \
&& . /root/.cargo/env \
&& cargo build --release --locked --manifest-path /src/binwalk/Cargo.toml \
&& install -D -m 755 /src/binwalk/target/release/binwalk /out/bin/binwalk
# Extractors binwalk calls that Ubuntu doesn't package (upstream src.sh).
# dumpifs ships a prebuilt binary in its repo; it's removed and rebuilt.
RUN fetch-commit https://github.com/askac/dumpifs.git /src/dumpifs "${DUMPIFS_COMMIT}" \
&& rm /src/dumpifs/dumpifs \
&& make -C /src/dumpifs dumpifs \
&& fetch-commit https://github.com/lzfse/lzfse.git /src/lzfse "${LZFSE_COMMIT}" \
&& make -C /src/lzfse install \
&& fetch-commit https://github.com/Lekensteyn/dmg2img.git /src/dmg2img "${DMG2IMG_COMMIT}" \
&& make -C /src/dmg2img dmg2img vfdecrypt HAVE_LZFSE=1 \
&& install -m 755 /src/dumpifs/dumpifs /usr/local/bin/lzfse \
/src/dmg2img/dmg2img /src/dmg2img/vfdecrypt /out/bin/
# Python extractors binwalk calls (upstream requirements.txt, versions
# pinned), and esptool. Each gets its own venv at its final path; the final
# image uses the same base and Python, so the venvs can be copied as-is.
RUN python3 -m venv /opt/binwalk-py \
&& CC=clang /opt/binwalk-py/bin/pip install --no-cache-dir --disable-pip-version-check \
uefi_firmware==1.16 jefferson==0.4.8 ubi-reader==0.8.16 \
lz4==4.4.5 zstandard==0.25.0 \
"vmlinux-to-elf @ git+https://github.com/marin-m/vmlinux-to-elf@${VMLINUX_TO_ELF_COMMIT}" \
&& python3 -m venv /opt/esptool \
&& /opt/esptool/bin/pip install --no-cache-dir --disable-pip-version-check \
"esptool==${ESPTOOL_VERSION}"
# --- Final image -------------------------------------------------------------
FROM ubuntu:24.04
ARG DEBIAN_FRONTEND=noninteractive
# Match the host user so files written to mounted volumes keep the right owner.
ARG USER_NAME=re
ARG USER_UID=1000
ARG USER_GID=1000
# Fingerprint of Anthropic's apt signing key, from the official install docs:
# https://code.claude.com/docs/en/desktop-linux
ARG ANTHROPIC_APT_FPR=31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg \
&& rm -rf /var/lib/apt/lists/*
# Add Anthropic's apt repository and refuse to continue if the key is wrong.
RUN curl -fsSLo /usr/share/keyrings/claude-desktop-archive-keyring.asc \
https://downloads.claude.ai/claude-desktop/key.asc \
&& gpg --show-keys --with-colons /usr/share/keyrings/claude-desktop-archive-keyring.asc \
| grep -q "^fpr:::::::::${ANTHROPIC_APT_FPR}:" \
&& echo "deb [arch=amd64,arm64 signed-by=/usr/share/keyrings/claude-desktop-archive-keyring.asc] https://downloads.claude.ai/claude-desktop/apt/stable stable main" \
> /etc/apt/sources.list.d/claude-desktop.list
# Recommends are skipped: they pull in QEMU/OVMF for Cowork, which needs KVM
# and isn't useful in this container. No keyring is installed: sign-in is not
# kept between runs. x11-utils provides the display check tools.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
claude-desktop \
x11-utils \
xauth \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Ghidra (downloaded and verified in the ghidra-dist stage). Ghidra 12.1.4
# needs JDK 21 (application.java.min=21). The full JDK, not the headless one,
# because Ghidra's GUI needs the AWT/X11 libraries.
ARG GHIDRA_VERSION
RUN apt-get update \
&& apt-get install -y --no-install-recommends openjdk-21-jdk unzip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghidra-dist /opt/ghidra_${GHIDRA_VERSION}_PUBLIC /opt/ghidra_${GHIDRA_VERSION}_PUBLIC
RUN ln -s "/opt/ghidra_${GHIDRA_VERSION}_PUBLIC" /opt/ghidra \
&& ln -s /opt/ghidra/ghidraRun /usr/local/bin/ghidra
ENV GHIDRA_INSTALL_DIR=/opt/ghidra
# Enable the "Developer" plugin package (File > Configure > Developer) in the
# CodeBrowser. Ghidra copies its default tools into the user's settings on
# first start, and settings are reset on every container start, so the
# default CodeBrowser.tool inside the jar is patched instead of user config.
# A bare only adds the package's RELEASED
# plugins; the snippet also lists the STABLE ones. The build fails if the
# snippet could not be inserted.
COPY ghidra/codebrowser-developer.xml /tmp/codebrowser-developer.xml
RUN set -e; cd /tmp \
&& jar_file=/opt/ghidra/Ghidra/Configurations/Public_Release/lib/Public_Release.jar \
&& unzip -q "$jar_file" defaultTools/CodeBrowser.tool \
&& awk 'FNR==NR { snip = snip $0 "\n"; next } !done && / /tmp/CodeBrowser.tool \
&& mv /tmp/CodeBrowser.tool defaultTools/CodeBrowser.tool \
&& grep -q 'FidDebugPlugin' defaultTools/CodeBrowser.tool \
&& jar uf "$jar_file" defaultTools/CodeBrowser.tool \
&& rm -r /tmp/defaultTools /tmp/codebrowser-developer.xml
# ESP32 Flash Loader (built above), installed like GhidraMCP into Ghidra's
# own Extensions directory. It's a loader, not a plugin, so no tool config is
# needed: it shows up as a format in File > Import File.
COPY --from=esp32-loader-build /out/esp32 /opt/ghidra/Ghidra/Extensions/esp32
# GhidraMCP (https://github.com/LaurieWired/GhidraMCP), official release zip
# checked against the SHA-256 GitHub lists for it. It holds:
# - the Ghidra extension, unpacked into Ghidra's own Extensions directory so
# it is installed for every user. Its GhidraMCPPlugin is a RELEASED plugin
# in the Developer package, so the CodeBrowser config above loads it. It
# serves an HTTP backend on 127.0.0.1:8080 while the CodeBrowser is open.
# - the ghidra-mcp Python MCP server (wheel), installed into its own venv.
# Claude Desktop starts it over stdio; it forwards calls to that backend.
# The wheel only gives version ranges, so every dependency is pinned to
# the version in GhidraMCP's uv.lock at the release commit.
ARG GHIDRAMCP_VERSION=2.0
ARG GHIDRAMCP_SHA256=ebf904fd6782e429e95a60a24fe0724b20c0acb2dedb304135a5281f6b5f2e72
ARG GHIDRAMCP_COMMIT=1e980252087b2479a21b9ca6d82d5829538651a7
ARG GHIDRAMCP_UVLOCK_SHA256=f80ffe8df8167535f253976a1dfb702a9a7f8836d884084017dd7c04e1444837
COPY ghidra/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py
RUN set -e \
&& apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-venv \
&& rm -rf /var/lib/apt/lists/* \
&& rel="GhidraMCP-release-$(echo "${GHIDRAMCP_VERSION}" | tr . -)" \
&& curl -fsSLo /tmp/ghidramcp.zip \
"https://github.com/LaurieWired/GhidraMCP/releases/download/${GHIDRAMCP_VERSION}/${rel}.zip" \
&& echo "${GHIDRAMCP_SHA256} /tmp/ghidramcp.zip" | sha256sum -c - \
&& unzip -q /tmp/ghidramcp.zip -d /tmp/ghidramcp \
&& unzip -q /tmp/ghidramcp/${rel}/ghidra_${GHIDRA_VERSION}_PUBLIC_*_GhidraMCP.zip \
-d /opt/ghidra/Ghidra/Extensions \
&& grep -qx "version=${GHIDRA_VERSION}" /opt/ghidra/Ghidra/Extensions/GhidraMCP/extension.properties \
&& curl -fsSLo /tmp/uv.lock \
"https://raw.githubusercontent.com/LaurieWired/GhidraMCP/${GHIDRAMCP_COMMIT}/uv.lock" \
&& echo "${GHIDRAMCP_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \
&& python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \
&& python3 -m venv /opt/ghidra-mcp \
&& /opt/ghidra-mcp/bin/pip install --no-cache-dir --disable-pip-version-check \
--constraint /tmp/constraints.txt /tmp/ghidramcp/${rel}/ghidra_mcp-*-py3-none-any.whl \
&& /opt/ghidra-mcp/bin/ghidra-mcp --help >/dev/null \
&& rm -rf /tmp/ghidramcp /tmp/ghidramcp.zip /tmp/uv.lock /tmp/constraints.txt /tmp/uvlock-to-constraints.py
# binwalk (built above) with the extraction tools its Dockerfile installs.
# sasquatch (SquashFS variants) comes as a .deb from onekey-sec; the release
# publishes no checksums, so the SHA-256 was taken when it was pinned.
# The Python tools' entry points are linked into /usr/local/bin; the venvs'
# python/pip are not, so the system python3 isn't shadowed.
ARG SASQUATCH_RELEASE=sasquatch-v4.5.1-5
ARG SASQUATCH_SHA256_AMD64=23191cb530bed01e9f00b9a28a74d03db057fb111e13ce5fb383356c1fdf7e6c
# binwalk -E -p (entropy graph as PNG) renders through Kaleido. binwalk's
# build downloads it into the build stage's /root, which the image doesn't
# have, so -p would crash; it's fetched here instead (same release the crate
# uses, no upstream checksum, SHA-256 taken when pinned) and found through
# KALEIDO_PATH at runtime.
ARG KALEIDO_VERSION=0.2.1
ARG KALEIDO_SHA256_AMD64=3eaa3efd41a00db05dd71add7f54ad4e9d4259552e3f7470ac4c1a8594cea214
COPY --from=binwalk-build /out/bin/ /usr/local/bin/
COPY --from=binwalk-build /opt/binwalk-py /opt/binwalk-py
COPY --from=binwalk-build /opt/esptool /opt/esptool
RUN set -e \
&& arch="$(dpkg --print-architecture)" \
&& [ "$arch" = amd64 ] || { echo "sasquatch checksum only pinned for amd64" >&2; exit 1; } \
&& curl -fsSLo /tmp/sasquatch.deb \
"https://github.com/onekey-sec/sasquatch/releases/download/${SASQUATCH_RELEASE}/sasquatch_1.0_${arch}.deb" \
&& echo "${SASQUATCH_SHA256_AMD64} /tmp/sasquatch.deb" | sha256sum -c - \
&& curl -fsSLo /tmp/kaleido.zip \
"https://github.com/plotly/Kaleido/releases/download/v${KALEIDO_VERSION}/kaleido_linux_x64.zip" \
&& echo "${KALEIDO_SHA256_AMD64} /tmp/kaleido.zip" | sha256sum -c - \
&& unzip -q /tmp/kaleido.zip -d /opt/kaleido \
&& rm /tmp/kaleido.zip \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
/tmp/sasquatch.deb \
7zip 7zip-standalone zstd srecord tar unzip sleuthkit cabextract \
lz4 lzop unrar unyaffs cpio device-tree-compiler \
liblzo2-2 libucl1 libfontconfig1 \
&& rm -rf /var/lib/apt/lists/* /tmp/sasquatch.deb \
&& for f in /opt/binwalk-py/bin/* /opt/esptool/bin/*; do \
case "$(basename "$f")" in python*|pip*|activate*|Activate*) continue ;; esac; \
ln -s "$f" /usr/local/bin/; \
done \
&& binwalk --version && esptool version
# Binwalk leaves a symlink to the input file in its extraction directory. The
# target is a container path, which is dangling when viewed from the host.
ENV BINWALK_RM_EXTRACTION_SYMLINK=1 \
KALEIDO_PATH=/opt/kaleido
# Ubuntu 24.04 ships a default "ubuntu" user on UID 1000; replace it.
RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu; fi \
&& groupadd -g "${USER_GID}" "${USER_NAME}" \
&& useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/bash "${USER_NAME}" \
&& install -d -o "${USER_UID}" -g "${USER_GID}" /workspace \
&& ln -s /workspace "/home/${USER_NAME}/workspace" \
&& install -d -o "${USER_UID}" -g "${USER_GID}" \
"/home/${USER_NAME}/.config" \
"/home/${USER_NAME}/.config/ghidra" \
"/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" \
"/home/${USER_NAME}/.config/Claude"
# Ghidra's project window has no default config to patch; it builds one in
# code and saves it on exit. So a starting FrontEndTool.xml with the Developer
# plugins is put in the user settings. Pre-creating the settings dir makes
# Ghidra ask for its user agreement, which it otherwise accepts by itself on
# a fresh start, so preferences carries the same USER_AGREEMENT=ACCEPT.
COPY --chown=${USER_UID}:${USER_GID} ghidra/FrontEndTool.xml ghidra/preferences \
/home/${USER_NAME}/.config/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC/
# Claude Desktop's MCP servers. Only this file is put in ~/.config/Claude;
# sign-in data is still created at runtime and discarded with the container.
COPY --chown=${USER_UID}:${USER_GID} claude-desktop/claude_desktop_config.json \
/home/${USER_NAME}/.config/Claude/claude_desktop_config.json
COPY --chmod=755 launch-apps.sh /usr/local/bin/launch-apps
USER ${USER_NAME}
WORKDIR /home/${USER_NAME}
# launch-apps starts Claude Desktop and Ghidra and stops both (and so the
# container) when either one is closed.
# No keyring: tell Electron not to look for one. Session data lives only in
# the container's filesystem, which is discarded on exit (run with --rm), so
# the user signs in on every start.
# --no-sandbox: Docker's default seccomp profile blocks the namespaces
# Chromium's sandbox needs, so the zygote dies on start. The container's own
# seccomp/namespaces stay in place as the boundary to the host instead.
CMD ["launch-apps", "--password-store=basic", "--no-sandbox"]