CCleaner 1.18.30 for macOS exposes a useful boundary to audit: a local client supplies an executable path, and a root helper launches it. The problem is neither JSON nor Unix sockets. It is the expansion of connection access into a general-purpose privileged execution capability.
Installation approval, socket reachability, request dispatch, and child-process identity need separate evidence. The scope here is the examined 1.18.30 implementation and its recorded results. Offline models check bytes and argument handling; they are not a new macOS privilege-escalation run. Other affected versions and a specific fixed release have not been established.
Installation approval is not request authorization
The helper identifier in SMPrivilegedExecutables is com.piriform.ccleaner.CCleanerAgent. After an administrator approves installation, the service must still decide which subsequent clients may invoke each operation. A password entered during setup does not authenticate every future message.

Interface illustration: installation approval, not a capture from a new test.
Full Disk Access and root identity are separate mechanisms. The former concerns TCC-protected data categories; the latter concerns Unix credentials. Neither is a universal switch for other system protections. An OS version string displayed by an application is also insufficient to identify an exact system build.
The connection crosses into a privileged endpoint
/Library/LaunchDaemons/com.piriform.ccleaner.CCleanerAgent.plist points Program at /Library/PrivilegedHelperTools/com.piriform.ccleaner.CCleanerAgent. The table extracts the relevant plist keys; it is not a complete installable job definition.
| Key or observation | Value | Meaning |
|---|---|---|
| SockFamily | Unix | Filesystem-named local IPC |
| SockType | Stream | Byte stream without application framing |
| SockPathMode | 438 | Octal 0666 |
| SockPathName | /var/run/com.piriform.ccleaner.CCleanerAgent.socket | Client connection endpoint |
| Helper identity | root | Identity in the historical runtime record |
flowchart LR accTitle: Local request crosses into a privileged process accDescr: A local process sends framed JSON over a Unix socket to CCleanerAgent, which dispatches action 8 and launches a child. A["Local process"] --> B["Unix Stream socket"] B --> C["CCleanerAgent · root"] C --> D["AgentAction = 8"] D --> E["NSTask child"]
Decimal 438 equals octal 0666. That mode does not restrict socket permissions to a particular ordinary-user group, but actual access also depends on the path, ACLs, and runtime state. A connected client may still face peer-credential checks and operation-specific authorization. The mode is evidence about the entry point, not a complete escalation finding. See Apple's launchd.plist manual for the fields.
Each framing marker is eleven bytes
CCleanerAgent::sendMessageToRunningAgent: appends a fixed prefix, JSON produced by NSJSONSerialization, and a fixed suffix, then writes the buffer through remoteFH. The markers are --613493r-- and --r394316--; both retain two hyphens at each end.
PREFIX0x00–0x0ASUFFIX0x0B–0x15
This byte view places the two constants together for comparison. It is not a complete message, and offset 11 is not the suffix offset in a real frame. The wire layout is PREFIX || JSON || SUFFIX; the JSON's UTF-8 byte length determines where the suffix starts.
import json
PREFIX = b'--613493r--'
SUFFIX = b'--r394316--'
message = {
'AgentAction': 8,
'Command': '/usr/bin/id',
'CommandArgumentsString': '',
}
wire = PREFIX + json.dumps(message, separators=(',', ':')).encode() + SUFFIX
assert len(PREFIX) == len(SUFFIX) == 11
assert json.loads(wire[len(PREFIX):-len(SUFFIX)]) == message
This compact model message contains 91 bytes: two 11-byte markers and 69 JSON bytes. Offline checks covered all 91 proper-prefix truncations, four string round trips, and the mode conversion. These checks validate the model, not the service's handling of the same cases.
A stream socket can return a partial frame or several frames in one read. Markers inside JSON strings, size limits, and state across reads require separate checks against the actual parser. A single-frame round trip neither establishes nor rules out another parsing defect.
Action eight passes a path to NSTask
SocketListener::parseMessage: decodes JSON, reads AgentAction as an integer, and dispatches it. Action 8 obtains Command and CommandArgumentsString, splits the latter on literal spaces, and enters runTask:arguments:.
[NSTask launchedTaskWithLaunchPath:command arguments:arguments];The helper then waits for the child to exit. The decisive capability is control over the launch path. NSTask takes a path and argv; it does not itself interpret those arguments as a shell command. The historical demonstration explicitly selected /bin/sh, so shell semantics came from the chosen executable, not from a semicolon gaining special meaning inside NSTask. See Apple's API documentation.
componentsSeparatedByString:@" " is not a shell tokenizer. The Python literal-separator model below illustrates the same splitting rule: adjacent and boundary spaces leave empty fields, while quote characters remain ordinary data. Foundation documents this behavior in NSString's separator API.
'' => ['']
'a b' => ['a', '', 'b']
' a ' => ['', 'a', '']
'"a b"' => ['"a', 'b"']Record the final argv rather than treating the displayed argument string as the invocation. Spaces in paths, quotes, and escape characters can all make the resulting array differ from what a reader expects.
What the root output establishes
$ id
uid=0(root) gid=0(wheel) ...This is an excerpt from the historical terminal's id output, with the remaining groups and unrelated host details omitted. It belongs to a run in which an ordinary client submitted the request, the helper launched a child, and that child reported uid=0. It is not output from the offline model.
sequenceDiagram accTitle: From a request to the observed identity accDescr: The helper parses a request, launches a child, and waits for it. The historical child output reports uid zero. participant C as Local client participant H as Root helper participant P as Child process C->>H: Framed JSON, AgentAction 8 H->>H: Read Command and split arguments H->>P: NSTask launch Note over P: Historical id output: uid=0 P-->>H: Exit H->>H: Wait completes
The demonstration used a two-stage script and a local connection to retrieve output. That interaction wrapper is not required for the defect: the boundary is crossed when the helper accepts a launch path outside a constrained business operation. Fix verification should focus on why the request was accepted, rather than recreating the entire interactive wrapper.
installCCleanerLibFromPath: and installCCleanerToolFromPath: merit review as well, but the available evidence does not close either path. Their names and installation role justify checking paths, ownership, and signatures; they do not establish arbitrary file replacement.
Constrain the delegated operation
- 1
Establish actual identities
Correlate the job configuration, running process, and socket permissions. Record installation approval, service identity, and client reachability separately.
- 2
Trace authorization on each request
Follow reception through the sensitive operation and locate the actual peer and permission checks. An installation dialog or framing constant is not a substitute.
- 3
Reduce the delegated capability
Prefer fixed business operations with constrained paths, arguments, and object ownership over a client-selected executable.
- 4
Test both acceptance and rejection
Compare an unapproved client, an approved client, and invalid arguments. Observe whether a child is created and which effective identity it holds.
For this sample, the reachable endpoint, action 8, controlled launch path, NSTask, and root result form a consistent chain. Changing the IPC mechanism alone does not settle authorization. A new implementation needs a fresh audit of where each capability is granted and which identity justifies it.