diff --git a/deploy/install.sh b/deploy/install.sh index 99816f7..b840636 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -5,9 +5,9 @@ set -eu HOST="${1:?usage: install.sh }" NODE_VERSION=v22.21.0 -GHIDRA_VERSION=11.2.1 -GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip -GHIDRA_SHA256=ce4db5117da0fbaf8f33863fec4f40902f754f06b68945a59fb1c0f9b1bc461c +GHIDRA_VERSION=12.2 +GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip +GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 SRC=$(cd "$(dirname "$0")/.." && pwd) APP=/opt/togetherdecomp DATA=/var/lib/togetherdecomp @@ -31,7 +31,7 @@ fi echo "== ghidra $GHIDRA_VERSION (server-side project and pseudocode)" # The JDK, not just the runtime: Ghidra compiles its .java scripts. -apt-get install -y -q openjdk-21-jdk-headless unzip >/dev/null +apt-get install -y -q openjdk-25-jdk-headless unzip >/dev/null GHIDRA_HOME="/opt/ghidra/ghidra_${GHIDRA_VERSION}_PUBLIC" if [ ! -x "$GHIDRA_HOME/support/analyzeHeadless" ]; then tmp=$(mktemp -d) diff --git a/deploy/rodecomp/Dockerfile b/deploy/rodecomp/Dockerfile index 28a7b92..e7b179b 100644 --- a/deploy/rodecomp/Dockerfile +++ b/deploy/rodecomp/Dockerfile @@ -1,7 +1,18 @@ FROM docker:29-cli AS docker-cli +FROM eclipse-temurin:25-jdk AS jdk +FROM node:24-bookworm-slim AS ghidra +ARG GHIDRA_URL=https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip +ARG GHIDRA_SHA256=2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67 +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates unzip && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL "$GHIDRA_URL" -o /tmp/ghidra.zip && echo "$GHIDRA_SHA256 /tmp/ghidra.zip" | sha256sum -c - && mkdir -p /opt/ghidra && unzip -q /tmp/ghidra.zip -d /opt/ghidra && rm /tmp/ghidra.zip + FROM node:24-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends git binutils ca-certificates && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends git binutils ca-certificates fontconfig && rm -rf /var/lib/apt/lists/* COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=jdk /opt/java/openjdk /opt/java/openjdk +COPY --from=ghidra /opt/ghidra /opt/ghidra +ENV JAVA_HOME=/opt/java/openjdk +ENV PATH=/opt/java/openjdk/bin:$PATH WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev --ignore-scripts diff --git a/src/client/cli.js b/src/client/cli.js index 6b59990..0e78a66 100644 --- a/src/client/cli.js +++ b/src/client/cli.js @@ -296,7 +296,7 @@ connect your AI with: td connect --workspace "${ws.dir}"`); } case 'config': { // td config show settings (token hidden) - // td config set one, e.g. `td config ghidraHome D:\ghidra_11.2.1_PUBLIC` + // td config set one, e.g. `td config ghidraHome D:\ghidra_12.2_PUBLIC` const [key, value] = args; const SETTABLE = ['ghidraHome', 'server']; if (!key) return print({ ...ws.config, token: ws.config.token ? '(set)' : null }); @@ -311,7 +311,7 @@ connect your AI with: td connect --workspace "${ws.dir}"`); if (args[0] === 'sync') return void (await ghidra.syncGhidraProject(ws), print('symbols applied')); if (args[0] === 'download') { const r = await ghidra.downloadGhidraProject(ws, { force: values.force }); - return print(r.updated ? `Ghidra project of ${r.info.version} downloaded${ws.config.ghidraHome ? '' : ' (open it with Ghidra 11.2.1, or run td ghidra install)'}` : 'the Ghidra project is up to date'); + return print(r.updated ? `Ghidra project of ${r.info.version} downloaded${ws.config.ghidraHome ? '' : ` (open it with Ghidra ${r.info.ghidra}, or run td ghidra install)`}` : 'the Ghidra project is up to date'); } throw new Error('usage: td ghidra install|download|build|sync'); case 'decompile': diff --git a/src/client/ghidra.js b/src/client/ghidra.js index 3660775..373b268 100644 --- a/src/client/ghidra.js +++ b/src/client/ghidra.js @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'; import { run } from '../shared/build.js'; import { hex } from '../shared/annotations.js'; import { loadToolchainLock } from '../shared/project.js'; +import { GHIDRA_RELEASE, ghidraAtLeast, ghidraVersionAt } from '../shared/ghidra.js'; import { Api } from './api.js'; const SCRIPT_NAMES = ['ApplySymbols.java', 'DecompileAt.java', 'DisassembleAt.java', 'ExportFunctions.java', 'ExportOriginals.java']; @@ -55,26 +56,46 @@ async function runHeadless(ws, args, timeoutMs) { return r.output; } -/** Downloads and unpacks the Ghidra release pinned in data/toolchain.lock.json. */ -export async function installGhidra(ws) { - const lock = loadToolchainLock(ws.dir).ghidra; - if (!lock?.url || !lock?.sha256) throw new Error('data/toolchain.lock.json has no ghidra { url, sha256, dir } entry'); +/** + * Downloads and unpacks a Ghidra release: the one the server builds its project with (so the + * downloaded project opens), else the one pinned in data/toolchain.lock.json, else td's own pin. + */ +export async function installGhidra(ws, release = null) { + const lock = release?.url ? release : loadToolchainLock(ws.dir).ghidra?.url ? loadToolchainLock(ws.dir).ghidra : GHIDRA_RELEASE; + if (!lock?.url || !lock?.sha256) throw new Error('no Ghidra release to install (data/toolchain.lock.json has no ghidra { url, sha256, dir } entry)'); const toolsDir = path.join(ws.tdDir, 'tools'); fs.mkdirSync(toolsDir, { recursive: true }); const zip = path.join(toolsDir, path.basename(new URL(lock.url).pathname)); if (!fs.existsSync(zip)) { - console.log(`downloading ${lock.url}`); + console.error(`downloading ${lock.url}`); await new Api('').download(lock.url, zip, lock.sha256); } // Windows' own tar.exe (bsdtar) unpacks zip files; a GNU tar earlier on PATH (Git Bash) would not. const winTar = path.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'tar.exe'); const r = os.platform() === 'win32' ? await run(winTar, ['-xf', zip, '-C', toolsDir], { timeoutMs: 600_000 }) : await run('unzip', ['-q', '-o', zip, '-d', toolsDir], { timeoutMs: 600_000 }); if (r.code !== 0) throw new Error(`could not unpack Ghidra: ${r.output}`); + fs.rmSync(zip, { force: true }); ws.config.ghidraHome = path.join(toolsDir, lock.dir); ws.save(); return ws.config.ghidraHome; } +/** + * A local Ghidra older than the one the server's project was saved with cannot open it. Upgrades + * the Ghidra td installed itself; a Ghidra the user configured is reported, not replaced. + */ +export async function ensureGhidraFor(ws, release) { + if (!release?.version || !ws.config.ghidraHome) return null; + const local = ghidraVersionAt(ws.config.ghidraHome); + if (local && ghidraAtLeast(local, release.version)) return null; + const managed = path.resolve(ws.config.ghidraHome).startsWith(path.resolve(path.join(ws.tdDir, 'tools'))); + if (!managed || !release.url) { + throw new Error(`the server's Ghidra project needs Ghidra ${release.version}${release.jdk ? ` (JDK ${release.jdk})` : ''}, but ${ws.config.ghidraHome} is ${local ?? 'unknown'}: install it and run \`td config ghidraHome \`, or run \`td ghidra install\``); + } + console.error(`upgrading Ghidra ${local ?? '?'} -> ${release.version} (the server's project needs it)`); + return installGhidra(ws, release); +} + function writeSymbolFile(ws) { const file = path.join(ws.tdDir, 'ghidra-symbols.tsv'); const lines = []; @@ -127,6 +148,7 @@ export function disassemble(ws, addr) { export async function downloadGhidraProject(ws, { force = false } = {}) { const client = new Api(ws.config.server, ws.config.token); const info = await client.get('/api/ghidra-project'); + await ensureGhidraFor(ws, info.release ?? (info.ghidra ? { version: info.ghidra } : null)); if (!force && ws.config.ghidraPackage === info.sha256 && fs.existsSync(path.join(ws.tdDir, 'ghidra', `${PROJECT_NAME}.gpr`))) { return { updated: false, info }; } @@ -140,7 +162,22 @@ export async function downloadGhidraProject(ws, { force = false } = {}) { const r = await run(tar, ['-xzf', tgz, '-C', projDir], { timeoutMs: 1800_000 }); if (r.code !== 0) throw new Error(`could not unpack the Ghidra project: ${r.output}`); fs.rmSync(tgz, { force: true }); + takeProjectOwnership(projDir); ws.config.ghidraPackage = info.sha256; ws.save(); return { updated: true, info }; } + +/** + * Ghidra opens a project only for the user recorded as its owner (its user.name, spaces removed). + * The server packs its project under its own account, so the unpacked copy is handed to whoever + * runs td. + */ +function takeProjectOwnership(projDir) { + const prp = path.join(projDir, `${PROJECT_NAME}.rep`, 'project.prp'); + if (!fs.existsSync(prp)) return; + const user = os.userInfo().username.replace(/\s/g, '').replace(/[<>&"]/g, ''); + const text = fs.readFileSync(prp, 'utf8'); + const owned = text.replace(/()/, (_, a, b) => a + user + b); + if (owned !== text) fs.writeFileSync(prp, owned); +} diff --git a/src/client/ops.js b/src/client/ops.js index aaae108..4c140a1 100644 --- a/src/client/ops.js +++ b/src/client/ops.js @@ -240,10 +240,13 @@ async function remoteCheck(ws, targets, { wait = true, explicit = false } = {}) /** * The original function as text. A local Ghidra (td ghidra install/build) answers locally; - * otherwise the server's Ghidra export does. + * otherwise, or when it is older than the release td pins (it cannot open the server's project), + * the server's Ghidra export does. */ export async function original(ws, query, kind) { - if (ws.config.ghidraHome) { + const { ghidraVersionAt, ghidraAtLeast, GHIDRA_RELEASE } = await import('../shared/ghidra.js'); + const local = ws.config.ghidraHome ? ghidraVersionAt(ws.config.ghidraHome) : null; + if (local && ghidraAtLeast(local, GHIDRA_RELEASE.version)) { const { decompile, disassemble } = await import('./ghidra.js'); const s = ws.findSymbol(query); const addr = s?.addr ?? (/^0x[0-9a-f]+$/i.test(query) ? Number.parseInt(query, 16) : null); diff --git a/src/server/app.js b/src/server/app.js index 75b25e8..c801a8a 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -31,6 +31,7 @@ import { PEImage } from '../shared/pe.js'; import { loadProject, loadToolchainLock } from '../shared/project.js'; import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js'; import { hex } from '../shared/annotations.js'; +import { GHIDRA_RELEASE } from '../shared/ghidra.js'; // TD_WEB_DIR points through the release symlink in production, so new pages are live without a restart. const WEB_DIR = process.env.TD_WEB_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); @@ -1011,7 +1012,8 @@ export function createApp(ctx) { auth(req); const info = ctx.ghidra.info(); if (!info) throw new HttpError(404, 'no Ghidra project has been built on this server yet'); - return { ...info, download: `${config.publicUrl}/api/ghidra-project/download` }; + const ghidra = GHIDRA_RELEASE.version === info.ghidra ? GHIDRA_RELEASE : { version: info.ghidra }; + return { ...info, release: ghidra, download: `${config.publicUrl}/api/ghidra-project/download` }; }); router.on('GET', '/api/ghidra-project/download', ({ req, res }) => { diff --git a/src/server/cli.js b/src/server/cli.js index 4e9ef51..2f340d2 100644 --- a/src/server/cli.js +++ b/src/server/cli.js @@ -13,6 +13,7 @@ import { Accounts } from './accounts.js'; import { agentGuidance, setAgentGuidance, resetAgentGuidance } from './guidance.js'; import { productivity, formatProductivity } from './stats.js'; import { PEImage } from '../shared/pe.js'; +import { GHIDRA_RELEASE } from '../shared/ghidra.js'; const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'template'); @@ -323,9 +324,9 @@ async function main() { } if (cmd === 'ghidra' && (sub === 'build' || sub === 'refresh')) { - // The server's Ghidra project (needs ghidraHome in server.json and a JDK 21). + // The server's Ghidra project (needs ghidraHome in server.json and the JDK that Ghidra needs). const ctx = openServer(dataDir); - if (!ctx.ghidra.enabled) throw new Error('set ghidraHome in server.json to a Ghidra 11.2.1 installation'); + if (!ctx.ghidra.enabled) throw new Error(`set ghidraHome in server.json to a Ghidra ${GHIDRA_RELEASE.version} installation`); ctx.store.setMeta('ghidra.commit', ctx.repo.head()); const info = sub === 'build' ? await ctx.ghidra.build() : await ctx.ghidra.refresh(); console.log(JSON.stringify(info, null, 2)); diff --git a/src/server/ghidra.js b/src/server/ghidra.js index 662962d..329f93f 100644 --- a/src/server/ghidra.js +++ b/src/server/ghidra.js @@ -12,6 +12,7 @@ import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { run } from '../shared/build.js'; import { hex } from '../shared/annotations.js'; +import { GHIDRA_RELEASE, ghidraVersionAt } from '../shared/ghidra.js'; const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'ghidra_scripts'); const PROJECT = 'td'; @@ -47,9 +48,8 @@ export class ServerGhidra { async headless(args, timeoutMs) { fs.mkdirSync(this.projectDir, { recursive: true }); const exe = path.join(this.config.ghidraHome, 'support', 'analyzeHeadless'); - // Ghidra 11.2 is made for JDK 21; the system default may be newer. - const jdk21 = '/usr/lib/jvm/java-21-openjdk-amd64'; - const javaHome = this.config.ghidraJavaHome ?? (fs.existsSync(jdk21) ? jdk21 : process.env.JAVA_HOME); + // Ghidra 12.2 needs JDK 25; `ghidraJavaHome` overrides a different default JDK. + const javaHome = this.config.ghidraJavaHome ?? process.env.JAVA_HOME; const env = { ...process.env, MAXMEM: this.config.ghidraMaxMem ?? '6G', ...(javaHome ? { JAVA_HOME: javaHome, PATH: `${javaHome}/bin:${process.env.PATH}` } : {}) }; const r = await run(exe, [this.projectDir, PROJECT, ...args, '-scriptPath', SCRIPTS_DIR], { timeoutMs, env }); if (r.code !== 0) throw new Error(`Ghidra failed (exit ${r.code}): ${r.output.slice(-2000)}`); @@ -101,7 +101,8 @@ export class ServerGhidra { const old = this.info(); fs.renameSync(tmp, path.join(this.dir, name)); const sha256 = await sha256File(path.join(this.dir, name)); - const info = { version: v.id, file: name, sha256, size: fs.statSync(path.join(this.dir, name)).size, builtAt: Date.now(), commit: head, functions: n, ghidra: this.config.ghidraVersion ?? '11.2.1' }; + const ghidra = ghidraVersionAt(this.config.ghidraHome) ?? this.config.ghidraVersion ?? GHIDRA_RELEASE.version; + const info = { version: v.id, file: name, sha256, size: fs.statSync(path.join(this.dir, name)).size, builtAt: Date.now(), commit: head, functions: n, ghidra }; fs.writeFileSync(path.join(this.dir, 'package.json'), JSON.stringify(info, null, 2)); if (old?.file && old.file !== name) fs.rmSync(path.join(this.dir, old.file), { force: true }); this.store.setMeta('ghidra.refreshedAt', Date.now()); diff --git a/src/shared/ghidra.js b/src/shared/ghidra.js new file mode 100644 index 0000000..fd5cfc7 --- /dev/null +++ b/src/shared/ghidra.js @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// The Ghidra release the server builds its project with. A project saved by a newer Ghidra cannot +// be opened by an older one, so clients that download it need the same release. +export const GHIDRA_RELEASE = { + version: '12.2', + url: 'https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip', + sha256: '2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67', + dir: 'ghidra_12.2_PUBLIC', + jdk: '25', +}; + +/** The application.version of the Ghidra installed at `home`, or null. */ +export function ghidraVersionAt(home) { + if (!home) return null; + try { + const props = fs.readFileSync(path.join(home, 'Ghidra', 'application.properties'), 'utf8'); + return props.match(/^application\.version=(.+)$/m)?.[1].trim() ?? null; + } catch { + return null; + } +} + +/** True when Ghidra version `a` is at least `b` (dotted numeric versions). */ +export function ghidraAtLeast(a, b) { + const key = (v) => String(v ?? '0').split('.').map((x) => Number.parseInt(x, 10) || 0).concat([0, 0, 0]).slice(0, 3).reduce((n, x) => n * 1000 + x, 0); + return key(a) >= key(b); +} diff --git a/template/data/toolchain.lock.json b/template/data/toolchain.lock.json index 4370bca..6856ee8 100644 --- a/template/data/toolchain.lock.json +++ b/template/data/toolchain.lock.json @@ -5,10 +5,10 @@ "clSha256": null }, "ghidra": { - "version": "11.2.1", - "url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.2.1_build/ghidra_11.2.1_PUBLIC_20241105.zip", - "sha256": "ce4db5117da0fbaf8f33863fec4f40902f754f06b68945a59fb1c0f9b1bc461c", - "dir": "ghidra_11.2.1_PUBLIC", - "jdk": "21" + "version": "12.2", + "url": "https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.2_build/ghidra_12.2_PUBLIC_20261016.zip", + "sha256": "2c2cd776db3c1f6a097ba418922378ac3649cadd5e844a6f5f088ae628a7be67", + "dir": "ghidra_12.2_PUBLIC", + "jdk": "25" } }