# 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}"
# unblob (https://github.com/onekey-sec/unblob), the firmware extractor REA
# uses for extract_firmware; REA is verified with 26.6.4. Installed from the
# PyPI wheel (SHA-256 from PyPI) into its own venv, with every dependency
# pinned to unblob's uv.lock at the release tag's commit, like ghidra-mcp.
ARG UNBLOB_VERSION=26.6.4
ARG UNBLOB_COMMIT=1fcc7a0a584a70a96c31f5a276c20944d199a089
ARG UNBLOB_WHEEL_SHA256=0fc16e9c7dc92fcf86b8b61475700c9dd16e1dddacf34467574ea965a5d3584e
ARG UNBLOB_UVLOCK_SHA256=0a88ed397d60e0624bdef34c7a0b7329a6d278dfdedb86f940c6ee25401c0317
COPY scripts/uvlock-to-constraints.py /tmp/uvlock-to-constraints.py
RUN set -e \
&& whl="unblob-${UNBLOB_VERSION}-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" \
&& curl -fsSLo "/tmp/${whl}" "https://files.pythonhosted.org/packages/cp39/u/unblob/${whl}" \
&& echo "${UNBLOB_WHEEL_SHA256} /tmp/${whl}" | sha256sum -c - \
&& curl -fsSLo /tmp/uv.lock "https://raw.githubusercontent.com/onekey-sec/unblob/${UNBLOB_COMMIT}/uv.lock" \
&& echo "${UNBLOB_UVLOCK_SHA256} /tmp/uv.lock" | sha256sum -c - \
&& python3 -I /tmp/uvlock-to-constraints.py /tmp/uv.lock > /tmp/constraints.txt \
&& python3 -m venv /opt/unblob \
&& /opt/unblob/bin/pip install --no-cache-dir --disable-pip-version-check \
--constraint /tmp/constraints.txt "/tmp/${whl}"
# (unblob needs libmagic to start, which this stage lacks; the final stage
# runs it.)
# --- 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 \
socat \
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 scripts/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 and unblob (built above) with the extraction tools their install
# scripts (binwalk's Dockerfile, unblob's install-deps.sh) put in place.
# sasquatch (SquashFS variants) comes as a .deb from onekey-sec, at the
# release unblob 26.6.4 uses. squashfs-tools adds the stock unsquashfs/
# mksquashfs next to it (sasquatch only installs sasquatch*), since that's
# the command people and Claude reach for first.
# unblob's ext2/3/4 extraction needs the rdump options from onekey-sec's
# e2fsprogs fork, so its .debs replace Ubuntu's
# e2fsprogs (same upstream 1.47.0). These releases publish no checksums;
# the SHA-256s were taken when 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. unblob gets a
# wrapper that puts its own venv first on PATH, so it calls its own locked
# jefferson/ubireader rather than binwalk's.
ARG SASQUATCH_RELEASE=sasquatch-v4.5.1-6
ARG SASQUATCH_SHA256_AMD64=efbc92bd27594e01211e1b582f105a463a0017360a0746a93de3f283906ade95
ARG E2FSPROGS_OK_RELEASE=v1.47.0-3.ok2
ARG LIBEXT2FS2_OK_SHA256_AMD64=4eaf27e79a570ec89e15d8851f7df693b8b3ae78ccbb5b2a4a7d3eee845f1beb
ARG LIBSS2_OK_SHA256_AMD64=6dfc4612915d885607f36fcea2b851a947f9c73943494208ec48a0cbe9887427
ARG E2FSPROGS_OK_SHA256_AMD64=360adf4aa996cc80bf39002775d39900ae207b591c105d12a8ad2a833eb536f4
# 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
COPY --from=binwalk-build /opt/unblob /opt/unblob
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 - \
&& mkdir /tmp/e2fsprogs-ok && cd /tmp/e2fsprogs-ok \
&& v="${E2FSPROGS_OK_RELEASE#v}" \
&& printf '%s %s\n' \
"${LIBEXT2FS2_OK_SHA256_AMD64}" "libext2fs2_${v}_${arch}.deb" \
"${LIBSS2_OK_SHA256_AMD64}" "libss2_${v}_${arch}.deb" \
"${E2FSPROGS_OK_SHA256_AMD64}" "e2fsprogs_${v}_${arch}.deb" > SHA256SUMS \
&& for f in $(awk '{print $2}' SHA256SUMS); do \
curl -fsSLO "https://github.com/onekey-sec/e2fsprogs/releases/download/${E2FSPROGS_OK_RELEASE}/$f"; \
done \
&& sha256sum -c SHA256SUMS && cd / \
&& 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 /tmp/e2fsprogs-ok/*.deb \
7zip 7zip-standalone zstd srecord tar unzip sleuthkit cabextract \
lz4 lzop unrar unyaffs cpio device-tree-compiler \
liblzo2-2 libucl1 libfontconfig1 \
android-sdk-libsparse-utils erofs-utils lziprecover partclone unar \
upx-ucl libmagic1t64 xz-utils squashfs-tools \
&& rm -rf /var/lib/apt/lists/* /tmp/sasquatch.deb /tmp/e2fsprogs-ok \
&& 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 \
&& printf '%s\n' '#!/bin/sh' 'PATH="/opt/unblob/bin:$PATH" exec /opt/unblob/bin/unblob "$@"' \
> /usr/local/bin/unblob \
&& chmod 755 /usr/local/bin/unblob \
&& binwalk --version && esptool version && unblob --version \
&& debugfs -V 2>&1 | grep -q 1.47.0 \
&& unblob --show-external-dependencies
# unsquashfs/sasquatch exit 2 as non-root on images with security.* xattrs
# although every file was extracted; the wrapper adds -no-xattrs by default.
COPY --chmod=755 scripts/squashfs-no-xattrs.sh /usr/local/libexec/squashfs-no-xattrs
RUN for t in unsquashfs sasquatch sasquatch-v4be; do \
ln -s /usr/local/libexec/squashfs-no-xattrs "/usr/local/bin/$t"; \
done
# 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
# Node.js, from the official tarball checked against nodejs.org's
# SHASUMS256.txt. Ubuntu's nodejs (18) is too old for REA; 24.22.1 is the
# version REA's own .nvmrc names.
ARG NODE_VERSION=24.22.1
ARG NODE_SHA256_AMD64=6c172a9ab6718f7b801fb5a7bd486af2ee5e32758086a04b84fcce2aa77095e6
RUN set -e \
&& arch="$(dpkg --print-architecture)" \
&& [ "$arch" = amd64 ] || { echo "Node.js checksum only pinned for amd64" >&2; exit 1; } \
&& apt-get update \
&& apt-get install -y --no-install-recommends xz-utils procps \
&& rm -rf /var/lib/apt/lists/* \
&& curl -fsSLo /tmp/node.tar.xz \
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
&& echo "${NODE_SHA256_AMD64} /tmp/node.tar.xz" | sha256sum -c - \
&& mkdir /opt/node \
&& tar -xJf /tmp/node.tar.xz -C /opt/node --strip-components=1 --no-same-owner \
&& rm /tmp/node.tar.xz
ENV PATH=/opt/node/bin:${PATH}
# REA, "Reverse Engineer Anything" (https://github.com/morluto/rea), from the
# rea-agents npm package. The tarball is checked against the integrity hash
# the npm registry lists for it; its dependencies are locked by the
# npm-shrinkwrap.json it ships. npm blocks dependency install scripts by
# default, so frida (optional, runtime instrumentation) stays unbuilt.
# REA runs in its own container (see run.sh), not next to Claude Desktop,
# because some of its tools run the target. It finds Ghidra through
# GHIDRA_INSTALL_DIR and needs procps' ps (installed above).
ARG REA_VERSION=6.8.0
ARG REA_INTEGRITY=sha512-9QDeRnGqEDzABZyr04eLnBXBv/c6I9/TWu/6M0MD4pEfkySI/BIgwTODIbAReX3C1x6hjw9+Jlx4A2yUSPZ9ww==
RUN set -e; cd /tmp \
&& npm pack --silent "rea-agents@${REA_VERSION}" \
&& actual="sha512-$(sha512sum "rea-agents-${REA_VERSION}.tgz" | cut -d' ' -f1 \
| python3 -I -c 'import base64,sys; print(base64.b64encode(bytes.fromhex(sys.stdin.read().strip())).decode())')" \
&& [ "$actual" = "${REA_INTEGRITY}" ] || { echo "rea-agents integrity mismatch: $actual" >&2; exit 1; } \
&& npm install --global --omit=optional --no-audit --no-fund "./rea-agents-${REA_VERSION}.tgz" \
&& rm -rf "rea-agents-${REA_VERSION}.tgz" /root/.npm \
&& [ "$(rea --version)" = "${REA_VERSION}" ]
ENV REA_BINWALK_COMMAND=/usr/local/bin/binwalk \
REA_UNBLOB_COMMAND=/usr/local/bin/unblob
# stdio-to-HTTP bridge (mcp-remote) that Claude Desktop starts to reach REA's
# HTTP endpoint in the other container. Installed from a committed lockfile.
COPY claude-desktop/mcp-bridge/package.json claude-desktop/mcp-bridge/package-lock.json /opt/mcp-bridge/
RUN cd /opt/mcp-bridge \
&& npm ci --omit=dev --ignore-scripts --no-audit --no-fund \
&& rm -rf /root/.npm \
&& test -f node_modules/mcp-remote/dist/proxy.js
# 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 managed configuration directory. run.sh copies a
# managed-settings.json in (root-owned, as Claude Desktop requires) when a
# third-party model provider is chosen at start.
RUN install -d -m 755 /etc/claude-desktop
# 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
# REA's skill (workflow instructions) for Claude Code sessions in Claude
# Desktop's Code tab, which load skills from ~/.claude/skills. Copied from
# the installed rea-agents package, so it matches the REA version. Chat in
# Claude Desktop does not read this folder.
RUN install -d -o "${USER_UID}" -g "${USER_GID}" \
"/home/${USER_NAME}/.claude" "/home/${USER_NAME}/.claude/skills" \
&& cp -r /opt/node/lib/node_modules/rea-agents/skills/reverse-engineer-anything \
"/home/${USER_NAME}/.claude/skills/" \
&& chown -R "${USER_UID}:${USER_GID}" "/home/${USER_NAME}/.claude/skills/reverse-engineer-anything" \
&& test -f "/home/${USER_NAME}/.claude/skills/reverse-engineer-anything/SKILL.md"
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"]