# RustServer 보안 진단 기록: 메모리 안전성 다음에 확인한 것들

2026-09-07 · 진단 및 재현 완료 시점의 기록 · 보안 패치 미적용

RustServer의 소스를 검토하고 로컬에서 문제를 재현한 결과, 우선 해결할 대상은 무제한 이벤트 적재, 송수신 콜백의 교착, HTTP 메시지 경계 처리, 그리고 파일·네트워크의 신뢰 경계였다. **메모리 변조나 실제 침해 사실을 확인한 조사는 아니다.** 이 글은 발견한 문제와 그 근거, 성능을 고려한 수정 방향을 기록한다. 패치 완료나 성능 개선을 보고하는 글은 아니다.

## 조사를 시작한 이유

출발점은 “내부 메모리 변조나 취약점이 있는지 전체적으로 확인해 달라”는 요청이었다. 대상은 CppServer 1.0.6.0의 프로토콜과 예제를 Rust로 옮긴 공개 프로젝트 [RustServer](https://github.com/hypark5540/rustserver/tree/b22031faff65bf078fe8c1e560e94409a049fdfa)다. TCP, TLS, UDP, HTTP/HTTPS, WebSocket/WSS와 FBE 기반 메시지 프로토콜을 제공한다.

프로젝트 자체에는 `#![forbid(unsafe_code)]`가 적용되어 있다. 이는 직접 작성한 unsafe Rust를 막는 유용한 방어선이지만, 큐가 끝없이 커지는 문제나 작업이 서로를 기다리는 문제까지 해결해 주지는 않는다. 종속 라이브러리와 운영체제 전체에 unsafe 코드가 없다는 뜻도 아니다. 그래서 파서, 데이터 보유량, 비동기 작업의 수명, 종료 경로와 신뢰 경계를 함께 살폈다. [검토 대상의 설정과 코드](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/lib.rs)

## 범위와 판단 기준

| 항목 | 기록 |
| --- | --- |
| 프로젝트 | `rustserver` v1.0.6 |
| 기준 커밋 | `b22031faff65bf078fe8c1e560e94409a049fdfa` |
| 검증 환경 | macOS Apple Silicon, rustc 1.97.1 |
| 검토 범위 | 전송 계층, HTTP/FBE 파서, 프로토콜 큐, 캐시, TLS 설정, 예제와 Rust 의존성 |
| 동적 검사 | loopback 소켓과 임시 디렉터리에서 제한된 재현 테스트 |
| 산출물 | 별도 감사 테스트 11개, 진단 기록, 수정·검증 계획 |
| 이 기록 시점의 상태 | 운영 코드 수정 없음, 패치 전후 성능 비교 없음 |

검토는 소스와 로컬 실행을 대상으로 했다. 외부 운영 서버를 공격하거나 프로세스 메모리 덤프·커널·설치 프로그램 전체를 포렌식 조사하지 않았다. 아래 숫자는 해당 환경의 관찰값이며, 전체 배포 환경의 피해 규모나 성능 수치가 아니다. 정식 CVSS 점수나 CVE가 부여된 결과도 아니다.

## 결과를 먼저 읽기

| 검사 | 결과 | 해석 |
| --- | --- | --- |
| 기존 테스트 | 65개 통과, 실패 0개 | 기존 테스트가 검증하는 동작은 유지됨 |
| Criterion의 테스트 모드 | 6개 성공 | 벤치마크 코드 실행 확인. 성능 비교 측정 아님 |
| 별도 감사 검사 | 11개 통과 | 10개 문제 동작 재현 + 1개 입력 변이 검사 |
| 결정적 입력 변이 | 20,000개, 파서 패닉 관찰 없음 | 제한된 입력 집합의 결과. 포괄적인 퍼징 아님 |
| RustSec 의존성 감사 | 알려진 취약점 매칭 0건 | 자체 코드의 취약점이 없다는 의미는 아님 |
| 의존성 경고 | 유지보수 중단 1건, yanked 버전 1건 | 각각 `rustls-pemfile 2.2.0`, `chacha20 0.10.1` |
| 보안 패치 / 성능 비교 | 미완료 / 미실시 | 해결 여부나 처리량 변화는 아직 말할 수 없음 |

감사 테스트는 현재의 문제 동작을 확인하도록 작성했으며 기본 실행에서는 `#[ignore]` 상태다. **이 테스트의 `pass`는 안전 판정이 아니라 재현 성공을 뜻한다.** 패치 단계에서는 기대값을 반대로 바꾸고 일반 회귀 검사로 편입해야 한다.

의존성 감사는 2026-09-07 실행했으며, 사용된 데이터베이스는 2026-09-02 갱신본, 커밋 `5a0ebedfe8bdd2e295b171f4162f8c977bcad9a5`, advisory 1,239개였다. 잠금 파일의 의존성 136개를 검사했다. RustSec은 `rustls-pemfile`을 유지보수 중단으로 분류하고 `rustls-pki-types`의 `PemObject` 사용을 안내한다. 이 경고를 곧바로 악용 가능한 취약점으로 계산하지 않았다. `chacha20`의 yanked 상태도 그 자체만으로 취약점의 원인을 입증하지 않는다. [RustSec 안내](https://rustsec.org/advisories/RUSTSEC-2025-0134.html)

## 재현한 문제와 원인

### 1. HTTP 파이프라인: 큐는 제한했지만 교착이 생겼다

하나의 연결에서 작은 요청 1,100개, 총 29,700바이트를 보냈다. 기본 읽기 청크 64 KiB 안에 들어가는 양이다. 클라이언트는 응답을 계속 읽고 있었지만, 요청 핸들러 호출은 1,026개에서 멈췄고 응답은 38바이트만 관찰됐다. 250ms 관찰 창에서 진행과 `stop()` 완료를 확인하지 못했다.

원인은 TCP 수신 콜백과 송신 콜백이 같은 mutex를 사용한다는 점이다. 수신 콜백은 잠금을 보유한 채 여러 응답을 bounded 송신 큐에 넣는다. 큐가 가득 차면 빈자리를 기다린다. writer는 첫 응답을 쓴 다음 `on_sent`를 호출하기 위해 같은 잠금을 기다리므로 다음 큐 항목을 꺼내지 못한다.

```text
수신 콜백: callback mutex 보유 → 송신 큐의 빈자리 대기
writer:    다음 dequeue 전에 → callback mutex 대기
```

이는 단순히 상대가 응답을 읽지 않아서 생기는 현상이 아니다. 테스트에서는 상대가 읽고 있었고, 소스의 순환 대기 관계가 관찰을 설명한다. 다만 정확히 1,026이라는 수치는 이 실행의 스케줄링과 기본 설정에 종속된다. [TCP 읽기·쓰기 루프](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/tcp.rs#L838)

수정의 핵심은 writer가 데이터 전송을 계속하기 위해 사용자 콜백의 완료까지 기다리지 않도록 만드는 것이다. 별도 이벤트 전달이나 완료 알림 합산을 검토하되, 새 이벤트 큐에도 한도를 두고 콜백 순서와 의미를 정의해야 한다. bounded 큐를 하나 더 추가하는 것만으로는 순환 대기가 없어졌다고 할 수 없다.

### 2. 프로토콜 이벤트 큐: 작은 프레임 제한만으로는 메모리가 제한되지 않는다

연결 콜백을 의도적으로 정지시킨 상태에서 4 KiB payload의 프레임 2,048개를 보냈다. 핸들러가 메시지를 하나도 처리하지 않았는데도 서버의 수신 통계는 8,437,760바이트까지 증가했다. 이 숫자는 네트워크에서 소비한 프레임 바이트이며, 프로세스 RSS 측정값이 아니다.

프로토콜 계층의 이벤트 전달이 `unbounded_channel`을 사용해 느린 핸들러와 수신 경로 사이에 상한 없는 적체를 허용한다. 개별 프레임의 최대 크기를 제한해도 프레임 개수와 총 보유 바이트가 제한되지 않으면 메모리 예산은 정해지지 않는다. [프로토콜 이벤트 큐](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/protocol.rs#L201)

패치에서는 이벤트 개수와 총 바이트를 함께 제한할 계획이다. 큐가 찼을 때 기다릴지, 거부할지, 해당 연결을 닫을지도 명시해야 한다. 특히 콜백 내부에서 요청을 보내고 응답을 기다리는 사용법은 단순한 `send().await` 교체로 새 교착을 만들 수 있어 별도로 검증해야 한다.

### 3. TCP 종료: 취소 신호가 있어도 진행 중인 쓰기가 끝나지 않았다

상대가 읽지 않는 연결에 8 MiB를 보내고 작은 소켓 송신 버퍼를 사용했다. 대기 중인 바이트가 남은 상태에서 서버를 종료했지만 250ms 안에 완료되지 않았다. 상대 소켓을 닫자 종료가 풀렸다.

writer는 다음 큐 항목을 기다릴 때 취소를 확인하지만, 이미 시작한 `write_all()`은 취소 신호와 경쟁하지 않는다. 읽기 루프의 종료 또한 사용자 콜백을 기다릴 수 있다. 따라서 “취소 토큰이 존재한다”와 “종료가 정해진 시간 안에 끝난다”는 별개의 보장이다. [TCP writer](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/tcp.rs#L873)

이 재현은 TCP에 대한 것이다. TLS writer에는 이미 쓰기 취소와 shutdown 제한이 있으므로 똑같은 누락이라고 묶지 않았다. 다만 콜백 직렬화와 큐 간 대기는 TLS에서도 검토 대상이다. WebSocket의 송신·종료 경로도 같은 관점의 코드 검토 대상이며, 별도의 동적 재현 완료로 세지는 않았다.

### 4. HTTP timeout: 업로드가 막힌 시간은 계산하지 않았다

응답을 읽지 않는 로컬 상대에게 8 MiB 요청을 보내고 요청 timeout을 25ms로 지정했다. 호출은 250ms가 지나도 쓰기 단계에서 대기했다.

HTTP 클라이언트가 요청 송신을 완료한 뒤 응답 receiver에만 timeout을 적용하기 때문이다. HTTPS에도 같은 배치가 있다. 타이머는 송신 잠금 대기, 큐 대기, 쓰기, 응답 대기를 포함하는 전체 요청 수명을 감싸야 한다. timeout 이후 늦은 응답을 다음 요청에 잘못 대응시키지 않도록 연결과 pending FIFO 정리도 함께 설계해야 한다. [HTTP 요청 수명](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/transport.rs#L487), [HTTPS의 같은 경로](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/tls_transport.rs#L523)

### 5. HTTP 직렬화: 검증한 파서 앞에서 잘못된 메시지를 만들 수 있었다

요청 target에 CRLF를 넣으면 요청 객체 하나가 두 개의 요청으로 직렬화됐다. 응답 header 값에 CRLF를 넣으면 추가 `Set-Cookie` 헤더가 만들어졌다.

입력 파서의 검증과 달리 출력 경로인 `to_bytes()`는 문자열을 그대로 이어 붙인다. 애플리케이션이 신뢰하지 않는 값을 target이나 header/cookie에 넣을 수 있는 경우 메시지 분할이나 헤더 주입으로 이어질 수 있다. 외부 입력이 해당 API에 도달할 수 있어야 한다는 조건이 중요하며, 이 검사만으로 모든 사용 애플리케이션의 악용 가능성을 단정하지 않는다. [요청 직렬화](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/message.rs#L456), [응답 직렬화](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/message.rs#L753)

오류를 반환하는 직렬화 API를 추가하고 모든 전송 경로에서 사용하도록 할 계획이다. 기존 API를 유지하더라도 잘못된 바이트를 그대로 보내는 우회 경로가 남아서는 안 된다. 실패 반환, 기존 호출의 실패 방식, 버전 호환 정책을 함께 정해야 한다.

### 6. HTTP 프레이밍: 헤더 길이만 따르면 다음 응답을 삼킨다

304 응답 뒤에 정상 200 응답을 이어 붙였다. 앞 응답의 `Content-Length`를 뒤 응답 길이로 설정하자 파서가 두 번째 응답 전체를 304의 본문으로 소비했다. 별도 검사에서는 `Transfer-Encoding: identity`와 `Content-Length`를 함께 가진 요청을 받아들였다.

RFC 9112는 HEAD 응답 및 1xx·204·304 응답의 본문 경계를 별도로 정의하며, TE와 CL이 함께 있는 메시지의 모호성도 다룬다. 이 구현은 해당 전송 코딩을 완전히 지원하지 않으므로 모호한 조합을 거부하는 방향을 검토한다. HEAD는 응답 바이트만으로 판단할 수 없어 요청 method 문맥도 필요하다. [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112.html#section-6.3)

304와 TE+CL 수용은 동적으로 확인했다. 다른 상태 코드와 HEAD는 같은 파서 설계의 수정·추가 검사 대상이다. 프록시와 백엔드를 결합한 실제 request smuggling 전체 공격 사슬을 검증한 결과는 아니다. [응답 파싱과 전송 코딩 검사](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/message.rs#L773)

### 7. 정적 파일 캐시: 처음 안전했던 경로가 갱신 때 달라졌다

정적 콘텐츠 마운트 안의 하위 디렉터리를 바깥 디렉터리를 가리키는 심볼릭 링크로 교체했다. 캐시 watchdog이 갱신되자 마운트 밖에 만든 테스트 파일의 내용이 응답에 들어왔다.

최종 파일의 symlink 여부만 검사해서는 조상 디렉터리의 교체를 막지 못한다. 마운트 시 검증이 끝났더라도 갱신 시 경로를 다시 따라가면 신뢰 경계가 바뀔 수 있다. 경로 검증과 실제 열기 사이의 경쟁도 고려해야 한다. [캐시 갱신 경로](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/cache.rs#L278)

재현에는 마운트 내부 디렉터리를 교체할 수 있는 로컬 파일시스템 권한이 필요했다. 일반적인 원격 URL traversal만으로 재현한 문제는 아니다. 디렉터리 핸들에 묶인 안전한 파일 열기 등으로 실제 읽기까지 마운트 경계를 유지하는 방법을 검토한다. 요청마다 파일시스템을 다시 검사하기보다는 마운트·갱신 경로에서 비용을 부담하게 하는 것이 목표다.

### 8. UDP 클라이언트: 설정한 상대와 실제 송신자를 구분하지 않았다

클라이언트에 지정한 서버와 별개인 로컬 소켓에서 데이터그램을 보냈다. 이 데이터가 클라이언트의 수신 콜백에 전달됐다.

현재 수신 루프는 `recv_from()` 결과를 설정한 remote와 대조하지 않는다. 단일 서버와 통신한다고 기대한 애플리케이션에서는 신뢰 경계를 넓히는 동작이다. 유니캐스트 기본 정책은 peer를 제한하고, 멀티캐스트나 여러 송신자가 필요한 사용법은 명시적으로 분리하는 방향이 적합하다. 송신 주소 제한은 암호학적 인증을 대체하지 않는다. [UDP 클라이언트 수신](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/udp.rs#L421)

### 9. TLS 핸드셰이크: 연결만 하고 말하지 않는 상대가 남았다

TLS 서버에 TCP 연결만 생성하고 TLS 데이터를 보내지 않는 상대 128개를 연결했다. 모두 서버 세션에 등록되었으며 250ms 후에도 남아 있었다. 소스에서는 서버 핸드셰이크의 별도 timeout과 개수 상한이 확인되지 않았다.

250ms 관찰만으로 영구 잔류나 실제 OOM을 입증한 것은 아니다. 관찰 결과와 제한이 없는 코드 경로를 함께 근거로 삼았다. 전체 연결 수와 동시 핸드셰이크 수를 분리해 제한하고, 핸드셰이크 deadline과 종료 정리를 추가할 계획이다. WS/WSS의 연결 초기화도 코드 검토에서 같은 관리 대상이다. [TLS 연결 처리](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/tls/transport.rs#L889)

위 아홉 절에는 HTTP 프레이밍 검사 두 개를 포함해 총 열 개의 문제 재현이 담겨 있다. 개별 문제가 서로 독립적인 열 개의 CVE라는 의미는 아니다.

## 코드 검토에서 함께 남긴 보강 항목

메모리 KeyValueCache는 엔트리 수와 총 용량을 제한해야 한다. 개별 HTTP 본문 제한만으로 여러 요청에 걸친 누적 저장량이 제한되지는 않는다. 예제 서버의 외부 인터페이스 바인딩과 저장 API 공개 범위도 기본 설정을 점검할 대상이다. 공개된 테스트 인증서·키는 개발 fixture이며 운영 신뢰 자료로 쓰지 않아야 한다. [캐시 구현](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/src/http/cache.rs), [예제 사용 안내](https://github.com/hypark5540/rustserver/blob/b22031faff65bf078fe8c1e560e94409a049fdfa/README.md)

이 항목들은 코드 검토와 설정 검토 결과다. 해당 캐시를 채워 실제 시스템을 OOM 상태로 만들거나, 공개 키로 운영 시스템에 접근하는 실험은 하지 않았다.

## 성능과 보안을 함께 고려한 수정 방향

목표는 정상 트래픽 경로의 작업을 작게 유지하고, 과부하의 비용을 무제한 메모리 대신 제한된 대기·거부·연결 종료로 드러내는 것이다. **아래 내용은 설계 예상이며 측정된 성능 결과가 아니다.**

| 패치 영역 | 비용을 줄이는 방향 | 달라질 수 있는 동작 |
| --- | --- | --- |
| 프로토콜 큐 | 개수·총 바이트 예산을 큐 진입 시 관리 | 과부하 시 대기, 거부 또는 연결 종료 |
| TCP/WS 쓰기와 종료 | 쓰기 대기에 취소를 결합하고 정리 수명을 제한 | 종료 중 일부 미전송 데이터 폐기 가능 |
| HTTP 파이프라인 교착 | writer 진행과 완료 콜백 전달의 순환 대기 제거 | 콜백 시점·합산·순서 계약 검토 필요 |
| TLS/WS 핸드셰이크 | 연결 허용 시점에 개수 제한, 연결당 deadline | 상한 초과 연결 거부, 느린 초기 연결 timeout |
| HTTP/HTTPS timeout | 요청 전체에 하나의 deadline 사용 | 대용량·저속 업로드가 이전보다 빨리 실패 가능 |
| TE+CL | 파싱 단계에서 모호한 프레이밍 거부 | 기존 `identity` 사용 클라이언트의 호환성 변화 |
| HEAD·1xx·204·304 | 상태와 요청 method로 본문 경계 결정 | 파서 문맥 API 추가 또는 변경 |
| CRLF | 출력 경계에서 선형 검증, 오류 반환 | 잘못된 header/target에 의존한 호출 실패 |
| 정적 파일 | 마운트·갱신에서 파일 접근 경계 검증 | 디렉터리 교체 방식에 따라 갱신 거부 가능 |
| UDP | 유니캐스트 peer 제한, 멀티캐스트 정책 분리 | 임의 송신자를 받던 코드의 명시적 설정 필요 |
| 메모리 캐시 | 저장 시 총량·엔트리 예산 관리 | 한도 초과 쓰기 실패 또는 명시적 축출 정책 |
| 예제 노출 범위 | 기본 loopback 바인딩, 외부 공개는 설정으로 선택 | 다른 기기에서 기본 예제에 바로 접속 불가 |
| PEM 의존성 | 기존 기반 라이브러리 API로 이동 | 인증서 로딩 경로의 내부 변경 |

아직 큐 용량, 연결 상한, timeout의 최종 기본값을 정하지 않았다. 정해야 하는 것은 숫자뿐 아니라 최대 프레임을 처리할 수 있는지, 콜백 재진입이 가능한지, timeout으로 취소된 호출이 보유량 통계를 정확히 반환하는지까지 포함한 계약이다.

## 다음 검증에서 확인할 것

1. 열 개의 문제 재현을 차단 기대값으로 전환한다. HTTP 1xx·204·HEAD, TLS/WS 종료와 핸드셰이크, 취소 후 자원 회수 검사를 보강한다.
2. 동일한 머신·빌드 옵션·트래픽에서 패치 전후를 비교한다. TCP/HTTP/TLS/WS 처리량뿐 아니라 p50·p95·p99 지연, CPU, RSS 최고치, 큐의 최대 보유 바이트를 기록한다.
3. 정상 부하와 과부하를 분리한다. 거부한 요청은 처리량에서 숨기지 않고 성공·거부·timeout 비율을 함께 보고한다.
4. 수신을 멈춘 상대, 멈춘 사용자 콜백, 반복적인 취소와 reconnect를 포함해 종료 소요 시간과 남은 task·session을 확인한다.
5. 기존 테스트, fmt, Clippy, 문서와 의존성 감사를 다시 통과시킨다. API 호환성과 설정 변경을 마이그레이션 문서로 남긴다.

## 재현 기록과 한계

감사 테스트는 `tests/security_audit.rs`에 추가했다. 이 글의 기준 커밋에는 포함되어 있지 않은 로컬 조사 산출물이므로 해당 파일의 GitHub 커밋 링크를 제시하지 않는다. 아래 첫 명령은 그 파일을 포함한 조사 체크아웃에서 실행한 명령이다.

```console
cargo test --locked --test security_audit -- --ignored --nocapture --test-threads=1
cargo test --locked --all-targets --all-features
cargo audit --json
```

```text
감사: 11 passed; 0 failed
  HTTP pipeline: 1026 / 1100 handlers, 38 response bytes; stop stalled
  Protocol: 8,437,760 received bytes / 2,048 frames; zero callbacks processed
  HTTP timeout: configured 25ms; still pending after 250ms during write
  TLS: 128 silent peers remained registered after 250ms
  Parser mutations: 20,000 inputs; no panic observed
일반 테스트: 65 passed; 0 failed; 감사 검사 11 ignored
Criterion 테스트 모드: 6 Success (성능 수치 아님)
```

입력 변이 검사는 고정 seed의 바이트 변경과 잘라내기를 사용했다. coverage-guided fuzzing이나 Miri 검증을 완료한 것으로 해석하면 안 된다. 의존성 검사도 지정한 잠금 파일과 감사 데이터베이스 시점의 결과다. 예제, 종속 라이브러리, 브라우저 자산, OS 전체의 무결성을 보증하는 인증서는 아니다.

이 기록의 성과는 문제가 발생하는 조건과 원인을 재현 가능한 형태로 좁힌 데 있다. 보안 패치 적용과 성능 비교가 끝나면 같은 조건으로 다시 측정해 후속 기록에 연결할 예정이다.
