A Japanese version of this is on Zenn.
The macOS app I build as a solo developer ships with a read-only MCP server. When an AI client like Claude or Cursor asks "is the Wi-Fi I'm on right now safe?", it's the process that looks at the local ARP table, checks exposed ports and suspicious URLs, and returns the result.
The other day I pulled that MCP server and the detection logic around it into a separate repo and released it as MIT-licensed open source. However much prose I write saying "it's read-only" and "it sends nothing externally," I figured people could just read the code faster.
Right after hitting publish I was pleased with myself — there, now it's transparent. Then the next morning I broke into a cold sweat:
"Anyone in the world can read every line of this now. If there's a dumb bug in it, won't it get hit instantly?"
Once that thought started I couldn't stop, so I decided to fuzz it myself — throw random malformed input at it and see if it crashes — and beat on it thoroughly before publication.
What actually goes wrong here?
The MCP server reads JSON-RPC from stdin, one line at a time, and processes it. The client app itself may be trustworthy, but the LLM is what assembles the tool arguments (url for audit_url_safety, query for get_app_help).
If the LLM gets manipulated — prompt injection, say — that input is effectively "a string an external attacker controls directly."
Fortunately it's written in Swift, so nothing here is going to escalate from memory corruption to RCE. The realistic risk is DoS: a crash (trap) from a force-unwrap (!) or an integer overflow, or a hang from an infinite loop or regex backtracking (ReDoS).
Worst case is one local subprocess dies and the connection to the AI client drops. But shipping — and keeping published — a server that dies instantly when you send it one weird string is just embarrassing as an engineer. I wanted to close the holes ahead of time.
Trying to use libFuzzer and getting turned away at the door
Swift can build in LLVM's libFuzzer by passing -sanitize=fuzzer to the compiler — the standard setup that mutates input intelligently using coverage feedback.
I ran a build to point it at the JSON-RPC parser:
swiftc -O -sanitize=fuzzer -parse-as-library Sources/*.swift Fuzz/fuzz_jsonrpc.swift -o fuzz
What came back:
error: unsupported option '-sanitize=fuzzer' for target 'arm64-apple-macosx26.0'
I'd completely forgotten. With the stock Swift toolchain bundled in Xcode, -sanitize=fuzzer isn't supported on macOS arm64 (Apple Silicon). You can get it working by pulling a separate development toolchain from swift.org — but then anyone who wants to git clone and reproduce the tests themselves has to install that toolchain too. I wanted to avoid that.
Scrappy mutation fuzzing with just XCTest
I gave up on proper coverage-guided fuzzing and switched to the brute-force version: mutate seed data randomly and hammer it inside swift test. It won't reach deep edge cases, but for shaking out shallow traps and hangs it's plenty useful — and above all it runs as-is on anyone's machine with just swift test.
Doing this needed one refactor first. The original main.swift had parsing, logic, and writing to stdout tightly coupled:
func handleMessage(_ message: [String: Any]) {
// ...
sendResult(id: id, [...]) // <- writes straight to FileHandle.standardOutput
}
while let line = readLine(strippingNewline: true) {
// parse and call handleMessage
}
You couldn't test it without spawning a process, so I split off the I/O and extracted a pure function:
enum MCPServer {
/// Takes one raw line of bytes, returns the JSON-RPC response lines to emit (0 or more). Pure.
static func handleLine(_ data: Data) -> [Data] { ... }
}
Now main.swift is just a pump connecting stdin/stdout to MCPServer.handleLine, and the tests can hit it directly, in memory.
On top of that:
func testMutationFuzz_handleLine_neverTrapsOrHangs() {
let iterations = 20_000
var rng = SystemRandomNumberGenerator()
for i in 0..<iterations {
// bit flips, insertions, deletions, chunk duplication, etc.
let input = mutate(seeds.randomElement(using: &rng)!, &rng)
let done = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
// a crash takes the whole test process down with it
_ = MCPServer.handleLine(Data(input))
done.signal()
}
// no return within 3 seconds = hang
if done.wait(timeout: .now() + 3) == .timedOut {
XCTFail("iteration \(i) stopped responding (hang): \(input.hexString)")
}
}
}
It died instantly, in a place I did not expect
Before running the random mutations, I ran a regression test that feeds typical attack payloads (nesting bombs, etc.) directly:
func testDeeplyNestedObject_boundedTime() {
let depth = 50_000
let bomb = String(repeating: "{\"a\":", count: depth) + "1" + String(repeating: "}", count: depth)
// tens of thousands of levels deep. the parser should reject this cleanly
expectHandled(Data(bomb.utf8), within: 3)
}
The moment I ran that under swift test, the test runner blew up:
exited with unexpected signal code 10
SIGBUS — a stack overflow.
I dug into where it died and was surprised: not my code, but Apple's own JSONSerialization.jsonObject(with:).
Feed it JSON where objects are nested too deep — {"a":{"a":{"a":... — and Foundation's internal parser exhausts the stack through recursive calls and crashes the whole process. In code I had it wrapped in try?:
guard let parsed = try? JSONSerialization.jsonObject(with: data) else { return [] }
But a crash from stack exhaustion isn't a Swift error, so try? doesn't catch it. The process just dies, no questions asked.
The interesting part I found while digging: deep arrays [[[[... don't crash. The array side has some kind of internal depth guard and gets rejected safely as an error — only object nesting ({) eats the stack with no guard.
So a malicious client (or an LLM emitting broken output) could reliably kill this MCP server remotely by sending one line of deeply-nested JSON.
The fix
JSONSerialization has no option to set a maximum parse depth. So the approach was to validate the depth myself before handing anything to the standard parser.
Count { and [ depth in a single pass, accounting for escapes inside string literals, and reject immediately once it exceeds a threshold:
static func jsonNestingWithinLimit(_ data: Data, max: Int) -> Bool {
var depth = 0, inString = false, escaped = false
for b in data {
if inString {
if escaped { escaped = false }
else if b == 0x5C { escaped = true } // \
else if b == 0x22 { inString = false } // "
continue
}
switch b {
case 0x22: inString = true
case 0x7B, 0x5B: depth += 1; if depth > max { return false } // { [
case 0x7D, 0x5D: if depth > 0 { depth -= 1 } // } ]
default: break
}
}
return true
}
It's O(N) with no allocation, so a nesting bomb gets rejected instantly. Normal MCP traffic is never nested dozens of levels deep, so I set the limit at a generous 128. I also added a max-bytes-per-line cap:
static func handleLine(_ data: Data) -> [Data] {
guard !data.isEmpty, data.count <= maxLineBytes,
jsonNestingWithinLimit(data, max: maxNestingDepth),
let parsed = try? JSONSerialization.jsonObject(with: data) else {
return []
}
// ...
}
How did my own scrappy command parsers hold up?
The other thing I was worried about was the parts that parse the output of OS commands like lsof and arp. Even a process with normal user privileges can nudge that output format around a bit — listen under a weird process name, bind to port 0, that kind of thing.
I threw a lot at these too: strings with columns wildly misaligned, PIDs above Int's max, non-ASCII characters, huge dummy output.
Result: not a single crash here.
guard parts.count >= 9 else { continue }
guard let pid = Int(parts[1]) else { continue }
if let port = Int(portStr) { ... }
Because every spot used guard let / if let to fail safe from the start, malformed lines just get skipped. And because the 0.0.0.0 check is an exact match, a broken string never gets misread as an "externally exposed port" either.
I braced for my own scrappy text-wrangling to be the problem, and got tripped up by the standard library I trusted most.
What open-sourcing it taught me
The worry that "publishing the code just gets it probed for vulnerabilities" — I genuinely had that before doing it.
But what I actually felt afterward: because I was now operating on the assumption that people would read it, I went in to really break it, and fixed bugs. If I'd kept it private and only run it on my own machine, I'd have left that JSONSerialization stack overflow sitting there, unnoticed, forever.
The fuzzing and regression tests I added all run as-is via swift test in the repo.
I'm not going to grandly claim it's "security audited," but I think I've gotten it to a state where I can show my hand on exactly how far I tested it and what assumptions the safety rests on.
If you have an app that handles JSON or the output of external commands, it might be worth feeding some extreme nesting or broken input into a test once — you may get tripped up by a standard framework in an unexpected place, which is at least interesting.
Top comments (0)