grep

Engineering

Redis와 Valkey를 해킹해 보자(Feat. CVE ID)

NHN

2026년 7월 6일

원문에서 보기 ↗

NHN Cloud_meetup banner_cve_202607_900.png

들어가며

안녕하세요. NHN Cloud NoSQL개발파트 이준영입니다.

여러분은 CVE(Common Vulnerabilities and Exposures)에 대해 들어보셨나요? CVE란 공개적으로 알려진 소프트웨어 및 하드웨어의 보안 취약점을 식별하고 명명하기 위한 표준화된 체계입니다. 각 취약점은 CVE ID라는 고유 식별자를 부여받으며, 이는 전 세계 보안 커뮤니티가 동일한 취약점을 정확하고 일관되게 추적하고 논의할 수 있도록 돕습니다.

저는 작년에 개인 프로젝트로 Valkey와 Redis의 취약점을 연구하였는데요. 운이 좋게 CVE ID까지 발급받게 되었습니다. 이번 글에서는 해당 취약점의 분석 내용을 공유하고자 합니다.

CVE 정보

구분세부 내용
CVEhttps://nvd.nist.gov/vuln/detail/CVE-2025-67733
PoChttps://github.com/JYlab/CVE-2025-67733
GitHub Security Advisoryhttps://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
CVSSCVSS: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 스크립트를 실행하는 것입니다.

NHN Cloud_meetup_data-forgery_1.png

이것이 어떻게 취약점을 유발하는지 이해하려면 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(성공 메시지)

Error(실패 사유)

Integer(정수 반환 - 카운트/증가 결과 등)

Bulk String(길이+데이터 - 값/바이너리)

Array(여러 RESP 값의 리스트 - 명령도 보통 이 형태)

Lua 스크립트 실행 흐름

Valkey에서 EVAL 명령으로 Lua 스크립트를 실행하면 다음과 같은 흐름으로 처리됩니다.

EVAL 명령 → luaCallFunction() → Lua 스크립트 실행 → (오류 발생 시) → 오류 처리 → 클라이언트 응답

luaCallFunction()

luaCallFunction()은 Lua 스크립트를 실행하고, 실행 결과나 오류를 처리하는 핵심 함수입니다.

NHN Cloud_meetup_luaCallFunction_1.png

이 함수에서 중요한 점은 다음과 같습니다.

  1. lua_pcall()로 Lua 스크립트를 실행
  2. 오류 발생 시 luaExtractErrorInformation()으로 오류 정보 추출
  3. 오류 타입에 따라 luaReplyToRedisReply() 또는 직접 오류 응답 전송

오류가 발생했을 때, 오류 메시지가 어떻게 만들어지고 클라이언트에게 전송되는지가 이 취약점의 핵심입니다.

redis.error_reply() 서버 API 동작 방식

Valkey의 Lua 스크립트 엔진에서는 오류 발생 시 오류를 테이블 형태로 반환할 수 있는 기능을 제공합니다. redis.error_reply()는 이와 같은 오류 테이블을 커스텀하게 정의할 때 사용하는 API입니다.

NHN Cloud_meetup_luaRedisErrorReplyCommand_1.png

위 Server API를 호출하면 C 함수 중 luaRedisErrorReplyCommand()가 호출됩니다. 이 함수는 redis.error_reply 파라미터의 개수와 String 여부를 판단하고, 만약 문자열에 -가 없으면 -를 추가로 붙인 뒤 luaPushErrorBuff() 함수에 err_buff 인자로 넘깁니다. RESP의 Error 프로토콜 양식이 -로 시작하기 때문입니다.

error() 내장 함수

error()는 Lua의 내장 함수로, 스크립트 실행을 즉시 중단하고 오류를 발생시킵니다. Valkey에서 Lua 스크립트가 error()를 호출하면, 인자로 전달된 값이 클라이언트에게 오류 응답으로 전송됩니다.

NHN Cloud_meetup_ex_error_1.png

여기서 핵심은 error()의 인자로 redis.error_reply()가 반환한 오류 테이블을 전달할 수 있다는 점입니다. 이 경우 오류 테이블의 err 필드 값이 그대로 RESP Error 응답으로 클라이언트에게 전송됩니다.

공격 관점에서의 흐름은 다음과 같습니다.

  1. redis.error_reply(...) → 악성 페이로드가 포함된 오류 테이블 생성
  2. error(오류 테이블) → 스크립트 중단 및 오류 응답 전송 트리거
  3. 서버 → 클라이언트로 오류 메시지 전송(이 과정에서 취약점 발생)

즉, error()는 redis.error_reply()로 만든 악성 페이로드를 실제로 클라이언트에게 전송하는 트리거 역할을 합니다.

다음 그림은 luaCallFunction() 함수의 일부이며, error()에 의해 발생한 오류 값이 lua_pcall()에 의해 캡처되는 모습입니다.

NHN Cloud_meetup_error_1.png

luaPushErrorBuff() 함수

다음 그림은 luaPushErrorBuff() 함수의 일부입니다. 입력으로 들어온 err_buff로부터 error code를 분리한 뒤, \r\n를 sdstrim()한 후 final_msg로 만들어 오류 테이블의 err 필드를 채웁니다.

NHN Cloud_meetup_luaPushErrorBuff_1.png

여기서 중요한 부분은 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 함수를 호출하면서 오류 메시지를 버퍼에 추가합니다. NHN Cloud_meetup_addReplyErrorSdsEx_1.png

이 함수는 sdsmapchars() 같은 sanitization 없이 오류 메시지를 그대로 전달합니다.

2. addReplyErrorLength() - networking.c:565 NHN Cloud_meetup_addReplyErrorLength_1.png

오류 메시지가 이미 -로 시작하면 그대로 전송합니다. addReplyProto() 함수를 호출하며 s 값을 버퍼에 넣기 위한 함수를 호출합니다.

3. _addReplyToBuffer() - networking.c:403 NHN Cloud_meetup_addReplyToBuffer_1.png

최종적으로 memcpy()를 통해 클라이언트의 출력 버퍼(c->buf)에 데이터가 직접 기록됩니다.

따라서 공격자가 error(redis.error_reply("X\r\n+FAKE"))를 실행하면 버퍼 상태가 다음과 같이 채워집니다. NHN Cloud_meetup_client_buffer_1.png

클라이언트는 \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 형식으로, 클라이언트는 명시된 길이만큼 데이터를 수신할 때까지 대기합니다.

공격자가 거대한 길이를 주입하면 NHN Cloud_meetup_dos_1.png

클라이언트 버퍼가 다음과 같이 채워집니다. NHN Cloud_meetup_client_buffer_2.png

공격 시나리오는 대략 다음과 같습니다.

  1. 첫 번째 응답 -X\r\n을 정상 처리(Error)
  2. 두 번째 응답 $999999999\r\n을 파싱
  3. 999,999,999 바이트의 데이터를 기다림
  4. 데이터가 오지 않으므로 클라이언트는 비정상 동작

여러 커넥션에서 동시에 이 공격을 수행하면, 다음과 같은 메커니즘으로 인해 서버 레벨에서 DoS가 발생할 수 있습니다.

마치며

이 글에서는 RESP 인젝션 취약점의 원리와 이를 활용한 소켓 포이즈닝 및 DoS 공격 방법에 대해 알아보았습니다. 오류 메시지 경로에서 CRLF를 충분히 검증하지 않는다는 작은 허점이 데이터 변조와 서비스 마비로 이어질 수 있다는 점에서, 아무리 단순한 문자열 처리 로직이라도 보안 관점에서의 검증이 중요하다는 것을 다시 한번 느꼈습니다.

이 글이 Valkey나 Redis의 내부 동작에 관심이 있는 분들, 그리고 오픈소스 보안 취약점 연구에 도전해 보고 싶은 분들에게 도움이 되길 바랍니다. 긴 글 읽어 주셔서 감사합니다. 😀

NHN Cloud_meetup banner_footer_202507-01.png