Engineering
Redis와 Valkey를 해킹해 보자(Feat. CVE ID)
2026년 7월 6일
원문에서 보기 ↗들어가며
안녕하세요. 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 |
| PoC | https://github.com/JYlab/CVE-2025-67733 |
| GitHub Security Advisory | 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 스크립트를 실행하면 다음과 같은 흐름으로 처리됩니다.
EVAL 명령 → luaCallFunction() → Lua 스크립트 실행 → (오류 발생 시) → 오류 처리 → 클라이언트 응답
luaCallFunction()
luaCallFunction()은 Lua 스크립트를 실행하고, 실행 결과나 오류를 처리하는 핵심 함수입니다.

이 함수에서 중요한 점은 다음과 같습니다.
lua_pcall()로 Lua 스크립트를 실행- 오류 발생 시
luaExtractErrorInformation()으로 오류 정보 추출 - 오류 타입에 따라
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 응답으로 클라이언트에게 전송됩니다.
공격 관점에서의 흐름은 다음과 같습니다.
redis.error_reply(...)→ 악성 페이로드가 포함된 오류 테이블 생성error(오류 테이블)→ 스크립트 중단 및 오류 응답 전송 트리거- 서버 → 클라이언트로 오류 메시지 전송(이 과정에서 취약점 발생)
즉, 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를 의미합니다.)
전체 흐름
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 메시지 경계로 인식하므로, 이를 두 개의 독립된 응답으로 파싱합니다.
-X\r\n→ 첫 번째 응답(Error)+FAKE\r\n→ 두 번째 응답(Simple String) - 다음 명령의 응답으로 처리됨
따라서 이후 PING 명령을 보내면, 서버의 실제 응답(+PONG\r\n) 대신 버퍼에 남아있던 +FAKE가 반환됩니다. 이렇게 소켓 포이즈닝이 가능합니다.
취약점: DoS
RESP 인젝션을 활용하면 클라이언트를 대기 상태로 만들어 서비스 거부 공격을 수행할 수 있습니다. RESP의 Bulk String 타입은 $길이\r\n데이터\r\n 형식으로, 클라이언트는 명시된 길이만큼 데이터를 수신할 때까지 대기합니다.
공격자가 거대한 길이를 주입하면 
클라이언트 버퍼가 다음과 같이 채워집니다. 
공격 시나리오는 대략 다음과 같습니다.
- 첫 번째 응답
-X\r\n을 정상 처리(Error) - 두 번째 응답
$999999999\r\n을 파싱 - 999,999,999 바이트의 데이터를 기다림
- 데이터가 오지 않으므로 클라이언트는 비정상 동작
여러 커넥션에서 동시에 이 공격을 수행하면, 다음과 같은 메커니즘으로 인해 서버 레벨에서 DoS가 발생할 수 있습니다.
- 모든 클라이언트 커넥션이 멈춤(hang) 상태
- 서버의 커넥션 슬롯 소진
- 새로운 클라이언트 접속 불가 → 서비스 전체 마비
마치며
이 글에서는 RESP 인젝션 취약점의 원리와 이를 활용한 소켓 포이즈닝 및 DoS 공격 방법에 대해 알아보았습니다. 오류 메시지 경로에서 CRLF를 충분히 검증하지 않는다는 작은 허점이 데이터 변조와 서비스 마비로 이어질 수 있다는 점에서, 아무리 단순한 문자열 처리 로직이라도 보안 관점에서의 검증이 중요하다는 것을 다시 한번 느꼈습니다.
이 글이 Valkey나 Redis의 내부 동작에 관심이 있는 분들, 그리고 오픈소스 보안 취약점 연구에 도전해 보고 싶은 분들에게 도움이 되길 바랍니다. 긴 글 읽어 주셔서 감사합니다. 😀

