Skip to content

Commit 5420f9a

Browse files
sipsorceryclaude
andcommitted
Fix SRTP AES-GCM ROC poisoning on unauthenticated packets (#1662)
For AEAD/GCM SRTP profiles, packet authentication happens during the payload decrypt (GcmBlockCipher.DoFinal), not in a separate HMAC step. UnprotectRtp, however, advanced the replay window / highest-index state (SsrcSrtpContext.S_l, which carries the ROC) via CheckAndUpdateReplayWindow *before* that decrypt. As a result a single packet that failed AEAD authentication - or a stray/reordered packet with a higher sequence number - permanently advanced S_l and then threw. Every subsequent packet derived its AES-GCM IV from the poisoned ROC (or was rejected by the replay window), producing an endless "mac check in GCM failed" with no recovery. This violated RFC 3711 section 3.3, which requires the replay list, s_l and ROC to be updated only AFTER the packet has been authenticated. HMAC profiles were unaffected because their authentication already ran before the replay update; only the GCM/AEAD path (used by WebRTC / the OpenAI realtime endpoint) was exposed. Fix (RTP path): - Split SsrcSrtpContext.CheckAndUpdateReplayWindow into a read-only CheckReplayWindow (run before decrypt) and a mutating UpdateReplayWindow (run only after the packet authenticates). - Wrap the decrypt switch so a failed AEAD authentication returns ERROR_HMAC_CHECK_FAILED instead of throwing, and does not advance any state. - Advance the replay window / ROC only after a successful decrypt. This also closes a minor DoS: previously a forged high-sequence packet could advance the replay window and drop legitimate traffic. The RTCP path (UnprotectRtcp) has the same pre-auth update and additionally derives its IV from S_l; it is left unchanged here (its explicit per-packet SRTCP index makes it lower-risk) and tracked as a follow-up. Adds SrtpGcmReplayAuthUnitTest which injects a packet that fails AEAD auth mid-stream and asserts (1) it is rejected with ERROR_HMAC_CHECK_FAILED without throwing and (2) the next legitimate packet still decrypts. The test fails on the previous implementation and passes after the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 92044d7 commit 5420f9a

2 files changed

Lines changed: 253 additions & 1 deletion

File tree

src/SIPSorcery/net/DtlsSrtp/Lib/SRTP/SrtpContext.cs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,81 @@ public bool CheckAndUpdateReplayWindow(uint sequenceNumber)
109109
return true; /* out of order but good */
110110
}
111111

112+
/// <summary>
113+
/// Read-only replay check. Returns whether <paramref name="sequenceNumber"/> (a 32-bit packet
114+
/// index) is acceptable, WITHOUT mutating any state. Per RFC 3711 section 3.3 the replay list,
115+
/// s_l and ROC MUST NOT be advanced until the packet has been authenticated, so callers must do
116+
/// this read-only check before decryption and only call <see cref="UpdateReplayWindow"/> once
117+
/// the packet has authenticated.
118+
/// </summary>
119+
/// <param name="sequenceNumber">RTP/RTCP 32-bit packet index.</param>
120+
/// <returns>true if the packet is not a replay and is within the window; otherwise false.</returns>
121+
/// <remarks>https://datatracker.ietf.org/doc/html/rfc2401 Appendix C</remarks>
122+
public bool CheckReplayWindow(uint sequenceNumber)
123+
{
124+
if (sequenceNumber == 0)
125+
{
126+
return !S_l_set; /* index 0 only acceptable as the very first packet */
127+
}
128+
129+
if (sequenceNumber > S_l)
130+
{
131+
return true; /* new larger index */
132+
}
133+
134+
var diff = (int)(S_l - sequenceNumber);
135+
if (diff >= REPLAY_WINDOW_SIZE)
136+
{
137+
return false; /* too old or wrapped */
138+
}
139+
140+
if ((Bitmap & ((ulong)1 << diff)) == ((ulong)1 << diff))
141+
{
142+
return false; /* already seen */
143+
}
144+
145+
return true; /* out of order but within the window and not yet seen */
146+
}
147+
148+
/// <summary>
149+
/// Advances the replay window / highest-index state for an index that has already passed
150+
/// <see cref="CheckReplayWindow"/> AND been authenticated. Must only be called after the packet
151+
/// authenticates, per RFC 3711 section 3.3, otherwise an unauthenticated packet could desync the
152+
/// ROC for the whole stream.
153+
/// </summary>
154+
/// <param name="sequenceNumber">The 32-bit packet index that was authenticated.</param>
155+
public void UpdateReplayWindow(uint sequenceNumber)
156+
{
157+
if (sequenceNumber == 0)
158+
{
159+
S_l_set = true;
160+
return;
161+
}
162+
163+
if (sequenceNumber > S_l)
164+
{
165+
var diff = (int)(sequenceNumber - S_l);
166+
if (diff < REPLAY_WINDOW_SIZE)
167+
{
168+
Bitmap = Bitmap << diff;
169+
Bitmap |= 1; /* set bit for this packet */
170+
}
171+
else
172+
{
173+
Bitmap = 1; /* This packet has a "way larger" index */
174+
}
175+
S_l = sequenceNumber;
176+
S_l_set = true;
177+
return;
178+
}
179+
180+
var d = (int)(S_l - sequenceNumber);
181+
if (d < REPLAY_WINDOW_SIZE)
182+
{
183+
Bitmap |= ((ulong)1 << d); /* mark as seen */
184+
}
185+
}
186+
112187
public void SetInitialSequence(uint sequenceNumber)
113188
{
114189
if (!S_l_set)
@@ -824,12 +899,16 @@ public virtual int UnprotectRtp(ReadOnlyBytes input, Bytes output, out int outpu
824899

825900
var offset = RtpReader.ReadHeaderLen(input);
826901

827-
if (!ssrcContext.CheckAndUpdateReplayWindow(index))
902+
// Read-only replay check. The window/ROC state is NOT advanced here; that only happens once
903+
// the packet has authenticated (UpdateReplayWindow below). RFC 3711 section 3.3.
904+
if (!ssrcContext.CheckReplayWindow(index))
828905
{
829906
outputBufferLength = 0;
830907
return ERROR_REPLAY_CHECK_FAILED;
831908
}
832909

910+
try
911+
{
833912
switch (context.Cipher)
834913
{
835914
case SrtpCiphers.NULL:
@@ -970,6 +1049,20 @@ public virtual int UnprotectRtp(ReadOnlyBytes input, Bytes output, out int outpu
9701049
return ERROR_UNSUPPORTED_CIPHER;
9711050
}
9721051
}
1052+
}
1053+
catch (Org.BouncyCastle.Crypto.InvalidCipherTextException)
1054+
{
1055+
// AEAD (GCM/CCM) authentication failed. Drop the packet WITHOUT advancing the replay
1056+
// window / ROC, so a single unauthenticated, corrupted or reordered packet cannot desync
1057+
// the ROC and cause every subsequent packet to fail to decrypt. RFC 3711 section 3.3.
1058+
outputBufferLength = 0;
1059+
return ERROR_HMAC_CHECK_FAILED;
1060+
}
1061+
1062+
// The packet has now been authenticated (HMAC above for HMAC profiles, or the AEAD decrypt
1063+
// for GCM/CCM profiles). Only now is it safe to advance the replay window / ROC.
1064+
// RFC 3711 section 3.3.
1065+
ssrcContext.UpdateReplayWindow(index);
9731066

9741067
// because of CCM/GCM, RTP headers must be unprotected only after the payload is unprotected and HMAC is verified
9751068
// RFC6904
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//-----------------------------------------------------------------------------
2+
// Filename: SrtpGcmReplayAuthUnitTest.cs
3+
//
4+
// Description: Regression test for the AES-GCM SRTP "ROC poisoning" bug
5+
// (issue #1662). For AEAD/GCM profiles, authentication happens during the
6+
// payload decrypt. The original UnprotectRtp advanced the replay window /
7+
// highest-index state (S_l, which carries the ROC) BEFORE that decrypt, so a
8+
// single packet that failed AEAD authentication - or a stray/reordered packet -
9+
// permanently advanced S_l. Every subsequent packet then derived the wrong
10+
// AES-GCM IV (or was rejected by the replay window), producing an endless
11+
// "mac check in GCM failed" and never recovering.
12+
//
13+
// Per RFC 3711 section 3.3 the replay list / s_l / ROC MUST only be updated
14+
// AFTER the packet authenticates. This test injects a packet that fails AEAD
15+
// authentication mid-stream and asserts:
16+
// 1. UnprotectRtp returns ERROR_HMAC_CHECK_FAILED (it does not throw); and
17+
// 2. the next legitimate in-order packet still decrypts.
18+
//
19+
// On the original (buggy) implementation step 1 throws
20+
// InvalidCipherTextException and step 2 fails with ERROR_REPLAY_CHECK_FAILED
21+
// (the forged high sequence number poisoned S_l). After the fix both pass.
22+
//
23+
// Author(s):
24+
// Aaron Clauson
25+
//
26+
// History:
27+
// 02 Jun 2026 Aaron Clauson Created.
28+
//
29+
// License:
30+
// BSD 3-Clause "New" or "Revised" License, see included LICENSE.md file.
31+
//-----------------------------------------------------------------------------
32+
33+
using System;
34+
using SIPSorcery.Net.SharpSRTP.SRTP;
35+
using Xunit;
36+
37+
namespace SIPSorcery.Net.UnitTests
38+
{
39+
public class SrtpGcmReplayAuthUnitTest
40+
{
41+
[Fact]
42+
public void FailedAeadAuth_DoesNotPoisonSubsequentPackets()
43+
{
44+
// SRTP_AEAD_AES_128_GCM - the profile OpenAI's realtime endpoint negotiates.
45+
var profile = new SrtpProtectionProfileConfiguration(
46+
SrtpCiphers.AEAD_AES_128_GCM,
47+
cipherKeyLength: 128,
48+
cipherSaltLength: 96,
49+
maximumLifetime: int.MaxValue,
50+
auth: SrtpAuth.NONE,
51+
authKeyLength: 0,
52+
authTagLength: 128);
53+
54+
// Deterministic key + salt so the test is reproducible.
55+
var key = new byte[16];
56+
var salt = new byte[12];
57+
for (int i = 0; i < key.Length; i++) { key[i] = (byte)(0x10 + i); }
58+
for (int i = 0; i < salt.Length; i++) { salt[i] = (byte)(0x80 + i); }
59+
60+
var sender = new SrtpContext(SrtpContextType.RTP, profile, key, salt);
61+
var attacker = new SrtpContext(SrtpContextType.RTP, profile, key, salt); // produces the forged packet
62+
var receiver = new SrtpContext(SrtpContextType.RTP, profile, key, salt);
63+
64+
const uint ssrc = 0x1234abcdu;
65+
66+
// ---- 1. Establish in-order receiver state (seqs 1..3) ----
67+
for (ushort seq = 1; seq <= 3; seq++)
68+
{
69+
Assert.True(TryRoundTrip(sender, receiver, ssrc, seq) == 0, $"Round-trip failed for seq {seq}.");
70+
}
71+
72+
// ---- 2. Inject a packet that fails AEAD authentication, with a higher sequence number ----
73+
// A forged/corrupted packet whose 16-bit sequence number (100) is greater than the last
74+
// accepted one. Pre-fix the receiver advanced S_l to 100 BEFORE the GCM auth ran, then threw.
75+
// With the fix it must return ERROR_HMAC_CHECK_FAILED without throwing and without advancing
76+
// any state.
77+
byte[] forged = EncryptThenCorrupt(attacker, ssrc, seq: 100);
78+
var scratch = new byte[forged.Length];
79+
int forgedResult = Unprotect(receiver, forged, scratch, out _);
80+
Assert.True(forgedResult == SrtpContext.ERROR_HMAC_CHECK_FAILED,
81+
$"A packet that fails AEAD authentication should be rejected with ERROR_HMAC_CHECK_FAILED ({SrtpContext.ERROR_HMAC_CHECK_FAILED}) and must not throw; got {forgedResult}.");
82+
83+
// ---- 3. The next legitimate in-order packet must still decrypt ----
84+
// Pre-fix this returned ERROR_REPLAY_CHECK_FAILED (-4) because the forged seq 100 had poisoned
85+
// S_l (seq 4 then looked "too old"). With the fix S_l was never advanced, so seq 4 decrypts.
86+
int afterResult = TryRoundTrip(sender, receiver, ssrc, seq: 4);
87+
Assert.True(afterResult == 0,
88+
$"A failed-authentication packet poisoned the SRTP ROC/replay state: the next legitimate packet returned {afterResult} instead of 0. Per RFC 3711 section 3.3 state must only advance after authentication.");
89+
}
90+
91+
// ---- helpers ----
92+
93+
private static byte[] EncryptThenCorrupt(SrtpContext sender, uint ssrc, ushort seq)
94+
{
95+
var rtpPacket = MakeRtpPacket(ssrc, seq, payloadType: 96, payload: new byte[16]);
96+
int extra = sender.CalculateRequiredSrtpPayloadLength(rtpPacket.Length) - rtpPacket.Length;
97+
var encrypted = new byte[rtpPacket.Length + extra];
98+
99+
int encLen;
100+
int encRc = Protect(sender, rtpPacket, encrypted, out encLen);
101+
Assert.True(encRc == 0, $"Protect failed (rc={encRc}).");
102+
103+
var trimmed = new byte[encLen];
104+
Buffer.BlockCopy(encrypted, 0, trimmed, 0, encLen);
105+
106+
// Flip a ciphertext byte (first payload byte, after the 12-byte RTP header) so the GCM
107+
// authentication tag no longer matches the payload.
108+
trimmed[12] ^= 0xFF;
109+
return trimmed;
110+
}
111+
112+
private static int TryRoundTrip(SrtpContext sender, SrtpContext receiver, uint ssrc, ushort seq)
113+
{
114+
var rtpPacket = MakeRtpPacket(ssrc, seq, payloadType: 96, payload: new byte[16]);
115+
int extra = sender.CalculateRequiredSrtpPayloadLength(rtpPacket.Length) - rtpPacket.Length;
116+
var encrypted = new byte[rtpPacket.Length + extra];
117+
118+
int encLen;
119+
int encRc = Protect(sender, rtpPacket, encrypted, out encLen);
120+
if (encRc != 0) { return encRc; }
121+
122+
var encryptedTrimmed = new byte[encLen];
123+
Buffer.BlockCopy(encrypted, 0, encryptedTrimmed, 0, encLen);
124+
125+
var decrypted = new byte[encLen];
126+
return Unprotect(receiver, encryptedTrimmed, decrypted, out _);
127+
}
128+
129+
private static byte[] MakeRtpPacket(uint ssrc, ushort seq, byte payloadType, byte[] payload)
130+
{
131+
// Minimal valid RTP packet: 12-byte header + payload. V=2, P=0, X=0, CC=0, M=0, PT=payloadType.
132+
var pkt = new byte[12 + payload.Length];
133+
pkt[0] = 0x80;
134+
pkt[1] = payloadType;
135+
pkt[2] = (byte)(seq >> 8);
136+
pkt[3] = (byte)(seq & 0xFF);
137+
pkt[8] = (byte)((ssrc >> 24) & 0xFF);
138+
pkt[9] = (byte)((ssrc >> 16) & 0xFF);
139+
pkt[10] = (byte)((ssrc >> 8) & 0xFF);
140+
pkt[11] = (byte)( ssrc & 0xFF);
141+
Buffer.BlockCopy(payload, 0, pkt, 12, payload.Length);
142+
return pkt;
143+
}
144+
145+
#if NET8_0_OR_GREATER
146+
private static int Protect(SrtpContext ctx, byte[] input, byte[] output, out int outLen)
147+
=> ctx.ProtectRtp(input.AsSpan(), output.AsSpan(), out outLen);
148+
149+
private static int Unprotect(SrtpContext ctx, byte[] input, byte[] output, out int outLen)
150+
=> ctx.UnprotectRtp(input.AsSpan(), output.AsSpan(), out outLen);
151+
#else
152+
private static int Protect(SrtpContext ctx, byte[] input, byte[] output, out int outLen)
153+
=> ctx.ProtectRtp(new ArraySegment<byte>(input), output, out outLen);
154+
155+
private static int Unprotect(SrtpContext ctx, byte[] input, byte[] output, out int outLen)
156+
=> ctx.UnprotectRtp(new ArraySegment<byte>(input), output, out outLen);
157+
#endif
158+
}
159+
}

0 commit comments

Comments
 (0)