AWS Elastic Beanstalk에서 운영하고 있는 Django의 Health Check와 AWS EC2 IMDS 관련 이슈 해결기, 그리고 IMDSv2

Seho Noh
벨루가 팀 블로그
16 min readMay 19, 2022

안녕하세요, 주류(酒, Liquor, 🍻) 스타트업 벨루가브루어리 개발자 노세호입니다.

저희가 만드는 벨루가 서비스(Veluga, https://www.veluga.kr)는 해외 주류 수입사, 국내 주류 생산자 <-> 주류 유통사 <-> 상점 <-> 일반 소비자 모두를 아우르는 주류 플랫폼을 만들고 있습니다.

주류 유통의 중심, 벨루가 비즈니스

벨루가 서비스의 API 서버는 현재 ‘AWS Elastic Beanstalk(이하 EB)’로 사용하고 있으며 구성의 간략한 도식은 아래와 같습니다.

벨루가 API 서버 인프라 구조 도식
벨루가 API 서버 인프라 구조 도식

벨루가 서비스는 벨루가를 이용하는 각 플레이어가 주류 산업에서 필요한 기능을 좀 더 편하게 사용할 수 있도록 지난 2021년 4월에 리뉴얼 출시를 진행했습니다. 그러면서 앞으로 서술할 문제에 대해 작업을 진행할 시점까지도 배포, 운영, 모니터링에 문제가 없었습니다. 그러다 한 사태가 벌어지기 시작합니다.

Log4j 보안 취약점 사태

2021년 12월 중순쯤 Java 진영에서 Log를 쌓을 때 사용되는 Apache 재단의 Log4j에 심각한 보안 취약점이 발견되었다는 뉴스가 업계를 떠들썩하게 만들었습니다.

Kisa에 올라온 Apache Log4j 보안 업데이트 권고
Kisa에 올라온 Apache Log4j 보안 업데이트 권고

인터넷 역사상 사상 초유의 보안 이슈로도 얘기될 정도로 심각한 사건이어서 저희 벨루가도 비록 주 언어로 파이썬을 사용하고 있긴 하지만 OS(Amazon Linux 2)에서 사용하는 소프트웨어에 딸린 패키지 의존성이나, JVM 기반의 Elasticsearch도 사용하고 있었기에 모든 서버군에 보안 점검 및 업데이트를 진행하려고 조사를 하고 차례대로 업데이트를 진행하던 중 문제가 발생합니다.

EB Health Status

EB에는 Application:Environment 별로 Health Check를 해서 현재 상태가 어떤지 요약해서 보여주는 Health Status 데이터가 있습니다. (서버 자원 사용량이 얼마나 많은지, 요청이 들어와서 반환한 API의 HTTP Status Code가 ≥ 400이 얼마나 많은지 등)

AWS Elastic Beanstalk 대시보드에 있는 Health Status (Ok)
AWS Elastic Beanstalk 대시보드에 있는 Health Status (Ok)
AWS Elastic Beanstalk Enhanced health overview에 있는 Ok Health Status
AWS Elastic Beanstalk Enhanced health overview에 있는 Ok Health Status

Health Status가 위 이미지처럼 Ok로 보이면 서버에 문제가 없다는 뜻이고, 아래 이미지처럼 Severe로 보이면 서버에 문제가 매우 매우 많다는 뜻입니다.

더 자세한 Health Status는 AWS 문서에서 확인하실 수 있습니다. https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/health-enhanced-status.html

AWS Elastic Beanstalk 대시보드에 있는 Health Status (Severe)
AWS Elastic Beanstalk 대시보드에 있는 Health Status (Severe)
AWS Elastic Beanstalk Enhanced health overview에 있는 Severe Health Status와 문제
AWS Elastic Beanstalk Enhanced health overview에 있는 Severe Health Status와 문제

EB에서는 Health Status가 특정 상태가 되면 EB의 설정을 바꾸는 데 많은 제약이 있습니다. 왜냐하면 현재 설정한 값/배포된 버전의 어플리케이션에서 새로 설정한 값/배포하는 버전의 어플리케이션과 비교했을 때 새 버전에 문제가 있다고 판단하고 서버를 다시 롤백시키기 때문입니다.

심지어 당시 엄청 큰 보안 이슈가 나왔다는 생각에 EB 플랫폼 버전을 업데이트하려고 했는데, 이 Severe 때문에 업데이트를 못 진행하고 있었습니다.

그나마 다행히도 어떤 문제가 있는지 안내해 줘서 문제점을 쉽게 파악할 수 있습니다. 다만 이제 어디에서 문제가 있는지 찾아야 하는데, 저희는 ‘Target.ResponseCodeMismatch’ 에러가 나왔기 때문에 관련 설정을 한 번 살펴보았습니다.

Health Check

위 ‘벨루가 API 서버 인프라 구조 도식’에서 보셨다시피 저희는 서버를 Application Load Balancer(이하 ALB)로 묶고, CPU 사용량에 따라 Auto Scaling을 설정해 서버가 필요한 만큼 띄우고 반납할 수 있도록 설정해둔 상태입니다. ALB에서 EC2의 상태를 확인하기 위해 서버의 로컬 IP에 지정한 health check path로 요청을 해서

  • 특정 path로
  • 일정 주기마다
  • n회의 요청을 했을 때
  • 지정한 HTTP status code를 받아야

ALB와 Auto Scaling에서 해당 서버가 정상적으로 작동하고 있다고 판단하고 Auto Scaling에서 설정한 서버의 Capacity만큼 관리하게 됩니다.

ALB Health Check 설정
ALB Health Check 설정 (위 설정값은 예시입니다.)

위 설정값을 보시면

  • Path / — (특정 path로)
  • Interval 15 seconds — (일정 주기마다)
  • Healthy threashold 3 requests — (n회의 요청을 성공해야)
  • HTTP code 200 — (지정한 HTTP status code를 받아야)

의 설정값이 지정돼있는걸 확인하실 수 있습니다.

현재까지의 내용을 한 번 정리해보자면

http://SERVER/
15초마다 요청해서
3번의 요청이
HTTP status code 200를 모두 반환

해야 서버를 healthy 상태로 판단하는 것인데, 위에서 EB Health Status가 Severe 상태로 보이는 이유는 HTTP status code가 200이 아닌 다른 값으로 반환되고 있다는 것입니다.

도대체 문제는 어디에?

우선 API의 Health Check 라우터 코드를 먼저 확인했습니다.

class SettingsViewSet(viewsets.ViewSet):
permission_classes = (AllowAny,)

@action(methods=['GET'], detail=False)
def health_check(self, request):
return Response(status=status.HTTP_200_OK)

전혀 문제 될 부분이 안 보였습니다. 다음으로 프레임워크의 설정을 확인했습니다.

벨루가의 API는 Django — Django REST Framework 구성으로 이루어져 있고, 보안을 위해 지정한 도메인이 아니면 CORS 에러가 나도록 Django.ALLOWED_HOSTSCORS_ORIGIN_REGEX_WHITELIST이 설정되어 있습니다.

# Django 설정
ALLOWED_HOSTS = [
'localhost',
'127.0.0.1',
]
# 환경마다 ALLOWED_HOSTS 도메인 추가
ALLOWED_HOSTS += [
'환경별 벨루가 도메인',
]
if AWS_LOCAL_IP:
ALLOWED_HOSTS += [AWS_LOCAL_IP]
# CORS 설정
from corsheaders.defaults import default_headers
CORS_ORIGIN_REGEX_WHITELIST = (
r'^(https?://)?([\w\.\-]+\.)?환경별 벨루가 도메인',
)
CORS_ALLOW_HEADERS = list(default_headers) + [
'허용할 HTTP Header'
]

Django.ALLOWED_HOSTS, CORS_ORIGIN_REGEX_WHITELIST도 문제가 되는 부분은 안 보입니다. 이미 이 코드로 200일 넘게 서비스를 운영하고 있었고, 무엇보다도 개발 환경의 API EB는 EB Health Status가 Ok였기 때문입니다.

if AWS_LOCAL_IP:
ALLOWED_HOSTS += [AWS_LOCAL_IP]

다시 한번 설정을 보니 ALB의 health check를 위해 AWS의 IMDS(인스턴스 메타데이터 서비스, Instance Metadata Service)를 이용해 ALLOWED_HOSTS에 로컬 IP를 추가하고 있었습니다.

IMDS

IMDS는 AWS EC2에서 인스턴스의 여러 내부 정보를 가져오기 위해 AWS 내부적으로 지원하는 서비스입니다. 간단하게는 http://169.254.169.254/latest/meta-data/API를 GET으로 호출해서 데이터를 가져올 수 있습니다.

예)

IMDS로 가져올 수 있는 데이터는 AWS 문서에서 확인하실 수 있습니다.
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-categories.html

그래서 저희는 위에 있는 AWS_LOCAL_IP코드를 아래와 같이 선언해서 사용하고 있었습니다.

def get_ec2_instance_local_ip():
"""
참고: https://stackoverflow.com/a/42535897/3743375
"""
import requests
try:
ip = requests.get(
'http://169.254.169.254/latest/meta-data/local-ipv4',
timeout=1.0
).text
except requests.exceptions.ConnectionError:
return None
return ip
AWS_LOCAL_IP = get_ec2_instance_ip()

환경별로 Django 설정에서AWS_LOCAL_IP를 호출하게 되면 EB 플랫폼 버전이 업데이트되거나 인스턴스가 교체되거나 새 어플리케이션이 배포됐을 때 ALOWED_HOSTS에 로컬 IP가 추가돼서 ALB의 health check가 통과되는 구조입니다.

if AWS_LOCAL_IP:
ALLOWED_HOSTS += [AWS_LOCAL_IP]

웹서버 로그를 확인해보니 HTTP status code가 200이 아닌 400으로 나오고 있었습니다.

10.0.123.234 - - [19/May/2022:19:11:09 +0900] "GET / HTTP/1.1" 400 154 "-" "ELB-HealthChecker/2.0" "-"

그런데 같은 코드로 환경에 따라 API가 실패한다는 것이 조금 이상해서 AWS EB 설정을 환경별로 다시 한번 비교해봤습니다.

Disable… IMDSv1…?

AWS EB — Instance — Disable IMDSv1 설정
AWS EB — Instance — Disable IMDSv1 설정 (위 개발 환경 / 아래 서비스 환경)

리뉴얼 할 당시 분명 같게 설정했다고 생각한 옵션에서 딱 하나 다른게 하나 있었습니다. 바로 “Disable IMDSv1”를 활성화한 겁니다. 이게 무엇인가 찾아보니 관련 문서가 있었습니다.

AWS EC2 — Use IMDSv2

저희가 필요한 내용은 ‘IMDSv2만 사용하게 강제하면, IMDSv1는 사용할 수 없다' 였습니다. (당연히…) 어떻게 다르기에 사용할 수 없다는 건지 조금 더 자세히 살펴보았습니다.

IMDSv2

단일 Request/Response 기반으로 이용할 수 있는 IMDSv1과는 달리, 세션 기반으로 이용할 수 있는 IMDSv2는 일정 기간(1초 ~ 6시간) 동안 사용할 수 있는 세션 토큰을 발급받아 인스턴스 메타데이터를 가져올 때 헤더에 담아 요청하는 방식으로 동작하고 있었습니다.

예)

[ec2-user ~]$ TOKEN=`curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"`[ec2-user ~]$ curl -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/

IMDSv1도 무기한(indefinitely)으로 지원할 예정이긴 하지만 왜 IMDSv2를 사용을 강제하면 IMDSv1는 사용할 수 없는지, 왜 IMDSv2로 전환하는 걸 권장하는지도 찾아보았습니다.

AWS Security Blog — Add defense in depth against open firewalls, reverseS proxies, and SSRF vulnerabilities with enhancements to the EC2 Instance Metadata Service

이하 요약 번역입니다.

EC2 IMDS 덕분에 많은 어플리케이션들이 인스턴스의 메타데이터에 접근할 수 있게 되어, 클라우드를 사용하는 유저들이 겪던 인증 관련 이슈들을 해결하는데 많은 도움을 준지 10년이 지났습니다.

IMDSv1도 충분히 안전하고, AWS는 계속 지원할 예정이지만 IMDSv2는 새로운 안전장치(belt and suspenders)를 추가해 4가지 취약점에서 더 안전하게 사용할 수 있습니다.

IMDSv2는 매 요청마다 세션 인증으로 돌아가며 이 방식을 통해 직전에 언급한 4가지 취약점에서 더 안전하다고 얘기할 수 있습니다.

  • 잘못 설정된 어플리케이션 방화벽
  • 열린 리버스 프록시
  • SSRF 취약점
  • 열린 레이어 3 방화벽 NAT

잘못 설정된 어플리케이션 방화벽

일부의 서드파티 어플리케이션 방화벽들은 공격자들이 방화벽 뒤의 네트워크에서 무단으로 공격을 할 수 있게 잘못 설정될 수 있습니다. 그리고 타 방화벽들을 분석해보니 대부분의 방화벽에서 HTTP PUT 요청은 허용하지 않는 것으로 나타났습니다

그래서 IMDSv2는 웹사이트나 브라우저에서 사용 빈번도가 적은 PUT 요청을 메타데이터 호출 전에 실행하게끔 설계해서 보안을 지킬 수 있습니다.

리버스 프록시

매우 드물게도 리버스 프록시에서 PUT 요청을 허용할 수도 있습니다. Apache httpd 나 다른 리버스 프록시들도 외부 요청을 내부 리소스에 닿게끔 잘못 설정할 수 있는데 대게 HTTP 헤더에 X-Forwarded-For가 담겨옵니다.

IMDSv2는 세션 인증 요청에 X-Forwarded-For 헤더가 있으면 세션 토큰을 발급하지 않기 때문에 효과적으로 방어할 수 있습니다.

SSRF 취약점

SSRF 취약점은 몇몇 웹 어플리케이션에서 공격자들에게 공격을 가능하게 할 수 있습니다. 마찬가지로 SSRF 취약점을 통해 내부 리소스에 접근할 수 있게 되는데, IMDSv1처럼 단일 Request/Response로 데이터를 받을 수 있는 경우 SSRF 취약점을 통해 IMDSv1에서 데이터를 가져올 요청을 하고 응답을 받으면 바로 탈취될 수 있는 것입니다.

하지만 IMDSv2는 PUT 요청을 한 번 진행하고 다른 요청에서 데이터를 가져올 수 있게 돼서 단일 Request/Response 보다 더 효과적으로 방어할 수 있고, 실제로 AWS는 이러한 조합이 대다수의 SSRF 취약점을 보호할 수 있는 것으로 분석됐습니다.

열린 레이어 3 방화벽과 NAT

IMDSv2는 열린 라우터, 레이어 3 방화벽, VPN, 터널, NAT 기기 등 잘못 설정된 기기들로부터 방어할 수 있습니다. IMDSv2의 PUT 응답에 있는 인증 토큰은 밖으로 내보낼 수가 없는데, 이는 저레벨 IP 패킷의 TTL(Time To Live) 값을 1로 설정했기 때문입니다. EC2 인스턴스를 비롯해 하드웨어와 소프트웨어들이 패킷을 다루게 되는 과정에서 각 패킷의 TTL 필드에서 1이 깎이게 되기 때문입니다. 그렇다고 TTL을 0으로 설정하게 되면 그 패킷은 무시하게 되고 전송자에게 에러 메시지가 반환됩니다.

이러한 특성으로 IMDSv2에서는 TTL 값을 1로 설정했고, 이는 IMDS 요청을 보내는 EC2 인스턴스만이 사용하고 휘발될 수 있도록 한 것입니다. 만약 EC2 인스턴스에서 열린 라우터, 레이어 3 방화벽, VPN, 터널, NAT를 잘못 설정했더라도 앞서 설명한 특성 덕분에 패킷이 EC2 인스턴스를 벗어나 라우터, 방화벽, VPN, 터널, NAT를 거치는 순간 TTL 값이 0으로 깎여서 공격자에게 전달되는 것을 막을 수 있습니다.

결론

어찌하다 보니 IMDSv2에 대한 설명 글이 되어버렸는데, 버전 업데이트가 된다는 게 단순히 보안을 위해서라고 생각을 했지만 이렇게 단순한 설계로 많은 취약점을 막을 수 있다는 것에 놀랐습니다.

그리고 이제 결론을 얘기해보자면 규모가 적은 팀이기에 만들어야 하는 기능이 많아 당장 IMDSv2로 전환은 하지 못하고, IMDSv1을 사용할 수 있게 풀어둔 다음 EB Python 플랫폼 버전을 3.2.1에서 3.3.9로 업데이트해서 최신 Amazon Linux 2 이미지를 사용할 수 있게 됐고, 당장 급한 보안 이슈를 해결할 수 있게 되었습니다.

어떻게 보면 설정 하나 on/off로 간단히 해결할 수 있는 이슈였는데, 이 글을 읽고 계신 분들도 아시다시피 설정 하나 차이로 많은 사이드 이펙트가 생길 수 있다는 불안감에 AWS 문서와 로그, Django — Django REST Framework — CORS 관련 문서도 꼼꼼히 읽어 지식과 경험이 늘어나게 되는 좋은 경험이었습니다.

그리고 저를 비롯한 서버 관리자분들이 다시는 이 이슈를 겪지 않길 바라며 + IMDSv2로 보안까지 챙기길 바라며 이 글을 남깁니다.

긴 글 읽어주셔서 감사합니다.

이처럼 벨루가에서는 한다면 제대로 하는, 자기가 경험한 것을 그대로 흘러 보내지 않고 공유해 같이 성장하고 싶은, 함께 일하는 팀원이 프로인 멋진 분들을 모시고 있어요.

저희 같이 광복 이후, 한 세기 넘게 변화가 없던 15조 원에 달하는 주류🍻 시장을 바꿔나가봐요.

--

--