웹서버(Web server)와 웹애플리케이션(WAS) 요청바디(request body) overflow 공격은 어떻게 대응할까? (고찰 포스팅)
머리말
최근 was 미들웨어 구간에 http request body 디버깅용 로깅을 구상하고 있었는데,
생각해보니 http body는 사실상 데이터 크기의 제한을 두지 않는 것으로 알고 있는데, 이 데이터를 미들웨어에서 계속 로깅을 위해 열어보게되면.. 악의적인 공격에 메모리가 가득차버리는게 아닌지? 이를 해결하려면 어떻게 해야될지 궁금증이 들었다..ㅎㅎ! (갑작스러운 띠용~)
대부분의 운영용 웹서버(아파치, nginx, envoy ...)는 요청 Body 크기를 제한하는 옵션을 갖고 있다.
이런 운영용 웹서버를 띄우는 컴퓨팅 리소스는 웹서버 트래픽 워크로드만 고려하기 때문에 웬만한 데이터량은 어차피 아주 잘 처리할 것이다.
문제는 이런 전용 웹서버들은 아주 잘 처리하지만, 비즈니스로직을 처리해야되는 WAS는 메모리가 모자를 수 있다..!
베스트인 상황은 웹서버에서 미리 백단의 WAS에 무리 없도록 적절히 필터링하는 것이지만, 정상적인 요청들까지 잘릴 수 있는 문제가 있다
어느정도는 WAS에서 직접 해결하는 솔루션이 필요할텐데, 어떻게 구현하면 좋을지 가설을 세워보기로 했다
(귀찮아서 사례를 찾거나 공부하진 않고.. 고찰 위주로 작성합니다 ㅠ)
WAS가 body를 메모리에 적재(load)하는 방식
보통 웹서비스 가장 앞(front)단에 전문 웹서버를 배치하지만, WAS 자체도 http를 수신할 웹서버는 필요하다.
sprint은 톰캣, fastapi(starlette)은 uvicorn(혹은 asgi 웹서버), flask는 gunicorn(혹은 wsgi 웹서버) 등등
was는 일종의 was 전용 웹서버에 연결된 이벤트 헨들러 서브루틴 모음집과 같다.
마치 CPU trap 명령어에 커널 시스템콜들이 매핑되어있는 구조와 유사할 것 같다.
WAS 전용 웹서버의 동작 => method + url 입력 => WAS 코드의 어떤 함수를 호출하자!
톰캣 <-> 스프링은 어떻게 동작하는지 모르겠지만, 파이썬 웹 생태계의 asgi/wsgi(인터페이스)는 was가 asgi/wsgi를 따르는 웹서버와 통신하는 규약이 정해져있다.
FastAPI, uvicorn 생태계만 잘아니 이 기준으로 설명하겠다.
베이스 프로세스는 uvicorn(asgi) 웹서버이다. (i.e, 최초로 구동되는 프로세스가 uvicorn)
ASGI 웹서버는 실행할 WAS 인스턴스(객체)를 인자로 받아 실행된다. 이때 WAS 인스턴스는 asgi 규약에 따라 함수처럼 호출될 수 있는 형태면서, 정해진 매개변수를 받을 수 있어야한다.
예시로 WAS 인스턴스 "app"을 생성하는 main.py 파일이 있다고해보자
# main.py
from fastapi import FastAPI
app_instance_hello = FastAPI()
uvicorn은 아래와 같은 방식으로 main.py 모듈을 가져와서 app 인스턴스를 포팅한다.
uvicorn main:app_instance_hello
그리고 uvicorn은 웹서버로서 TCP 특정 포트를 listen하는 상태로 구동되며, http 패킷이 들어올 때마다 아래와 같은 형식으로 asgi was 인스턴스를 함수처럼 호출한다.
app_instance_hello(scope, receive, send)
* scope는 http의 대략적인 메타데이터이다. 헤더와 host, client 정보들이 들어있는 것으로 알고있다
* send, receive는 uvicorn에서 WAS에게 빌려주는 데이터 통신 단말기(interface)와도 같다.
* 이는 파이썬이 함수를 일급개체로 다루는 언어이기 때문에 가능하며, send, receive도 ASGI 규약에 따라 호출할 수 있는 함수이다.
* 쉽게 이해하면 send는 client에게 데이터를 보내는 함수, receive는 데이터를 받는 함수이다.
FastAPI, Starlette 코드를 까보면, 특정 layer부터 scope, receive, send를 Request라는 객체로 추상화해서 사용하고
request 객체는 body를 적재할 때, receive 함수를 호출해서 가져온다는 것을 알 수 있따.
여기서 재밌는 점은 Request 객체의 동작 방식이다.
fastapi, starlette는 여러 계층으로 데이터가 흐르면서 raw한 데이터로서 request를 객체를 사용하는데, body를 한 번 호출한 후 캐싱하고 있다.
이는 성능적으로 그럴 수 있다고 생각할 수 있지만, 실상은 receive 함수는 uvicorn 웹서버가 실제로 body에 담긴 TCP 패킷을 수신하는 로직과 대응된다는 사실이다.
즉, 한 번 수신한 stream을 다시 uvicorn에게 요청할 수 없기 때문에 캐싱해야한다.
이 동작으로 여러 약점들을 유추할 수 있을 것이다.
1. request 객체를 잃어버리면 다시 body를 찾을 수 없다
=> 실제로 FastAPI는 미들웨어 계층과 route 계층간 request 객체를 공유하지 않는다.
=> 미들웨어에서 route 계층으로 넘어 갈 때, scope, receive, send를 기반으로 다음 계층의 ASGI 인스턴스를 실행한다.
=> 이는 미들웨어 계층에서 body를 수신해버릴 경우, 그 다음 계층들 부터는 body를 찾을 수 없는 문제가 발생한다.
2. body를 한 번에 모두 메모리에 적재한 이후 비즈니스 로직이 실행된다.
=> 코드를 보다시피 body를 부르는 순간 모든 데이터를 수신한다. (웹소켓이나 미디어 타입에 따라 다를 수 있지만?)
=> 이는 body 크기가 매우크고 이보다 시스템 메모리가 작다면 http를 수신하자마자 OOM으로 시스템이 죽을 것이다
Body Buffer overflow 공격은 어떻게 방어할 수 있을까?
위 단락의 2번 약점을 어떻게 막을 수 있을지가 이번 포스팅에서 궁금한 사항이다..!
body를 몇 메가 수준으로 채워서 다량의 client에서 공격한다면 분명 WAS가 뻗을 수 있다.
최전선의 L7 웹서버에서 body 크기와 rate limit 등을 통해 방어해줄 수도 있지만, was에서 처리해야되는 부분도 분명 있을 것이다.
예로, 실제 API 설계상 다량의 body 데이터를 받을 수 있다면 이를 무작정 웹서버에서 자를 수 있게 할 수도 없다.
위에서 body를 메모리에 적재하는 방식을 본다면, WAS 자체도 실제 비즈니스로직을 시작하기전 lazy하게 body를 읽어온다는 것을 알 수 있다.
당연하게도 최대 크기가 고정된 header와 달리, 이론상 크기가 무한할 수 있는 body를 웹서에서 was로 넘어올 때부터 즉각 모두 받는 다는 것은 언매치한 설계이다.
아주 좋은 방법은! header를 먼저 불러와 인증처리를 수행하고, 인증된 유저인 경우만 body를 후속으로 읽는 것이다.
=> 이때, 머리가 띵했다
=> 왜 인증을 헤더에 하는지!!
(물론, body가 필요없는 메소드도 있고 http 포맷에 항상 header는 존재하고, 일관성 등을 고려해서 헤더가 표준일 것이다.)
아름답게도 이는 실제로 가능한 시나리오로 보인다.
---
위 사진은 FastAPI의 Route 계층에서 endpoint 함수를 실행하는 과정이다.
보다시피 의존성 함수들을 먼저 실행하고, endpoint 함수를 최종 실행하는걸 볼 수 있다.
values가 아마 pydantic 기반 schema 유효성 검증이 모두 끝난후 전달하는 값일 것이다.
FastAPI의 depend 기능을 사용해서 인증을 구현한다면, 인증 함수에서 header만 열어보고 raise한다면 분명 body까지 열어보지 않고 tcp 연결을 끊을 수 있을 것이다!!
문제
안타깝게도 FastAPI에서 기본으로 제공하고 있는 request handler는 의존성 함수를 처리하기전에 body를 먼저 까보고 있다.
FastAPI의 의존성들을 모두 해결하고나면, request 매개변수들을 endpoint 함수 매개변수로 변환하는 고정된 로직이 존재하는데 이때 body 또한 변환하는데 사용하기 위함으로 보인다.
아쉬운 점은 빨간색 사각형 안에 body 코드를 request.body()로 변형했다면 좋지 않았을까 싶다.
마지막에 body를 검증하고 매개변수로 변환하는 과정에서 body를 모두 메모리에 적재하는건 필연적이다.
하지만, request.body()로 변경해서 최대한 body 적재를 lazy하게 수행하고 앞선 의존성 함수중 인증 로직이 먼저 수행되서 연결을 끊었다면, 이번 포스팅에서 의도한 동작을 쉽게 구현하지 않았을까 싶다..
현재 FastAPI에서 이를 해결하는 방법은 Route 클래스에서 route handler 함수를 오버라이딩하고 내부 request handler와 solve_dependencies 함수를 다시 구현하는 방법이 그나마 간단해보인다.
FastAPI 깃이슈에 올려야되나..ㅎㅎ..
사실 body overflow 공격은 대부분 웹서버에서 body 크기 제한, rate limit 등을 겸해서 WAS까지 도달하는 트래픽을 제한하는 형태로 관리하는게 가장 편리하고, 현실적이라고 생각한다.
만약 WAS에서 이정도까지 조정해야되는 프로젝트라면 애초에 FastAPI는 사용하지 않았을거라고 생각한다 ㅎㅎㅎ!!
이런 귀여운 허점들이 애기애기한 프로젝트들의 단점이지 않을까 싶다