Compromising Signal's Contact Discovery Enclave
V12 found two critical object-lifetime vulnerabilities that allow the untrusted host server to break the enclave boundary.
Compromising Signal’s Contact Discovery Enclave
Summary
Signal’s Contact Discovery Service lets users learn which of their contacts use Signal without revealing their address book to the service. This is done by processing encrypted queries inside an attested Intel SGX enclave, keeping their contents out of reach of the server operator.
We found two critical object-lifetime vulnerabilities that allow the untrusted host server to break the enclave boundary. The first vulnerability gives the host an arbitrary enclave memory read. The second gives the host full control over the enclave’s register context, enabling code execution within the enclave. We exploited both vulnerabilities on real SGX hardware matching the Azure machines used by Signal’s production deployments, demonstrating full compromise of the enclave. Our proofs of concept extract the Noise private key from enclave memory, allowing the host to impersonate the enclave and decrypt queries.
Both issues were responsibly disclosed to Signal and have now been fixed.
Background
Contact Discovery with SGX
Signal’s Contact Discovery Service (CDSI) allows a client to learn which of their contacts are also Signal users. It submits the user’s address book to the contact discovery service every 48 hours on both the Android and iOS mobile apps, where it is enabled by default. Address books (your contact list) are sensitive data, and must be processed securely.
The computation itself is very simple: the intersection of the address book ∩ registered Signal users. An ordinary server with a user database can compute this easily, but the server learns the query. TLS protects the address book in transit, but the data is decrypted for processing, and clients cannot verify the handling of that data: whether it is processed honestly, or captured and stored.
To solve this privacy problem, Signal runs the core of the service within an Intel SGX enclave. The service provider owns and operates the host machine, but only the enclave, running the critical code, is trusted.
The enclave’s contents are measured and signed, allowing clients to verify its integrity, and it runs in a hardware-enforced protected context, with its memory encrypted and unreadable to host software. The enclave exposes only a small interface through which the host can make calls to communicate with trusted code. The hardware root of trust measures the enclave’s initial state, which clients can verify against known-good values.
Side Channel Mitigation with ORAM
While Intel SGX provides memory encryption, access patterns to the encrypted database can still leak information about queries (e.g. consider that similar queries will result in a similar pattern of memory accesses). These access patterns can be observed by the untrusted host with high resolution using a wide array of published controlled-channel attacks.
To defend against these attacks, Signal uses Oblivious RAM (ORAM) algorithms. ORAM algorithms allow access to a logical array while making memory access patterns appear random and indistinguishable. The use of ORAM to protect database lookups during query processing mitigates side-channel attacks and provides query privacy. Unsurprisingly, ORAM algorithms have significant overhead, so Signal relies on an oblivious sharding approach to improve performance using parallelism. While the algorithmic workings of ORAM aren’t relevant here, it is a fascinating area of research. Signal’s algorithm is derived from Oblix/Snoopy; SONIC proposes a newer, more performant design.
Host Control Primitives
The use of Intel SGX and oblivious algorithms provides significant security guarantees to the contact discovery service. However, the enclave still runs on a machine operated by the host, which is responsible for lifetime/provisioning, page mappings, and thread scheduling. This has advantages: the trusted computing base (TCB) is kept small, minimizing attack surface. But the enclave must be written in a way that it remains secure even when communicating with a malicious host.
While the host cannot directly access enclave memory or modify trusted code, it can indirectly interfere with its execution. For example, the host can clear page-permission bits, causing page faults that trigger enclave exits. This alone does not break confidentiality: enclave exit and resume are handled securely with hardware assistance. Even so, this can be leveraged as a primitive for fine-grained scheduling control, to make race conditions deterministically exploitable.
Consider this simplified pattern as an illustrative example:
/* trusted enclave code */if (is_valid(ptr)) { // (1) check an object // (2) <- host pauses thread here... // host makes another call, to free/replace ptr use(ptr); // (3) resume, trusted code operates on stale state}The host can force page faults at targeted points in the code, allowing it to pause an enclave thread, while it can invoke a call in another thread that corrupts the state of the first thread. By use of this pattern, races that would be rare under normal execution can be made deterministic. Similarly, being able to pause threads also simplifies heap grooming, allowing reliable and targeted UAF exploitation to inject attacker controlled state.
Duplicate shard worker UAF to Arbitrary read
Root cause
Because ORAM is expensive, CDSI parallelizes queries across shards, each with a FIFO queue serviced by a long-running worker.
For each shard, a query enqueues a lookup request followed by a wait request, then blocks until a worker dequeues the wait. With a single worker processing requests in order, the wait is always processed after the lookup finishes. Its completion therefore confirms that the lookup is done, allowing the result buffer to safely be freed.
Intended flow: Lookup requested -> result written -> wait consumed -> buffer freed
The enclave allows the host to spawn a worker with the enclave_run_shard(shard_id) ECALL. However, it does not check whether a shard already has a worker. A malicious host can therefore start a duplicate shard worker, violating the correctness of the wait protocol.
With two workers, the wait request can be consumed by the second worker while the first worker is still processing the lookup. When the first worker completes, it will write its result into a freed buffer.
Attack: Worker A starts lookup -> worker B consumes wait -> buffer freed -> worker A writes
This race can be made deterministic using the aforementioned host control primitives.
Attack flow
The stale write can be chained into a read primitive. Each lookup writes its result as a 56-byte directory record. Because account registration to phone numbers is handled by Signal’s infrastructure, the CDSI enclave makes the host responsible for loading database records into the enclave with the enclave_load_pb() ECALL. The host can therefore load crafted records into the database, and use its own client to issue a query that will write a host-controlled result. After grooming the heap, this stale write to freed memory can be made to overwrite data in another client’s response structure.
Specifically, a protobuf bytes field is represented as a pointer and a length:
struct pbtools_bytes_t { uint8_t *buf_p; size_t size;};The host arranges for the freed buffer to be reused as another client’s response, causing the stale write to overwrite buf_p and size with host controlled values. When the enclave encodes that client’s response, the protobuf encoder copies out the chosen memory range. The host can therefore read any size bytes from any enclave address.
Impact
The PoC uses this read primitive to extract the 32-byte Noise responder private key, allowing the malicious host to impersonate the enclave and decrypt its traffic. This primitive can similarly be used to extract arbitrary live enclave secrets.
Client-handle TOCTOU to Code execution
Root cause
Each client connection is represented inside the enclave by a client_t object; the enclave returns the object’s enclave address to the host as an opaque handle. To acquire a client, client_get() checks that the object has a secret canary value, then marks it in-use with a compare-and-swap on a separate state byte:
check(c->canary == g_client_secret);CAS(c->state, CLIENT_UNUSED, CLIENT_INUSE);The client_t object also holds the client’s Noise (secure channel) state: send and recv point to NoiseCipherState objects which have encrypt/decrypt callback fields that are invoked during client operations.
The canary check confirms that the address refers to a real live client at the moment of the check. However, because the check and the acquisition are separate operations, the enclave later acts on whatever object now occupies that address. A stale acquisition can accept a host-controlled replacement object. Because send and recv are part of that replacement, the host can control the function pointer the enclave will call.
Attack flow
The host pauses enclave_retry_response() between these two operations using the page-permission primitive: it sets the client object’s page read-only, causing the CAS to page fault, then suspends the thread. While that thread is suspended, the host calls enclave_close_client() to free the client. After grooming the heap with other ECALLs, the host calls enclave_rate_limit() to allocate a new 56-byte object at the same address with host chosen contents. When the original thread is resumed, it operates on the replacement which the enclave treats as an already authenticated live client.
The replacement object is crafted with send/recv pointing into host memory (in SGX, the trusted context can directly access untrusted memory). The host crafts a malicious NoiseCipherState whose encrypt callback points to oe_continue_execution, Open Enclave’s register-restoration code, with a host-supplied register context.
When the enclave attempts to encrypt the response through the stale send state, noise_encrypt_message() dispatches to the controlled state->encrypt pointer. This allows the host to set enclave registers and execute code within the trusted context. For example, our PoC executes memcpy:
RIP = enclave memcpyRSP = host-controlled stackRDI = output buffer in host memoryRSI = source buffer in enclave memoryRDX = lengthThe host causes the enclave to execute memcpy, extracting the 32-byte responder private key to host memory.
Impact
This gives the host full control over the enclave’s register context, allowing code execution within the trusted context. This results in a comprehensive compromise of the enclave’s confidentiality and integrity.
Proof of Concept
We validated both exploits on real SGX hardware, on the same Azure machine type that Signal uses in production. The enclave was built from the unmodified official sources with debugging disabled (Debug=0) and the expected MRENCLAVE measurement.
Our PoCs extract the active Noise responder private key. The enclave’s attestation authenticates the corresponding responder public key for the enclave instance, so for each exploit, we extract the private key, derive its X25519 public key, and match it to the attested value, confirming a real compromise of the live, attested enclave.
Fixes
Signal fixed both issues in their enclave code. The shard-worker bug was resolved by enforcing one worker per shard inside the trusted boundary (df22988b). The client-handle bug was fixed by merging the canary and state into a single atomic acquisition word (b1c5ac44).