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
Signal-iOS contains vulnerabilities in cross-thread authorization for pinning/unpinning messages (>= 7.91) and poll votes (>= 7.84).
Signal for iOS validates a dataMessage sender’s group membership against the thread named by the envelope’s group context, but the Pinned Message and Poll Vote handlers then look up the target message in a global, thread-blind index. The validated thread is never compared to the thread of the message that gets mutated.
Any registered Signal user can therefore unpin a message in any group on the recipient’s device, corrupt that group’s pin record, or cast a vote in a poll in a group they are not a member of — provided they can name the target by (timestamp, authorAci). The poll-vote handler has no message-request consultation at all. The pin/unpin handler does consult hasPendingMessageRequest, but a freshly-created TSContactThread defaults to shouldThreadBeVisible = false, which short-circuits that check before it reaches the whitelist — so an attacker who has never previously contacted the recipient passes the gate. Only an attacker whose 1:1 thread is already in the visible-but-unaccepted state is blocked.
The impact is local to the targeted recipient’s database (the malicious dataMessage is delivered 1:1, not to the group fan-out, so other group members are unaffected) and does not touch message content, keys, or trust state. The author-binding checks themselves are correct: the attacker can only unpin/vote as themselves, not impersonate another principal.
Root cause: InteractionStore.fetchMessage is not thread-scoped
SignalServiceKit/Messages/Interactions/InteractionStore.swift:251-280
public func fetchMessage(
timestamp: UInt64,
incomingMessageAuthor: Aci?,
transaction: DBReadTransaction,
) throws -> TSMessage? {
let records = try InteractionRecord.fetchAll(
transaction.database,
sql: """
SELECT *
FROM \(InteractionRecord.databaseTableName)
WHERE \(interactionColumn: .timestamp) = ?
""",
arguments: [timestamp],
)
for record in records {
if incomingMessageAuthor == nil, let outgoingMessage = try TSInteraction.fromRecord(record) as? TSOutgoingMessage {
return outgoingMessage
}
if
let incomingMessage = try TSInteraction.fromRecord(record) as? TSIncomingMessage,
let authorUUID = incomingMessage.authorUUID,
try ServiceId.parseFrom(serviceIdString: authorUUID) == incomingMessageAuthor
{
return incomingMessage
}
}
return nil
}
The query has a single predicate (timestamp = ?) and the post-fetch loop filters only by author. Both timestamp and incomingMessageAuthor are taken directly from sender-supplied protobuf fields by every caller below.
This contrasts with the older InteractionFinder.findMessage(withTimestamp:threadId:author:) used by Reactions, Remote Delete, and Admin Delete — that path does require message.uniqueThreadId == threadId and is not affected.
Project
Signal-iOS
Tested Version
Details
Issue 1: Cross-thread pin/unpin (GHSL-2026-112)
1.1 — The membership check is bound to the envelope’s thread, not the target’s thread
SignalServiceKit/Messages/MessageReceiver.swift:1022-1030
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)
}
When the attacker omits groupV2 from the dataMessage, the returned thread is the 1:1 contact thread with the attacker. The group-membership guard at line 1060 is never reached.
The pin gate then runs against that 1:1 thread:
SignalServiceKit/Messages/MessageReceiver.swift:1465-1492
if thread.canUserEditPinnedMessages(aci: envelope.sourceAci, tx: tx) {
if let pinMessage = dataMessage.pinMessage {
do {
try DependenciesBridge.shared.pinnedMessageManager.pinMessage(
pinMessageProto: pinMessage,
pinAuthor: envelope.sourceAci,
thread: thread,
pinSentAtTimestamp: envelope.timestamp,
expireTimer: dataMessage.expireTimer,
expireTimerVersion: dataMessage.expireTimerVersion,
transaction: tx,
)
...
}
}
if let unpinMessage = dataMessage.unpinMessage {
do {
let targetMessage = try DependenciesBridge.shared.pinnedMessageManager.unpinMessage(
unpinMessageProto: unpinMessage,
transaction: tx,
)
SignalServiceKit/Contacts/TSThread.swift:658-684
public func canUserEditPinnedMessages(aci: Aci, tx: DBReadTransaction) -> Bool {
guard !hasPendingMessageRequest(transaction: tx) else {
return false
}
guard
let groupThread = self as? TSGroupThread
else {
// Not a group thread, so no additional access to check.
return true
}
...
}
The hasPendingMessageRequest guard does not require the recipient to have accepted the attacker. preprocessDataMessage calls TSContactThread.getOrCreateThread, which creates a fresh thread on first contact:
let insertedThread = TSContactThread(contactAddress: contactAddress)
insertedThread.anyInsert(transaction: transaction)
return insertedThread
The TSThread initializer (line 180) sets self.shouldThreadBeVisible = false. The hasPendingMessageRequest call delegates (TSThread+OWS.swift:86-88) to ThreadFinder.hasPendingMessageRequest, which short-circuits before any whitelist consultation:
// If we're creating the thread, don't show the message request view
if !thread.shouldThreadBeVisible {
return false
}
This means the gate has three outcomes depending on the attacker’s existing 1:1 thread state on the recipient’s device:
Attacker’s TSContactThread state |
hasPendingMessageRequest |
canUserEditPinnedMessages |
|---|---|---|
| Fresh (no prior 1:1 contact — e.g., an ex-group-member who never DM’d Bob) | false (early return at ThreadFinder.swift:200-202) |
true — bypass |
| Visible, pending (attacker previously sent a normal message; Bob never accepted) | true (full whitelist check runs) |
false — blocked |
| Accepted | false (whitelisted) |
true |
The canonical attack profile — a removed group member who learned (timestamp, authorAci) while in the group — lands in the fresh row: group membership is tracked on the TSGroupThread, not the TSContactThread, so getOrCreateThread creates a brand-new contact thread on first 1:1 envelope. The unpin handler (MessageReceiver.swift:1487-1496) returns nil before reaching TSIncomingMessageBuilder, so it inserts nothing into the 1:1 thread and shouldThreadBeVisible stays false (TSThread.updateWithInteraction is the only path that flips it, and is never reached) — the bypass remains usable for repeated unpins.
1.2 — unpinMessage deletes the pin record by interactionId only
SignalServiceKit/Messages/Interactions/PinnedMessages/PinnedMessageManager.swift:185-238
public func unpinMessage(
unpinMessageProto: SSKProtoDataMessageUnpinMessage,
transaction: DBWriteTransaction,
) throws -> TSInteraction {
...
guard
let targetAuthorAciBinary = unpinMessageProto.targetAuthorAciBinary,
let targetAuthorAci = try? Aci.parseFrom(serviceIdBinary: targetAuthorAciBinary)
else {
throw OWSAssertionError("Target author ACI not present")
}
var targetMessageInteractionId: Int64
guard
let targetMessage = try interactionStore.fetchMessage(
timestamp: unpinMessageProto.targetSentTimestamp,
incomingMessageAuthor: targetAuthorAci == localAci ? nil : targetAuthorAci,
transaction: transaction,
), let interactionId = targetMessage.grdbId?.int64Value
else {
throw OWSAssertionError("Can't find target pinned message")
}
...
failIfThrows {
_ = try PinnedMessageRecord
.filter(PinnedMessageRecord.Columns.interactionId == targetMessageInteractionId)
.deleteAll(transaction.database)
}
return targetMessage
}
The function never receives the validated thread. Both targetSentTimestamp and targetAuthorAciBinary are read straight from the proto, fetchMessage resolves them across the entire database, and the deleteAll filters on interactionId alone — so a pin record belonging to any group is removed.
1.3 — pinMessage writes a PinnedMessageRecord with the envelope’s threadId against the target’s interactionId
SignalServiceKit/Messages/Interactions/PinnedMessages/PinnedMessageManager.swift:79-168
public func pinMessage(
pinMessageProto: SSKProtoDataMessagePinMessage,
pinAuthor: Aci,
thread: TSThread,
...
) throws {
...
guard
let targetAuthorAciBinary = pinMessageProto.targetAuthorAciBinary,
let targetAuthorAci = try? Aci.parseFrom(serviceIdBinary: targetAuthorAciBinary)
...
guard
let targetMessage = try interactionStore.fetchMessage(
timestamp: pinMessageProto.targetSentTimestamp,
incomingMessageAuthor: targetAuthorAci == localAci ? nil : targetAuthorAci,
transaction: transaction,
), let interactionId = targetMessage.grdbId?.int64Value,
targetMessage.giftBadge == nil,
!targetMessage.wasRemotelyDeleted
...
guard let threadId = thread.sqliteRowId else {
throw OWSAssertionError("threadId not found")
}
// If this is a retry of an existing pinned message, delete the old entry so the expiry gets updated.
deletePinForMessage(interactionId: targetMessageInteractionId, transaction: transaction)
...
failIfThrows {
_ = try PinnedMessageRecord.insertRecord(
interactionId: targetMessageInteractionId,
threadId: threadId,
...
)
}
thread here is the 1:1 thread; targetMessageInteractionId belongs to a message in Group X. The deletePinForMessage call (lines 240-249) again filters only by interactionId:
public func deletePinForMessage(
interactionId: Int64,
transaction: DBWriteTransaction,
) {
_ = failIfThrows {
try PinnedMessageRecord
.filter(PinnedMessageRecord.Columns.interactionId == interactionId)
.deleteAll(transaction.database)
}
}
— so if the target was already pinned in Group X, that record is deleted. The newly-inserted record has threadId = <1:1 thread> and interactionId = <Group X message>. Because fetchPinnedMessagesForThread filters by threadId, the message disappears from Group X’s pinned list. Functionally equivalent to an unpin, plus a stray record on the 1:1 thread.
1.4 — Attack scenario
- Bob is in Group X. Charlie’s message at timestamp
Tis pinned in Group X on Bob’s device. - Alice is any registered Signal user who knows
(T, Charlie's ACI)— most realistically a former Group X member, but the only hard requirement is the tuple. Alice has never had any contact with Bob. - Alice sends
Content { dataMessage { unpinMessage { targetSentTimestamp = T, targetAuthorAciBinary = <Charlie> } } }with nogroupV2context, addressed to Bob’s ACI. - Bob’s client:
preprocessDataMessage→getOrCreateThreadcreates a freshTSContactThread(shouldThreadBeVisible = false) →canUserEditPinnedMessages→hasPendingMessageRequestreturnsfalse(ThreadFinder.swift:200-202) → non-groupreturn true(line 667) →unpinMessage→fetchMessagefinds Charlie’s message in Group X →PinnedMessageRecorddeleted.
Pin/unpin in Signal’s threat model is a group-admin action. Alice — a stranger — performed it on Bob’s local view of a group she has no relationship with.
CWEs
- CWE-863: “Incorrect authorization”
Issue 2: Cross-thread poll vote (GHSL-2026-113)
SignalServiceKit/Messages/MessageReceiver.swift:1208-1215
if let pollVote = dataMessage.pollVote {
do {
guard
let (targetMessage, shouldNotifyAuthorOfVote) = try DependenciesBridge.shared.pollMessageManager.processIncomingPollVote(
voteAuthor: envelope.sourceAci,
pollVoteProto: pollVote,
transaction: tx,
)
The thread validated by preprocessDataMessage is in scope here but not passed.
SignalServiceKit/Messages/Interactions/Polls/PollMessageManager.swift:120-168
public func processIncomingPollVote(
voteAuthor: Aci,
pollVoteProto: SSKProtoDataMessagePollVote,
transaction: DBWriteTransaction,
) throws -> (TSMessage, shouldNotifyAuthorOfVote: Bool)? {
guard
let aciBinary = pollVoteProto.targetAuthorAciBinary,
let pollAuthorAci = try? Aci.parseFrom(serviceIdBinary: aciBinary)
...
guard
let targetMessage = try interactionStore.fetchMessage(
timestamp: pollVoteProto.targetSentTimestamp,
incomingMessageAuthor: localAci == pollAuthorAci ? nil : pollAuthorAci,
transaction: transaction,
),
targetMessage.isPoll,
let interactionId = targetMessage.grdbId?.int64Value
...
let signalRecipient = recipientDatabaseTable.fetchRecipient(serviceId: voteAuthor, transaction: transaction)
guard let voteAuthorId = signalRecipient?.id else {
Logger.error("Can't find voter in recipient table")
return nil
}
let isUnvote = try pollStore.updatePollWithVotes(
interactionId: interactionId,
optionsVoted: pollVoteProto.optionIndexes,
voteAuthorId: voteAuthorId,
voteCount: pollVoteProto.voteCount,
transaction: transaction,
)
pollAuthorAci (proto-supplied) and targetSentTimestamp (proto-supplied) drive the thread-blind lookup; the only post-fetch check is targetMessage.isPoll. The downstream PollStore.checkValidVote only checks !poll.isEnded, multi-select compliance, and voteCount range — no membership check exists at any layer.
processIncomingPollTerminate (lines 170-196) has the same lookup gap, but the incomingMessageAuthor: terminateAuthor == localAci ? nil : terminateAuthor binding restricts the target to polls authored by envelope.sourceAci. An attacker can therefore terminate only their own poll cross-thread — a logic bug, not a privilege escalation.
Attack scenario
Alice (any registered Signal user, no prior contact with Bob, not in Group X) sends Content { dataMessage { pollVote { targetSentTimestamp = T, targetAuthorAciBinary = <Charlie>, optionIndexes = [0], voteCount = 1 } } } with no groupV2. Unlike the pin path, this handler never consults hasPendingMessageRequest — the only upstream gate is preprocessDataMessage, which always returns a thread for a 1:1 envelope. The vote therefore lands regardless of whether Alice’s contact thread is fresh, visible-pending, or accepted. Bob’s client records Alice’s vote against Charlie’s poll in Group X.
The vote is correctly attributed to Alice (voteAuthorId is derived from envelope.sourceAci; see MessageReceiver.swift:L1212 → PollMessageManager.swift:L150 → PollMessageManager.swift:L160). The integrity violation is “non-member voted”, not “spoofed someone else’s vote”: Bob will see Alice voted Option A in a group Alice is not in.
CWEs
- CWE-863: “Incorrect authorization”
Impact
Severity: Low.
The bypass is a clean threat-model violation — any registered Signal user, with no prior relationship to the recipient, performs a group-admin action on the recipient’s device — but it is bounded in every direction that matters:
- Local-only. The malicious
dataMessagetravels on the 1:1 unicast path, not the group fan-out. The recipient’s device is the only one whose pin/poll state is touched; every other group member’s view is unchanged. - Targeting requires prior knowledge. The attacker must supply an exact
(timestamp, authorAci)pair. Timestamps are millisecond-precisionUInt64; not blindly guessable. Practically obtainable by a former group member, by sharing another group with the target message’s author, or by traffic analysis. - No effective contact-acceptance requirement on the realistic attack path. Issue 2 has no message-request consultation. Issue 1 has one, but it passes in two of the three possible thread states: a fresh 1:1 thread (no prior contact — the canonical “removed group member” profile, since group membership does not create a contact thread) and an accepted 1:1 thread both let the attacker through. The gate blocks only the visible-but-unaccepted state — an attacker who has previously sent the recipient a normal 1:1 message that was never accepted.
- No content, keys, or trust touched. Pins and poll votes are recoverable UI metadata. Nothing here reaches message bodies, identity keys, sender keys, link previews, or registration state.
- Attribution intact. The poll vote is recorded under the attacker’s own
recipientId. The anomaly is visible: a vote from someone not in the group.
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-112 or GHSL-2026-113 in any communication regarding these issues.