| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-50524 |
| 영향 소프트웨어 | .NET 8.0.0–8.0.28, 9.0.0–9.0.17, 10.0.0–10.0.9의 System.Net.Security |
| 영향 플랫폼 | Linux, macOS, Windows를 포함한 모든 공식 공지 플랫폼·아키텍처 |
| 취약 지점 | SslStream.IO.cs::ProcessTlsFrame()과 TlsFrameHelper.TryGetFrameHeader()의 실패 의미 불일치 |
| CWE | CWE-1287 (Improper Validation of Specified Type of Input) |
| CVSS v3.1 | CVSS 7.5 High — AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| 공개일 | 2026-07-14 |
| 수정 버전 | .NET 8.0.29 / SDK 8.0.129, .NET 9.0.18 / SDK 9.0.316, .NET 10.0.10 / SDK 10.0.302 |
| 수정 커밋 | dotnet/runtime 84737e147c8169b730fe64ba48126ea688872f7e |
.NET의 SslStream은 네트워크 스트림 위에서 TLS 핸드셰이크와 암호화 레코드를 처리한다.
한 번 읽은 TCP 버퍼에 TLS 레코드가 여러 개 들어오면 ProcessTlsFrame()은 첫 레코드를
소비한 뒤, 남은 바이트에서도 완전한 핸드셰이크 레코드를 찾아 한 번에 네이티브 TLS
구현으로 넘긴다.
취약 버전의 TlsFrameHelper.TryGetFrameHeader()는 남은 바이트가 TLS나 지원하던 SSLv2
헤더로 보이지 않을 때 header.Length = -1로 표시하면서도 true를 반환했다.
최초 프레임 크기를 구하는 경로는 이 음수 길이를 별도로 거부했지만, 같은 버퍼의 두 번째
이후 프레임을 묶는 경로는 반환값만 믿고 -1을 정상 프레임 길이처럼 사용했다. 그러면
버퍼의 소비 위치가 앞으로 가지 않고 오히려 한 바이트 뒤로 물러나 같은 잘못된 데이터를
반복 처리하거나 잘못된 범위 예외로 연결될 수 있다. 네트워크 공격자는 핸드셰이크 중 정상
형태의 빈 레코드 뒤에 조작된 후속 바이트를 붙여 애플리케이션을 멈추거나 충돌시켜
서비스 거부를 일으킬 수 있었다.
TCP는 연속된 바이트 스트림만 제공하고, 송신자가 한 번에 쓴 메시지 경계를 보존하지 않는다. TLS는 자체 레코드 헤더로 그 경계를 복원한다. TLS 1.0 이후 레코드 헤더는 5바이트다.
바이트 오프셋 0 1..2 3..4
+-----------+----------------+----------------+
TLS 레코드 헤더 | ContentType| LegacyVersion | PayloadLength |
| 1 byte | 2 bytes | 2 bytes |
+-----------+----------------+----------------+
전체 레코드 길이 = 5 + PayloadLength
예를 들어 15 03 01 00 00은 타입 0x15(Alert), 버전 필드 0x0301, 페이로드 길이
0인 5바이트 레코드로 해석할 수 있다. TCP 한 번의 읽기에는 이 레코드 하나만 올 수도 있고,
뒤에 다른 TLS 레코드가 연달아 붙을 수도 있으며, 다음 레코드의 일부만 올 수도 있다.
따라서 수신 코드는 다음을 구분해야 한다.
[완전한 레코드] [완전한 레코드] → 둘을 함께 처리 가능
[완전한 레코드] [다음 헤더 일부] → 첫 레코드만 처리, 나머지는 더 읽기
[완전한 레코드] [형식이 아닌 바이트] → 연결 오류로 종료
SslStream은 아직 TLS 구현에 넘기지 않은 암호화 바이트를 SslBuffer에 보관한다.
내부 ArrayBuffer는 활성 데이터의 시작을 _activeStart, 쓰인 끝을
_availableStart로 추적한다. 정상적인 DiscardEncrypted(n)은 이미 처리한 n바이트를
복사하지 않고 시작 오프셋만 앞으로 옮긴다.
public void Discard(int byteCount)
{
Debug.Assert(byteCount <= ActiveLength);
_activeStart += byteCount;
}처리 전
buffer: [ record A ][ record B ][ 아직 덜 온 데이터 ]
^ activeStart
DiscardEncrypted(len(A))
처리 후
buffer: [ record A ][ record B ][ 아직 덜 온 데이터 ]
^ activeStart
이 메서드의 숨은 전제는 byteCount >= 0이다. 디버그 단언도 상한만 확인하고 음수를
거부하지 않으므로, DiscardEncrypted(-1)이 호출되면 _activeStart가 한 칸 뒤로
움직인다. 이후 ActiveSpan을 만들 때 시작 위치가 음수가 되면 예외가 발생할 수 있고,
그 전에 같은 바이트를 더 긴 활성 데이터로 되풀이해서 읽는 비진행 루프가 생길 수 있다.
.NET에서 TryParse()류 메서드는 일반적으로 성공하면 true와 유효한 출력값을, 실패하면
false를 반환한다. TryGetFrameHeader(frame, ref header)도 호출자 관점에서는 다음 계약을
가져야 한다.
true → header.Length는 소비 가능한 양의 프레임 길이
false → 헤더가 부족하거나 형식이 잘못됨; 출력값을 길이로 사용하지 않음
취약 코드에서는 "5바이트보다 짧음"은 false, "5바이트 이상이지만 알 수 없는 형식"은
Length = -1과 true였다. 일부 호출자는 반환값과 길이를 모두 검사했지만 다른 호출자는
반환값만 검사했다. 같은 함수의 실패 표현이 두 갈래로 나뉜 것이 이 CVE의 핵심이다.
최초 TLS 레코드의 크기를 구하는 GetFrameSize()는 TryGetFrameHeader()가 실패하거나
길이가 음수이면 예외를 던졌다.
if (!TlsFrameHelper.TryGetFrameHeader(buffer, ref _lastFrame.Header))
throw new IOException(SR.net_ssl_io_frame);
if (_lastFrame.Header.Length < 0)
throw new AuthenticationException(SR.net_frame_read_size);하지만 ProcessTlsFrame()은 이미 첫 레코드가 완성된 뒤 같은 TCP 버퍼에 함께 들어온 다음
레코드를 묶는 최적화 경로다. 여기서는 반환값이 false일 때만 반복을 중단하고,
true이면 nextHeader.Length를 즉시 신뢰했다.
while (_buffer.EncryptedLength > TlsFrameHelper.HeaderSize)
{
TlsFrameHeader nextHeader = default;
if (!TlsFrameHelper.TryGetFrameHeader(
_buffer.EncryptedReadOnlySpan, ref nextHeader))
{
break;
}
frameSize = nextHeader.Length;
...
chunkSize += frameSize;
_buffer.DiscardEncrypted(frameSize);
}그런데 헤더 도우미의 알 수 없는 형식 분기는 다음과 같았다.
else
{
header.Length = -1;
}
return true;따라서 5바이트 이상의 malformed 후속 데이터는 frameSize = -1이 된다. 일반 데이터
처리 중에는 타입 검사 분기가 루프를 끝낼 수도 있지만, 테스트가 겨냥한 재협상
(NegotiateClientCertificateAsync()) 상태에서는 핸드셰이크/애플리케이션 경계를
다르게 처리하므로 음수 길이가 버퍼 소비까지 도달한다.
upstream 회귀 테스트의 정적 입력은 정상 연결을 만든 뒤 다음 바이트를 원시 스트림에 쓴다. 첫 5바이트는 길이 0인 Alert 레코드로 해석되고, 뒤의 0 바이트들은 어떤 지원 형식의 헤더도 아니다.
입력: 15 03 01 00 00 | 00 00 00 00 00 00
└─ 정상 헤더 ──┘ └── malformed 후속 데이터 ──┘
초기 상태
┌────────────────────── SslBuffer ──────────────────────┐
│ [15 03 01 00 00] [00 00 00 00 00 00] │
│ ^ activeStart │
└────────────────────────────────────────────────────────┘
│ 첫 frameSize = 5
▼
DiscardEncrypted(5)
┌────────────────────────────────────────────────────────┐
│ [처리한 첫 레코드] [00 00 00 00 00 00] │
│ ^ activeStart │
└────────────────────────────────────────────────────────┘
│ TryGetFrameHeader → true, Length = -1
▼
chunkSize += -1
DiscardEncrypted(-1)
┌────────────────────────────────────────────────────────┐
│ [처리한 첫 레코드] [00 00 00 00 00 00] │
│ ^ activeStart가 한 바이트 뒤로 이동 │
└────────────────────────────────────────────────────────┘
│ 같은 잘못된 영역을 더 길게 다시 봄
└─ 반복 비진행 또는 잘못된 Span 범위 → 응답 불능/충돌
깨진 불변식은 **"프레임을 처리할 때마다 버퍼 시작점은 양의 프레임 길이만큼 단조롭게
앞으로 이동한다"**는 것이다. -1이 정상 길이로 흘러들면 이 진행 보장이 역전된다.
Microsoft 공지는 인증·사용자 상호작용 없이 네트워크에서 malformed TLS 핸드셰이크를 보내
애플리케이션을 충돌시키거나 응답하지 않게 만들 수 있다고 설명하며, 기밀성·무결성 영향은
없고 가용성 영향만 높게 평가한다.
수정 커밋 84737e147은 실패 의미를 false 하나로 통일했다. 알 수 없는 형식이면
Length = -1을 남기는 것에 더해 즉시 false를 반환한다.
else
{
+ // unknown format
header.Length = -1;
+ return false;
}
return true;최초 레코드 경로는 false를 AuthenticationException으로 처리하도록 정리됐고,
더 중요한 후속 레코드 경로도 이제 실패를 조용히 break하지 않고 연결 인증 오류로
종료한다.
if (!TlsFrameHelper.TryGetFrameHeader(
_buffer.EncryptedReadOnlySpan, ref nextHeader))
{
- break;
+ if (NetEventSource.Log.IsEnabled())
+ NetEventSource.Error(this, "invalid TLS frame size");
+ throw new AuthenticationException(SR.net_frame_read_size);
}커밋은 ProcessTlsFrame() 입구와 각 후속 프레임 처리 전에 frameSize > 0 디버그 단언도
추가했다. 단언은 릴리스 환경의 입력 검증을 대신하지 않지만, 개발·테스트 중 같은 계약
위반이 다시 들어오면 즉시 드러나게 한다.
[첫 번째 정상 TLS 레코드] [malformed 후속 헤더]
│
▼
첫 레코드는 정상적으로 소비
│
▼
TryGetFrameHeader(후속 데이터)
├─ 정상 형식 → true + frameSize > 0 → 앞으로 소비
└─ 알 수 없는 형식 → false
│
▼
AuthenticationException
연결/핸드셰이크를 오류로 종료
DiscardEncrypted(-1) 호출 없음
activeStart가 뒤로 가는 상태 전이 없음
회귀 테스트 MalformedPacketsDuringHandshake_ThrowsAuthenticationException()은 정상
클라이언트·서버 SslStream 인증을 마친 뒤 클라이언트 인증서 재협상 중 조작된 바이트를
보내고, 서버가 무한 처리나 예기치 않은 버퍼 예외 대신 AuthenticationException으로
종료하는지 확인한다. 이 보고서는 테스트나 입력을 실행하지 않고 소스와 diff만 정적으로
분석했다.
운영자는 사용하는 지원 계열을 각각 8.0.29, 9.0.18, 10.0.10 이상으로 올리고 애플리케이션을 재시작해야 한다. 프레임워크 종속 배포는 패치된 공유 런타임을 설치하면 되지만, 취약 런타임을 포함한 self-contained 또는 single-file 배포는 새 SDK/런타임으로 다시 빌드해 재배포해야 한다.
프레임 파서의 true 반환과 양의 길이 조건을 하나의 타입이나 결과 객체로 묶으면 같은 계약 분리를 어떻게 예방할 수 있을까?
TCP 버퍼에서 여러 레코드를 한 번에 묶는 최적화와 레코드별 엄격한 검증 사이에는 어떤 테스트 경계가 필요할까?
- Microsoft Security Advisory CVE-2026-50524 — dotnet/announcements — 영향 플랫폼·버전, CVSS, 수정 버전과 배포 지침
- CVE-2026-50524 — NVD — CNA 설명, CWE와 CPE
- dotnet/runtime 수정 커밋
84737e147— 실제 파서 수정과 회귀 테스트, MIT License - RFC 8446 §5.1 Record Layer — TLS 1.3 레코드 계층과 5바이트 헤더 구조
- USN-8553-1 — .NET vulnerabilities — Linux 배포판 수정 패키지 확인
본문 참고 자료로 충분했다.
필수 검색에서 독립적인 공개 exploit/PoC는 확인하지 못했다. upstream 커밋에는
malformed 후속 TLS 바이트를 사용하는 회귀 테스트가 포함돼 있지만, 이는 고정된 단위
검증이며 공개 exploit로 취급하지 않았다. PoCTrust는 공개 exploit 없음·회귀 테스트
정적 분석만 수행이다.