Coordinated Disclosure Timeline
- 2026-04-10: Security at signal.org was notified about the vulnerability.
- 2026-04-15: Signal security informed that there was collision with another report.
- 2026-04-15: 8.7.0.1523 with the fix was released.
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
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:
threadis aTSContactThread(the attack path) — the cast toTSGroupThreadfails, the body is skipped, noelsebranch exists, validation succeeds.threadis aTSGroupThread— the check compares the group ID derived fromeditMessage.groupV2againstgroupThread.groupModel.groupId. ButgroupThreadwas itself derived bypreprocessDataMessagefromeditMessage.groupV2(L2167). It compares A vs A and never inspectseditTarget.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
- Alice is a full member of Group G with Victim. Alice sends message M at timestamp T.
- Alice is removed from Group G.
- Within 48 hours of T, Alice sends an
EditMessageto Victim over the 1:1 Signal-Protocol session with:targetSentTimestamp = TdataMessage.groupV2 = nildataMessage.body = <new content>
- On Victim’s device: contact-thread routing → threadless target lookup finds M in Group G → group-cast check skipped →
anyOverwritingUpdaterewrites 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 |
- Setup: All three in Group G. Alice sends a benign message in Group G.
- 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
PushGroupSendJobis now queued in the JobManager, persisted to SQLite, withoriginalEditedMessageset. - Carol removes Alice from Group G (Alice is offline, doesn’t receive the update yet).
- Take Alice off airplane mode. The pending
PushGroupSendJobruns.groupSendEndorsementsare now invalid → falls tolegacyTargets. The patchedcreateEditMessageContentstripsgroupV2. The edit is encrypted with the existing 1:1 Double Ratchet session and delivered to Bob. - 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:
- Signal-Android —
EditMessageProcessor.kt:75:val validGroup = groupId == targetThreadRecipient.groupId.orNull() - Signal-Desktop —
Edits.onEditrejects edits whereedit.conversationId !== targetMessage.conversationId.
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
- CWE-863: “Incorrect Authorization”
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.