Files
rea/src/apktool/ApktoolConfiguration.ts
T
ae95e68ba5 fix(apktool): run Windows batch launchers through cmd.exe (#1777)
On Windows, Node refuses to spawn .bat and .cmd files without a shell
(CVE-2024-27980), so REA_APKTOOL_COMMAND pointing at Chocolatey's
apktool.bat or a java -jar wrapper failed its version probe with EINVAL,
reported as "did not report a usable version".

Move Ghidra's conservative cmd.exe invocation (no AutoRun, no delayed
expansion, every token quoted, interpreter metacharacters rejected) into
src/process/WindowsBatchCommand.ts. Ghidra keeps its error type, wording
and length limit. apktool now uses it for both the version probe and the
decode run when the launcher is a batch file on Windows. A launcher that
cmd.exe would reinterpret reports launcher_unsupported (not_configured)
with remediation instead of version_unresolved.

Fixes #1774

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:48 +08:00

211 lines
6.9 KiB
TypeScript

import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { isAbsolute } from "node:path";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { JsonValue } from "../domain/jsonValue.js";
import {
isWindowsBatchFile,
windowsBatchCommand,
WindowsBatchCommandError,
} from "../process/WindowsBatchCommand.js";
/** Environment key selecting the caller's apktool launcher. */
export const APKTOOL_COMMAND_ENV = "REA_APKTOOL_COMMAND";
const VERSION_TIMEOUT_MS = 20_000;
const VERSION_MAX_OUTPUT_BYTES = 64 * 1024;
/** How the apktool launcher was selected for this provider instance. */
export interface ApktoolCommandSelection {
readonly command: string;
readonly commandSource: "environment" | "path";
}
/**
* Resolve the caller-supplied apktool launcher. `REA_APKTOOL_COMMAND` must
* be absolute so the selection stays explicit; without it, apktool is
* resolved from `PATH` at run time. Both the upstream wrapper script and
* `java -jar` launcher scripts work: REA only runs `<command> --version`
* and `<command> d ...`.
*/
export const resolveApktoolCommand = (
environment: Readonly<Record<string, string | undefined>>,
): ApktoolCommandSelection => {
const configured = environment[APKTOOL_COMMAND_ENV];
if (configured === undefined || configured.trim() === "")
return { command: "apktool", commandSource: "path" };
if (!isAbsolute(configured))
throw new ApktoolConfigurationFailure(
"not_absolute",
`${APKTOOL_COMMAND_ENV} must be an absolute path to the apktool launcher`,
{ configured_command: configured },
);
return { command: configured, commandSource: "environment" };
};
export type ApktoolConfigurationFailureCode =
| "not_absolute"
| "command_missing"
| "launcher_unsupported"
| "version_unresolved";
export class ApktoolConfigurationFailure extends Error {
constructor(
readonly code: ApktoolConfigurationFailureCode,
message: string,
readonly diagnostics: Readonly<Record<string, JsonValue>> = {},
options?: ErrorOptions,
) {
super(message, options);
}
}
export interface ApktoolClientIdentity {
readonly version: string | null;
}
/** Process creation for one apktool run on the selected host. */
export interface ApktoolInvocation {
readonly command: string;
readonly arguments: readonly string[];
readonly windowsVerbatimArguments: boolean;
}
/**
* Run the selected launcher directly, or through cmd.exe when Windows can
* only execute it there (the Chocolatey `apktool.bat` and `java -jar`
* wrapper scripts). Throws WindowsBatchCommandError when a token cannot be
* passed to cmd.exe safely.
*/
export const apktoolInvocation = (
selection: ApktoolCommandSelection,
arguments_: readonly string[],
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): ApktoolInvocation =>
platform === "win32" && isWindowsBatchFile(selection.command)
? {
...windowsBatchCommand({
environment,
batchPath: selection.command,
arguments: arguments_,
}),
windowsVerbatimArguments: true,
}
: {
command: selection.command,
arguments: arguments_,
windowsVerbatimArguments: false,
};
/** Read the version from `apktool --version` output (first non-empty line). */
export const parseApktoolVersionOutput = (
stdout: string,
): ApktoolClientIdentity => {
const line = stdout
.split(/\r?\n/u)
.map((entry) => entry.trim())
.find((entry) => entry !== "");
return { version: line === undefined ? null : line };
};
/** Probe the selected apktool launcher without decoding anything. */
export const inspectApktoolClient = async (
selection: ApktoolCommandSelection,
signal?: AbortSignal,
environment: Readonly<Record<string, string | undefined>> = process.env,
platform: NodeJS.Platform = process.platform,
): Promise<ApktoolClientIdentity> => {
const hostEnvironment = { ...process.env, ...environment };
let invocation: ApktoolInvocation;
try {
invocation = apktoolInvocation(
selection,
["--version"],
hostEnvironment,
platform,
);
} catch (cause) {
if (!(cause instanceof WindowsBatchCommandError)) throw cause;
throw new ApktoolConfigurationFailure(
"launcher_unsupported",
`The selected apktool launcher cannot be run through cmd.exe: ${cause.message}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to a launcher path without cmd.exe metacharacters (" % ^ ! & | < >), or to a native apktool executable`,
},
{ cause },
);
}
try {
const { stdout } = await execFileOutput(
invocation.command,
invocation.arguments,
{
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
timeout: VERSION_TIMEOUT_MS,
stopSignal: "SIGTERM",
maxBuffer: VERSION_MAX_OUTPUT_BYTES,
env: hostEnvironment,
...(signal === undefined ? {} : { signal }),
},
);
return parseApktoolVersionOutput(stdout);
} catch (cause) {
const failure = execFileOutputFailure(cause);
if (signal?.aborted || failure?.code === "ABORT_ERR")
throw new AnalysisCancelledError("inspect_apktool_client", { cause });
if (failure?.code === "ENOENT")
throw new ApktoolConfigurationFailure(
"command_missing",
`The selected apktool launcher was not found: ${selection.command}`,
{
selected_command: selection.command,
command_source: selection.commandSource,
remediation: `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script)`,
},
{ cause },
);
throw new ApktoolConfigurationFailure(
"version_unresolved",
"The selected apktool launcher did not report a usable version",
{
selected_command: selection.command,
...(failure === undefined
? {}
: {
exit_code: failure.code,
stderr_excerpt: failure.stderr.slice(0, 512),
}),
},
{ cause },
);
}
};
/** Availability in the shape shared by every provider. */
const rejectionCodeOf = (
code: ApktoolConfigurationFailureCode,
): ProviderRejectionCode =>
code === "command_missing"
? "executable_missing"
: code === "not_absolute" || code === "launcher_unsupported"
? "not_configured"
: "version_unresolved";
export const apktoolConfigurationAvailability = (
cause: ApktoolConfigurationFailure,
): ProviderAvailability =>
({
status: "unavailable",
code: rejectionCodeOf(cause.code),
reason: cause.message,
diagnostics: { ...cause.diagnostics },
}) as ProviderAvailability;