CVE-2023-30861 분석
이 글의 독자 대상
-
서버 앞단(Nginx, CDN 등)에서 캐시 설정을 다루는 운영자, 개발자
-
앱 보안이나 서비스 안정성에 관심 있는 사람
-
Flask 같은 웹 프레임워크를 사용하는 개발자
Info
-
CVE-2023-30861이라는 Flask 취약점이 왜 위험한지 쉽게 이해할 수 있어요.
-
간단한 테스트 환경을 만들어서 문제를 직접 재현해볼 수 있어요.
-
Flask 내부 코드 변화를 보면서 문제가 생긴 이유를 알 수 있어요.
-
어떻게 막을 수 있는지 실제 대응 방법을 알 수 있어요.
-
쿠키를 캐싱할 때 주의할 점까지 생각해볼 수 있어요.
어느날 서비스를 이용하다가 아무 이유 없이 다른 사람의 계정으로 로그인 된 적이 있나요? RIDI 에서 CDN 캐시를 잘못 설정해서 개인정보가 유출된 사례(2023.03) 에서는 41분 동안 서비스 화면에서 다른 사람의 개인정보들이 보이는 오류가 발생했고, 지그재그에서는 페이지를 새로고침 할때마다 다른 사람으로 로그인(2023.11)되는 사례가 있었습니다.
이번 글은 Flask의 알려진 취약점인 CVE-2023-30861를 분석하여 해당 취약점이 어떻게 악용될 수 있는지 분석하고, 조치할 수 있는 방법을 제시합니다.
CVE란?
오픈소스에서 취약점을 발견하게 된다면 이를 효율적으로 관리하기 위해 붙여진 취약점 일련번호입니다. 번호는 CVE-발생연도-일련번호
로 구성되어 있습니다.
CVE-2023-30861
CVE-2023-30861은 CVSS 스코어1가 7.5인 High에 분류된 취약점 입니다. CVSS 스코어가 높을 수록 공격이 쉽고 치명적이죠.
해당 취약점은 Flask에서 세션을 생성할 때 session.permernent=True
옵션이 설정되어 있으면 매 요청마다 Set-Cookie를 응답합니다. 이때 Flask 앞에 캐싱 서버가 있을 경우 정책에 따라 세션키가 캐싱될 수 있는 취약점입니다.
세션키가 캐싱이 되면 처음에 봤던 RIDI와 지그재그 처럼 다른 사용자의 세션키를 의도와 다르게 가져갈 수 있게 되고 결국 다른 사용자의 개인정보와 아이디가 탈취될 수 있는 위험이 존재하게 됩니다.
취약점 발생 조건
CVE-2023-30861은 다음과 같은 환경일 때 발생하게 됩니다.
CVE-2023-30861 취약점 발생 조건
-
캐시 서버가 Set-Cookie를 캐싱할 것
-
flask 2.2.5 미만의 버전을 사용할 것
-
flask의 session 기능을 사용할 것
-
세션을 생성할 때 session.permernent = True로 설정할 것
-
세션을 참조(읽기, 수정)하지 않는 페이지가 존재할 것
취약점 테스트 환경 구축
취약점 발생 조건과 동일한 취약한 환경을 구축해서 테스트 해보겠습니다. 환경설정은 github에 구성했습니다.
⚠️ 주의 (CAUTION)
해당 환경은 Flask의 CVE-2023-30861 취약점을 재현하기 위한 목적의 PoC입니다.
절대로 인터넷에 노출된 환경에서 실행하는 걸 금지합니다.
외부 접근이 가능한 네트워크에 연결되면 실제 공격에 악용될 수 있습니다.
소스 분석
login()
id와 password가 같다면 session.permanent=True
옵션을 설정하고 생성된 session을 사용자에게 전달합니다.
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
if username in users and users[username] == password:
session.permanent = True
session['username'] = username
flash('로그인 성공!')
return jsonify({"username":username})
else:
flash('로그인 실패: 아이디 또는 비밀번호가 틀렸습니다.')
return jsonify({'message':'로그인실패'})
me()
로그인할 때 session
을 참조해 username을 읽어 사용자에게 반환합니다.
@app.route('/me')
def me():
if 'username' in session:
return jsonify({"username":session['username']})
return jsonify({'message':'로그인이 필요합니다'})
home()
고정 값인 "main page"응답이 나오게 됩니다. session은 참조하지 않습니다.
session.permernent
session.permernent
옵션을 활성화 하면 Flask는 세션을 일정시간마다 유지하게 됩니다. Flask는 세션을 유지하기 위해 매 요청마다 응답 값으로Set-Cookie
를 보내게 됩니다. 기본값은 False
입니다.
그렇다면 위의 예제에서 POST /login
의 응답 값이 어떻게 달라지는 확인해봅시다.
session.permernent = False
POST login
하게 되면 응답으로 Set-Cookie
에 생성된 세션키가 나오게 됩니다.
POST http://10.0.1.6:5000/login
Content-Type: application/x-www-form-urlencoded
username=admin&password=admin123
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:34:17 GMT
Content-Type: application/json
Content-Length: 21
Vary: Cookie
Set-Cookie: session=.eJzty0EKgCAQRuGr1L92IxGJV8mIycYKUqLJVXT3vEA3aPXgwXdjDDvJygLb36iuEkQWoYWh4PJkWu8yzcG47LvOVCW60WX50NYYHvWrX32oQWE8-IyUOBUdaBdWyMJnosiwoDluCc8LAlnLgw.aA7pKQ.W9lhwkIekV4R8ZFBRGme0DmKJok; HttpOnly; Path=/
Connection: close
{
"username": "admin"
}
session을 참조하는 GET /me
request는 session.permernent=False
일 경우 Vary: Cookie
만 설정되어 응답하게 됩니다.
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:39:00 GMT
Content-Type: application/json
Content-Length: 21
Vary: Cookie
Connection: close
{
"username": "admin"
}
session을 참조하지 않은 GET /
request는 session.permernent=False
일 경우 Vary: Cookie
를 설정하지 않습니다.
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:40:41 GMT
Content-Type: application/json
Content-Length: 24
Connection: close
{
"message": "main page"
}
session.permernent = True
해당 옵션을 설정하게 되면 세션 참조와 상관 없이 모든 요청에 Set-Cookie
가 붙게 됩니다.
POST /login
의 응답값 중 Set-Cookie
안에 Expires
가 추가 되었습니다.
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:47:05 GMT
Content-Type: application/json
Content-Length: 21
Vary: Cookie
Set-Cookie: session=.eJzty0EKgzAQRuGr2H-djRQxeJUmhDFOtNCEknFW4t3NIVy6evDgOxDSj2RjwfQ50O0tyCxCK8PA6WyH6JSWZJ3GcbRdS__u24ppeMGf5lGPulV5g_DnmqlwaXqvygYqXAtlxgRa8rfgvACLe9wK.aA7sKQ.56khpLGDhQPzFSNibX1j25IXOQ0; Expires=Thu, 29 May 2025 02:47:05 GMT; HttpOnly; Path=/
Connection: close
{
"username": "admin"
}
session
을 참조하는 GET /me
request엔 Vary:Cookie
와 함께 Set-Cookie
가 응답값에 추가되었습니다.
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:47:53 GMT
Content-Type: application/json
Content-Length: 21
Vary: Cookie
Set-Cookie: session=.eJzty0EKgzAQRuGr2H-djRQxeJUmhDFOtNCEknFW4t3NIVy6evDgOxDSj2RjwfQ50O0tyCxCK8PA6WyH6JSWZJ3GcbRdS__u24ppeMGf5lGPulV5g_DnmqlwaXqvygYqXAtlxgRa8rfgvACLe9wK.aA7sWQ.VmteAUgHLXP7Bx-K7Jeefweo99w; Expires=Thu, 29 May 2025 02:47:53 GMT; HttpOnly; Path=/
Connection: close
{
"username": "admin"
}
세션을 참조하지 않는 GET /
request에선 Set-Cookie
만 추가되었습니다. 이때 Vary: Cookie
를 추가하지 않았기 때문에 세션키 캐싱 취약점이 발생하게 됩니다.
HTTP/1.1 200 OK
Server: Werkzeug/2.2.2 Python/3.10.12
Date: Mon, 28 Apr 2025 02:48:43 GMT
Content-Type: application/json
Content-Length: 24
Set-Cookie: session=.eJzty0EKgzAQRuGr2H-djRQxeJUmhDFOtNCEknFW4t3NNQRXDx58B0L6kWwsmD4Hur0FmUVoZRg4ne0QndKSrNM4jrZr6d99WzENL_jTPOpRt1XeIPy5Zipcmt6rsoEK10KZMYGW_C04L9ee_a4.aA7siw.hGLyxHM115s6ytG6rd0UsACH9SA; Expires=Thu, 29 May 2025 02:48:43 GMT; HttpOnly; Path=/
Connection: close
{
"message": "main page"
}
Vary: Cookie가 무엇이길래..
http response에 붙는 Vary 헤더는 캐싱을 할 때 HTTP 요청에 포함된 헤더를 대조한다고 합니다.
Vary
HTTP 응답 헤더는 원 서버로부터 새로운 리소스를 요청해야 하는지 캐시된 응답이 사용될 수 있는지를 결정하기 위해 이후의 요청 헤더를 대조하는 방식을 결정합니다.캐시가
Vary
헤더 필드를 지닌 요청을 수신한 경우,Vary
헤더에 의해 지정된 모든 헤더 필드들이 원래의 (캐시된) 요청과 새로운 요청 사이에서 일치하지 않는다면 그 캐시된 응답을 사용해서는 안 됩니다.
따라서 Vary: Cookie
가 붙은 response를 받은 캐시 서버는 사용자의 http 요청 내의 Cookie
에 설정된 값에 따라 캐싱을 다르게 하게 됩니다.
캐싱 서버 구축
아래와 같이 웹 페이지를 캐싱하도록 인프라를 구축해보겠습니다.
Nginx 설정
캐싱과 프록시를 설정하기 위해 nginx를 설정합니다. Nginx는 기본적으로 Set-Cookie를 캐싱하지 않으므로 proxy_ignore_headers
를 설정하여 캐싱하도록 구성했습니다.
또한 캐시 서버에서 전달된 응답 값인지 WAS에서 전달한 응답 값인지 확인하기 위해. add_header X-Cache-Status $upstream_cache_status;
를 설정했습니다.
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=mycache:10m inactive=10m use_temp_path=off;
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://cve-2023-30861-poc:5000;
proxy_cache mycache;
proxy_cache_key $scheme$host$request_uri;
proxy_cache_valid 200 10m;
proxy_ignore_headers Set-Cookie; # dangerous
add_header X-Cache-Status $upstream_cache_status;
}
}
Cookie 캐싱하기
로그인 후 Vary: Cookie
를 설정한 /me 요청을 몇 번 요청하게 되면 X-Cache-Status
가 HIT
상태가가 되며, 이는 nginx에서 해당 내용을 캐싱 되었다는 것을 알 수 있습니다.
HTTP/1.1 200 OK
Server: nginx/1.25.5
Date: Mon, 28 Apr 2025 03:17:55 GMT
Content-Type: application/json
Content-Length: 21
Connection: close
Vary: Cookie
Set-Cookie: session=.eJzty0EKgzAQRuGr2H-djRQxeJUmhDFOtNCEknFW4t3NPXT14MF3IKQfycaC6XOg21uQWYRWhoHT2Q7RKS3JOo3jaLuW_t23FdPwgj_Nox51M-UNwp9rpsKl6b0qG6hwLZQZE2jJ34LzAtB0Do8.aA7zWw.m6l70hHPnSrjMR2zIUHxzSqBi7c; Expires=Thu, 29 May 2025 03:17:47 GMT; HttpOnly; Path=/
X-Cache-Status: HIT
{
"username": "admin"
}
새로운 브라우저를 띄워 GET /me
를 요청하면 어떻게 될까요? nginx에 Set-Cookie
가 캐싱되어 있어서 그대로 사용자에게 전달되지 않을까요?
그렇지 않습니다. 왜냐하면 Flask가 Vary: Cookie
를 응답 값에 넣었기 때문에 전달하는 Cookie값이 일치하지 않으면 캐시에 접근할 수 없기 때문입니다.
Vary:Cookie가 없다면?
이제 쿠키를 가진 상태에서 /
를 요청 하면 Set-Cookie
필드와 함께 응답이 캐시에 저장됩니다.
HTTP/1.1 200 OK
Server: nginx/1.25.5
Date: Mon, 28 Apr 2025 03:29:44 GMT
Content-Type: application/json
Content-Length: 24
Connection: close
Set-Cookie: session=.eJztyzEKhDAQRuGr6F-nkUUMXmUjMsaJLpiwZJxKvLs5hkWqBw--C3M4SHYWjN8LzVmCyCK0MQycLrb3TmkN1qkfBtuUdJ-uLB_6FtNtqqqqqpepyWD-c46UOBV9ZmUDFc6JImMErfGXcD8GCEEF.aA72Jw.faLD0-YoHdtdjGksP9cGL3WOJwk; Expires=Thu, 29 May 2025 03:29:43 GMT; HttpOnly; Path=/
X-Cache-Status: HIT
{
"message": "main page"
}
해당 응답값에는 Vary:Cookie
가 존재하지 않습니다. 따라서 다른 브라우저에서 다른 사용자의 쿠키값이 아래와 같이 노출됩니다.
캐시서버에 저장되어 있는 쿠키 값을 가져온 뒤 GET /me
를 요청하면 다른 사용자 정보에 접근할 수 있게 됩니다.
원인 분석
해당 취약점은 Flask 2.2.5부터 해결되었습니다. Flask 개발자는 어떻게 문제를 해결했는지 살펴봅시다.
발생 위치는 sesions.save_session
함수이며 기존 로직에서 Vary: Cookie
를 설정하기 전 빠져 나갔기 때문에 취약점이 발생한 것으로 보입니다.
취약한 곳이 어디였는지 명확하게 판단하기 위해 디버깅해보겠습니다.
세션을 참조하지 않는 /
요청 시 save_session
385 line에서 session.accessed
가 False
가 되어 Vary:Cookie
를 설정하지 않습니다.
session을 읽거나 수정하지 않으면 access가 True가 되지 않도록 비즈니스 규칙에 정의되었기 때문입니다.
SecureCookieSession 클래스를 살펴보면 CallbackDict
를 상속받아 get, set을 할 때 accessed가 True가 되도록 오버라이딩 하고 있습니다.
따라서 session을 참조할 때 session.accessed가 True가 되며 Vary: Cookie
가 설정되는 동작 원리를 알게 됐습니다.
해당 취약점은 session.permernent=True
일 때 session 참조와 상관 없이 Set-Cookie
가 전송되었기 때문에 발생하게 된 것이었습니다.
class SecureCookieSession(CallbackDict, SessionMixin):
"""Base class for sessions based on signed cookies.
This session backend will set the :attr:`modified` and
:attr:`accessed` attributes. It cannot reliably track whether a
session is new (vs. empty), so :attr:`new` remains hard coded to
``False``.
"""
#: When data is changed, this is set to ``True``. Only the session
#: dictionary itself is tracked; if the session contains mutable
#: data (for example a nested dict) then this must be set to
#: ``True`` manually when modifying that data. The session cookie
#: will only be written to the response if this is ``True``.
modified = False
#: When data is read or written, this is set to ``True``. Used by
# :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie``
#: header, which allows caching proxies to cache different pages for
#: different users.
accessed = False
def __init__(self, initial: t.Any = None) -> None:
def on_update(self) -> None:
self.modified = True
self.accessed = True
super().__init__(initial, on_update)
def __getitem__(self, key: str) -> t.Any:
self.accessed = True
return super().__getitem__(key)
def get(self, key: str, default: t.Any = None) -> t.Any:
self.accessed = True
return super().get(key, default)
def setdefault(self, key: str, default: t.Any = None) -> t.Any:
self.accessed = True
return super().setdefault(key, default)
정리하자면 해당 취약점은 아래와 같이 발생하게 된 것입니다.
취약점 정리
-
세션을 참조(읽거나 수정)하면 session.accessed = True 상태가 되므로 Vary:Cookie 가 설정되어 캐싱을 해도 공유가 되지 않음
-
그러나 세션을 전혀 참조하지 않았을 때는 session.accessed가 False인 상태로 남아 있으므로 save_session에서 Set-Cookie는 설정하나, Vary: Cookie는 설정되지 않음 <-- 취약점 발생
-
따라서 2.2.5 버전에서는 참조 여부와 상관 없이 save_session 마지막에 Vary: Cookie를 강제로 추가하도록 패치됨
수정된 버전의 소스를 살펴보면 개발자는 패치만 적용할 뿐만 아니라 테스트 코드까지 작성하여 재발방지까지 하고 있는 것으로 보입니다.
이 부분은 취약점 조치를 어느 범위까지 테스트했는지 알 수 있었습니다. /ignore 컨트롤러를 따로 만들어 Vary: Cookie
가 설정된 것까지만 확인하고 있습니다.
마치며
분석 이전엔 CVE 내용만 보고 Flask에만 있는 마이너한 취약점이라고 생각했으나, 캐시를 잘못 설정하면 발생할 수 있는 시나리오를 잘 반영해주는 사례라 분석할 가치가 있다고 판단했습니다
Cookie를 캐싱하는 경우가 과연 있을지 찾아보다가 보통 광고 추적, A/B 테스트, 사용자 통계를 낼 때 값이 고정되어 있는 쿠키를 이용한다고 합니다.
또한 CDN내에서 공통으로 사용하는 전역 쿠키를 캐싱하여 시스템 성능을 향상시키기 위해 캐싱하다고 합니다.
위의 사례에서 보듯 꼭 필요하지 않은 경우에는 사용자 통계는 쿠키를 사용하지 않는 방향으로 가는것이 적절해 보이긴 합니다.
이때 추적을 위해 Set-Cookie
를 캐싱하게되면 성능은 올릴 수 있어도 위와 같은 취약점이 발생할 수 있으니 난감하네요.
만일 캐시 서버를 통해 쿠키를 캐싱하는 구조일 경우 쿠키가 사용되는 범위를 고려해야 할 필요가 있겠습니다.
flask 에서 session.permernent
를 사용하지 않고 세션을 커스텀 헤더를 통해 전송하는 방안도 검토할 수 있겠습니다. 커스텀 헤더를 선택하게 된다면 Cookie
가 제공하는 기본 보안 옵션인 httpOnly, SameSite, Secure
를 포기할 수 있는가를 생각해봐야겠습니다.
해당 옵션이 존재하지 않으면 XSS, CSRF, 암호화 되지 않은 통신이 있는지 주기적으로 점검해야하기 때문에 개발자, 운영자의 관리 포인트가 증가하기 때문입니다.
기술엔 은총알이 없다고 합니다. 비록 현업을 깊게 경험하진 않았지만, 여러 상황을 따져가며 최적의 조치방안을 제시하는것을 이 글을 작성하면서 간접적으로 체험할 수 있었습니다.
더 좋은 해결책이 있거나 사례를 발견한다면 다시 한 번 공유하도록 하겠습니다.
잘못된 부분이나 잘 이해되지 않은 부분이 있다면 피드백 부탁드립니다. 읽어 주셔서 감사합니다.
-
CVSS란 취약점의 심각도를 수치화하기 위한 국제 표준입니다. ↩