요약
[](https://www.nhncloud.com/kr) ## 들어가며 안녕하세요. NHN Cloud NoSQL개발파트 이준영입니다. 여러분은 CVE(Common Vulnerabilities and Exposures)에 대해 들어보셨나요? CVE란 공개적으로 알려진 소프트웨어 및 하드웨어의 보안 취약점을 식별하고 명명하기 위한 표준화된 체계입니다. 각 취약점은 CVE ID라는 고유 식별자를 부여받으며, 이는 전 세계 보안 커뮤니티가 동일한 취약점을 정확하고 일관되게 추적하고 논의할 수 있도록 돕습니다. 저는 작년에 개인 프로젝트로 Valkey와 Redis의 취약점을 연구하였는데요. 운이 좋게 CVE ID까지 발급받게 되었습니다. 이번 글에서는 해당 취약점의 분석 내용을 공유하고자 합니다. ### CVE 정보 | 구분 | 세부 내용| | --- | --- | | CVE | [https://nvd.nist.gov/vuln/detail/CVE-2025-67733](https://nvd.nist.gov/vuln/detail/CVE-2025-67733) | | PoC | [https://github.com/JYlab/CVE-2025-67733](https://github.com/JYlab/CVE-2025-67733) | | GitHub Security Advisory | [https://github.com/valkey-io/valkey/security/advisories/GHSA-p876-p7q5-hv2m](https://github.com/valkey-io/valkey/security/advisories/GHSA-p876-p7q5-hv2m) | | Valkey 패치 | - 취약 버전: 9.0.1 이하- 패치 버전: 9.0.2, 8.1.6, 8.0.7, 7.2.12 | | Redis 패치 | - 취약 버전: 8.4.1 이하
- 패치 버전: 8.4.2, 8.2.5, 8.0.6, 7.4.8, 7.2.13 | | CVSS | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:L/A:H (8.5/10) |
## 개요 먼저 RESP 인젝션 공격에 대해 설명하고자 합니다. 분석 내용은 Valkey 8.1.4 버전의 코드를 기준으로 작성하였습니다. 해당 취약점을 통해 Valkey 서버의 소켓 포이즈닝을 유발하여 데이터를 변조할 수 있으며, DoS 공격 또한 가능합니다. 공격 방법은 Valkey의 `EVAL` 명령에 다음과 같은 Lua 스크립트를 실행하는 것입니다.  이것이 어떻게 취약점을 유발하는지 이해하려면 `RESP`, `Lua 스크립트 실행 흐름`, `redis.error_reply() 서버 API`, `error() 내장 함수` 등에 대한 이해가 필요합니다. 따라서 배경 지식을 먼저 설명한 뒤 취약점 분석 내용을 공유하겠습니다.
## RESP RESP(Redis Serialization Protocol)는 Valkey, Redis 계열에서 클라이언트↔서버가 명령/응답을 주고받을 때 사용하는 직렬화 프로토콜입니다. 타입 접두어(prefix) + CRLF(`\r\n`)로 구분하고, Bulk는 길이를 명시하여 사용합니다. 대표 타입(Simple String, Error, Integer, Bulk String, Array)과 사용 예시는 다음과 같습니다. ### Simple String(성공 메시지) * 프로토콜: `+` * 예시: `+OK\r\n`, `+PONG\r\n` ### Error(실패 사유) * 프로토콜: `-` * 예시: `-ERR unknown command 'HELLO'\r\n` ### Integer(정수 반환 - 카운트/증가 결과 등) * 프로토콜: `:` * 예시: `:0\r\n`, `:123\r\n` ### Bulk String(길이+데이터 - 값/바이너리) * 프로토콜: `$` * 예시: `$5\r\nhello\r\n`, `$-1\r\n` (null), `$0\r\n\r\n` (빈 값) ### Array(여러 RESP 값의 리스트 - 명령도 보통 이 형태) * 프로토콜: `*` * 예시: `*0\r\n`, `*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n`, `*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n`
## Lua 스크립트 실행 흐름 Valkey에서 `EVAL` 명령으로 Lua 스크립트를 실행하면 다음과 같은 흐름으로 처리됩니다. ```text EVAL 명령 → luaCallFunction() → Lua 스크립트 실행 → (오류 발생 시) → 오류 처리 → 클라이언트 응답 ``` ### luaCallFunction() `luaCallFunction()`은 Lua 스크립트를 실행하고, 실행 결과나 오류를 처리하는 핵심 함수입니다.  이 함수에서 중요한 점은 다음과 같습니다. 1. `lua_pcall()`로 Lua 스크립트를 실행 2. 오류 발생 시 `luaExtractErrorInformation()`으로 오류 정보 추출 3. 오류 타입에 따라 `luaReplyToRedisReply()` 또는 직접 오류 응답 전송 오류가 발생했을 때, 오류 메시지가 어떻게 만들어지고 클라이언트에게 전송되는지가 이 취약점의 핵심입니다. ## redis.error\_reply() 서버 API 동작 방식 Valkey의 Lua 스크립트 엔진에서는 오류 발생 시 오류를 테이블 형태로 반환할 수 있는 기능을 제공합니다. `redis.error_reply()`는 이와 같은 오류 테이블을 커스텀하게 정의할 때 사용하는 API입니다.  위 Server API를 호출하면 C 함수 중 `luaRedisErrorReplyCommand()`가 호출됩니다. 이 함수는 `redis.error_reply` 파라미터의 개수와 String 여부를 판단하고, 만약 문자열에 `-`가 없으면 `-`를 추가로 붙인 뒤 `luaPushErrorBuff()` 함수에 `err_buff` 인자로 넘깁니다. RESP의 Error 프로토콜 양식이 `-`로 시작하기 때문입니다.
## error() 내장 함수 `error()`는 Lua의 내장 함수로, 스크립트 실행을 즉시 중단하고 오류를 발생시킵니다. Valkey에서 Lua 스크립트가 `error()`를 호출하면, 인자로 전달된 값이 클라이언트에게 오류 응답으로 전송됩니다.  여기서 핵심은 `error()`의 인자로 `redis.error_reply()`가 반환한 오류 테이블을 전달할 수 있다는 점입니다. 이 경우 오류 테이블의 `err` 필드 값이 그대로 RESP Error 응답으로 클라이언트에게 전송됩니다. 공격 관점에서의 흐름은 다음과 같습니다. 1. `redis.error_reply(...)` → 악성 페이로드가 포함된 오류 테이블 생성 2. `error(오류 테이블)` → 스크립트 중단 및 오류 응답 전송 트리거 3. 서버 → 클라이언트로 오류 메시지 전송(이 과정에서 취약점 발생) 즉, `error()`는 `redis.error_reply()`로 만든 악성 페이로드를 실제로 클라이언트에게 전송하는 트리거 역할을 합니다. 다음 그림은 `luaCallFunction()` 함수의 일부이며, `error()`에 의해 발생한 오류 값이 `lua_pcall()`에 의해 캡처되는 모습입니다.  ## luaPushErrorBuff() 함수 다음 그림은 `luaPushErrorBuff()` 함수의 일부입니다. 입력으로 들어온 `err_buff`로부터 error code를 분리한 뒤, `\r\n`를 `sdstrim()`한 후 `final_msg`로 만들어 오류 테이블의 `err` 필드를 채웁니다.  여기서 중요한 부분은 `sdstrim()` 함수에 의해 취약점이 발생한다는 점입니다. `sdstrim(msg, "\r\n")` 함수는 `msg` 문자열의 양 끝에서 `\r`, `\n`에 해당하는 문자가 있으면 이를 제거하는 함수입니다. 해당 함수의 문제점은 문자열 전체에서 `\r\n`를 제거하는 것이 아니라, 문자열 양 끝에서만 `\r\n`을 제거한다는 점입니다.
## 취약점: 소켓 포이즈닝을 통한 데이터 변조 취약한 오류 메시지가 실제로 클라이언트 버퍼에 어떻게 쓰여지는지 살펴보겠습니다. (여기서 말하는 '클라이언트 버퍼'는 서버가 각 클라이언트 연결별로 유지하는 서버 사이드 출력 버퍼, 즉 `c->buf`를 의미합니다.) ### 전체 흐름 ```text luaCallFunction() ↓ luaExtractErrorInformation() → err_info.msg = "X\r\n+FAKE" ↓ sdscatfmt(final_msg, "-%s", err_info.msg) → final_msg = "-X\r\n+FAKE" ↓ addReplyErrorSdsEx(c, final_msg, flags) ↓ addReplyErrorLength(c, err, sdslen(err)) ↓ addReplyProto(c, s, len) ← s = "-X\r\n+FAKE" addReplyProto(c, "\r\n", 2) ↓ _addReplyToBufferOrList(c, s, len) ↓ _addReplyToBuffer(c, s, len) ↓ memcpy(c->buf + c->bufpos, s, reply_len) ← 클라이언트 버퍼에 직접 쓰기 ``` ### 상세 분석 **1\. addReplyErrorSdsEx() - networking.c:706** 해당 코드 내에서 `addReplyErrorLength` 함수를 호출하면서 오류 메시지를 버퍼에 추가합니다.  이 함수는 `sdsmapchars()` 같은 sanitization 없이 오류 메시지를 그대로 전달합니다. **2\. addReplyErrorLength() - networking.c:565**  오류 메시지가 이미 `-`로 시작하면 그대로 전송합니다. `addReplyProto()` 함수를 호출하며 `s` 값을 버퍼에 넣기 위한 함수를 호출합니다. **3\. \_addReplyToBuffer() - networking.c:403**  최종적으로 `memcpy()`를 통해 클라이언트의 출력 버퍼(`c->buf`)에 데이터가 직접 기록됩니다. 따라서 공격자가 `error(redis.error_reply("X\r\n+FAKE"))`를 실행하면 버퍼 상태가 다음과 같이 채워집니다.  클라이언트는 `\r\n`을 RESP 메시지 경계로 인식하므로, 이를 두 개의 독립된 응답으로 파싱합니다. 1. `-X\r\n` → 첫 번째 응답(Error) 2. `+FAKE\r\n` → 두 번째 응답(Simple String) - 다음 명령의 응답으로 처리됨 따라서 이후 `PING` 명령을 보내면, 서버의 실제 응답(`+PONG\r\n`) 대신 버퍼에 남아있던 `+FAKE`가 반환됩니다. 이렇게 소켓 포이즈닝이 가능합니다.
## 취약점: DoS RESP 인젝션을 활용하면 클라이언트를 대기 상태로 만들어 서비스 거부 공격을 수행할 수 있습니다. RESP의 Bulk String 타입은 `$길이\r\n데이터\r\n` 형식으로, 클라이언트는 명시된 길이만큼 데이터를 수신할 때까지 대기합니다. 공격자가 거대한 길이를 주입하면  클라이언트 버퍼가 다음과 같이 채워집니다.  공격 시나리오는 대략 다음과 같습니다. 1. 첫 번째 응답 `-X\r\n`을 정상 처리(Error) 2. 두 번째 응답 `$999999999\r\n`을 파싱 3. 999,999,999 바이트의 데이터를 기다림 4. 데이터가 오지 않으므로 클라이언트는 비정상 동작 여러 커넥션에서 동시에 이 공격을 수행하면, 다음과 같은 메커니즘으로 인해 서버 레벨에서 DoS가 발생할 수 있습니다. * 모든 클라이언트 커넥션이 멈춤(hang) 상태 * 서버의 커넥션 슬롯 소진 * 새로운 클라이언트 접속 불가 → 서비스 전체 마비 ## 마치며 이 글에서는 RESP 인젝션 취약점의 원리와 이를 활용한 소켓 포이즈닝 및 DoS 공격 방법에 대해 알아보았습니다. 오류 메시지 경로에서 CRLF를 충분히 검증하지 않는다는 작은 허점이 데이터 변조와 서비스 마비로 이어질 수 있다는 점에서, 아무리 단순한 문자열 처리 로직이라도 보안 관점에서의 검증이 중요하다는 것을 다시 한번 느꼈습니다. 이 글이 Valkey나 Redis의 내부 동작에 관심이 있는 분들, 그리고 오픈소스 보안 취약점 연구에 도전해 보고 싶은 분들에게 도움이 되길 바랍니다. 긴 글 읽어 주셔서 감사합니다. [](https://www.nhncloud.com/kr)