악의적인 트래픽 방어를 위한 서버 인프라 구축기

jonghyun.log·2024년 8월 3일
2
post-thumbnail
post-custom-banner

저는 평소 인프라를 구축할때 보안적인 측면은 많이 고려하지 않았는데요.
이번 기회로 인프라, 네트워크를 구축할 때 고려해야할 내용으로 배운 내용을 정리할겸
제가 겪은 악의적인 트래픽 공격을 겪은 문제 상황과 해결한 과정을 공유해 보겠습니다.

악의적인 트래픽이 서버를 공격한다

최근 아직 서비스 배포도 하지 않았음에도, 개발중인 서버에
에러 로그와 트래픽이 미친듯이 발생하는 이슈가 발생했습니다.

악의적인 트래픽은 어디로부터 들어오는가

기존에 어떤 ip를 통해서 서버로 요청을 보내는지에 대한 모니터링 장치가 구축되어있지 않았기 때문에
일단 급한대로 직접 EC2에 직접 접속하여 tcpdump 명령어로 패킷 정보를 모니터링 하기로 하였습니다.

악성 요청이 주기적으로 계속 오고 있었기에 급하게 캡쳐한 패킷 정보로도 현 상황에 대한 유의미한 모니터링 정보를 확보할 수 있었습니다.

패킷 dump 파일을 scp 명령어로 제 로컬 컴퓨터에 옮기고 Wireshark 툴로 Http 요청을 확인해보니



.env, .git/config 같이 중요한 파일의 담긴 경로는 물론이고 xxx.php 같은 경로등 정말 다양한 주소로부터 각종 악의적인 요청이 들어오는것을 확인해볼 수 있었습니다 😰

요청이 들어오는 경로는 정말 다양했는데요. 트래픽이 들어오는 서버의 ip 경로들을 적어보면

  1. 배포한 서버의 도메인 주소
  2. EC2 프라이빗 ip 주소 (EC2 health check 요청)
  3. EC2 퍼블릭 IPv4 주소
  4. Application Load Balnacer의 DNS ip 주소들
    43.201.xxxx
    207.154.xxxx
    15.165.xxxx
    13.125.xxxx
    ...
    (nslookup ALB 도메인 주소 명령어로 ALB DNS 주소를 확인할 수 있습니다.)

2번은 AWS가 제공하는 내부적인 ec2 health check 트래픽이니 논외로 하고
1,3,4번의 경로로 오는 악의적인 요청은 별도의 대응이 필요해 보였습니다.

특히나 이런 공격으로 크게 두가지의 큰 손실을 유발할 여지가 있었는데요

  1. 서버의 중요한 파일이 외부로 유출됨
  2. 서버에 요청을 마구잡이로 보내 서버에 장애가 일어남

현재 서버에서 중요한 파일을 API 엔드포인트로 열어놓은 경우는 없었기에
1은 당장은 실제 피해로 이어질 여지가 없어 보였지만,
2는 서버로 서비스 운영에 직접적인 장애로 이어질 여부가 있어 보였습니다.

해결하기 전에 현재 인프라 구조부터 뜯어보기

원래 의도한 서버 트래픽 호출 구조

정상적인 트래픽 구조라면 위의 그림처럼

유저가 도메인 주소를 통해 api 요청
-> 도메인 주소의 DNS 서버로 요청 전달
-> DNS 서버의 ip주소와 연동된 AWS Route 53으로 호스팅
-> ALB(Application Load Balancer)로 호스팅
-> ALB에서 매핑해 놓은 대상 그룹(target group)으로 호스팅
(EC2로 호스팅하면서 http 요청은 https 요청으로 리다이렉션후 요청 인증서 보안 처리)
-> Target group에서 매핑한 EC2 포트(8080) 로 트래픽 전달
(Target group 에서 EC2 혹은 외부 IP 주소 로 호스팅 가능)
-> EC2 API 서버에서 요청 처리

위의 순서로 트래픽이 전달되어야 정상인데도 불구하고

실제 일어나고 있는 트래픽 호출 구조

외부에서 ALB로 직접 요청을 보내거나 더 심하게는 EC2로 직접 트래픽 요청을 허용하는 상황이 벌어지고 있었습니다.
이런 상황에서 악의적인 트래픽을 효율적으로 관리하기 위해서는 두가지 개선이 필요하다고 생각했습니다.

  1. 요청이 들어오는 입구를 도메인을 통한 요청으로만 중앙화하자
  2. 중앙화된 요청을 필터링을 통하여 관리하자

원인도 파악했으니 이제 문제를 해결해보자

1. 도메인 주소를 통한 트래픽만 요청을 허용하자

우선 여러 퍼널로 들어오는 요청들은 관리하기 너무 어려우니
EC2 퍼블릭 IPv4 주소로 직접적으로 들어오는 요청과 ALB IP 주소로 호출하는 요청을
허용하지 않는 보안 정책을 도입하는게 가장 손쉽게 적용할 수 있는 방법이라고 생각했습니다.

그리고 실제로, AWS 관리 콘솔에서 클릭 몇번으로 보안 그룹(Security Group)을 생성하고
인바운드(Inbound) 규칙 관리를 하면 간단하게 설정이 가능한 인프라를 지원하고 있기 때문에
설정을 진행했습니다.

기존에 사용하던 보안 그룹 설정

기존에는 ALB, VPC 에서 사용하던 보안 그룹을 하나로 통일해서 사용하느라
VPC에서 허용하면 안되는 Https(443), http(80) 포트가 허용되어 있어서
EC2 퍼블릭 IPv4 주소로 직접적인 요청이 가능했습니다.

VPC 보안 그룹 설정

EC2들과, RDS들이 모여있는 VPC 에 적용하는 보안그룹을 생성하여
인바운드 규칙으로

스프링 서버가 요청을 받는 8080 포트,
Postgres RDS 5432 포트,
EC2 ssh 연결을 위한 22번 포트

만 허용하도록 설정을 허용했고,
8080 포트ALB 보안그룹 아이디를 매핑해서 ALB에서 오는 Https 요청만 요청을 허용하도록 설정했습니다.
Postgres RDS, SSH 포트는 각각 자체적으로
보안정책(RDS:접속 아이디,비밀번호 검증), (SSH: pem 키 사용)이 존재하므로
개발 편의성을 위해서 따로 ip 제약을 걸지 않았습니다.

ALB 보안 그룹 설정

ALB 보안 그룹VPC 내부 보안 그룹 분리를 진행하였습니다.

2. 중앙화된 요청을 필터링을 통하여 관리하자

이제 트래픽을 중앙화 했으니 전체 트래픽중에서
어떤 요청을 악의적인 트래픽으로 처리하고 걸러낼지 고민이 필요한 상황입니다.

🚫 첫번째 해결 아이디어 : AWS WAF 솔루션 이용하기

제일 간단한 방법은 AWS WAF 서비스를 이용하는 것입니다.
WAF 솔루션은 악성 요청을 보내는 IP를 자동으로 블락하고 허용할 요청 경로를 설정하는 등의 여러기능을 제공합니다.
하지만, 위 방법은 비용이 청구되는 AWS의 솔루션이기 때문에
영세한 프로젝트를 진행하는 입장에서 바로 도입하기에 부담이 되었습니다.

악성 요청을 필터링하는 요구사항을 만족하고 금액이 청구되지 않는 다른 방법을 고민해보게 되었습니다.

🚫 두번째 해결 아이디어 : NGINX에서 악성 요청 필터링하기

악성 트래픽을 보낸 IP 정보를 패킷 정보에서 확인해보면

  1. 요청 보내는 악의적인 API 엔드포인트가 베리에이션이 끝이 없다.
  2. 요청 보내는 IP 주소를 계속 바꿔서 호출하기 때문에 어떤 IP가 악성 유저인지 판별이 어렵다.

AWS가 제공하는 별도의 솔루션을 사용하지 않은 상황에서 특정 IP가 악성 유저인지 식별후 블락하는 로직을 추가하기에는 어려움이 있다고 생각했습니다.

그래서, 제가 생각한 솔루션은 API 서버의 엔드포인트만 요청을 허용하도록 필터링 규칙을 적용하자 였습니다.

그 이유로, 어떤 ip를 블락할지 결정하기에 어려운 상황이며 어떤 요청을 보내는지 그 패턴을 식별하기 어려운 상황에서는
개발한 서버의 API 엔드포인트만 허용하고 나머지 요청을 다 막아버리는 보안정책만 도입하더라도
적은 공수 대비 유의미한 효과를 볼 수 있을 것이라는 생각이 들었기 때문입니다.

이러한 보안 정책을 실제로 적용할 솔루션으로 처음으로 떠올린 것은 Nginx에서 필터링 규칙을 적용하는 것이었습니다.

이미 기존에 설정되어 있는 CI/CD 구조로 Github Actions를 사용하고 있었기에
배포할때마다 필터링 규칙을 동적으로 적용하는 다음의 솔루션을 생각했습니다.

  1. 서버 프로젝트 전체 API 엔드포인트 리스트를 응답으로 출력하는 API를 구현한다.
  2. Github Actions 에서 Spring Boot 소스코드를 빌드한다.
  3. 빌드한 JAR 파일을 실행해서 1의 API로 부터 전체 API 리스트를 가져온다.
  4. 3의 리스트를 배포전 Nginx 필터링 규칙에 적용한다.
  5. 빌드한 JAR 파일을 EC2에 배포한다.

위의 과정을 거치면 허용해야하는 API 엔드포인트가 생겨날 때 마다
일일히 추가해줄 필요 없이 배포시에 자동으로 Nginx에 보안 정책을 도입할 수 있을 것이라고 생각했습니다.

하지만, 이 방식은 스케일 아웃(Scale Out)을 통해 인프라 구조가 조금만 바뀌면
추가된 Nginx 들을 모두 관리해줘야 한다는 문제가 생길수도 있는 방법이었습니다.

개발하고 있던 프로젝트가 추후 스케일 아웃(Scale Out)까지 고려해서 만들고 있던 상황이었기 때문에
이런 측면도 감안하여 좀 더 공수가 덜 들어가면서 같은 효과를 보는 방법이 있을지 고민해보게 되었습니다.

✅ 세번째 해결 아이디어 : ALB에서 악성 요청 필터링하기

그러다 고민중에 나온 아이디어로 2번에서 Nginx가 하는 필터링을ALB에서 하도록 설정하는 방법이었습니다.

스케일 아웃을 진행한다고 하더라도 ALB를 사용한 인프라 구조는 그대로 가져갈 예정이었기 때문에
ALB 의 보안그룹 정책을 명령어를 통해 동적으로 추가하고 삭제하는 방법을 찾아보게 되었고,

AWS CLI 를 사용하여 ALB를 동적으로 관리하는 방법을 제공하고 있음을 확인했습니다.

  1. 서버 프로젝트 전체 API 엔드포인트 리스트를 응답으로 출력하는 API를 구현한다.
  2. Github Actions 에서 Spring Boot 소스코드를 빌드한다.
  3. 빌드한 JAR 파일을 실행해서 1의 API로 부터 전체 API 리스트를 가져온다.
  4. 3의 리스트를 Github Actions 스크립트상에서 AWS CLI를 사용하여ALB 필터링 규칙에 적용한다.
  5. 빌드한 JAR 파일을 EC2에 배포한다.

위의 두번째 방법에서 4의 부분만 Nginx -> ALB 로 바꿔서 처리하면 됩니다!

  1. 서버 프로젝트 전체 API 엔드포인트 리스트를 응답으로 출력하는 API를 구현한다.
@RestController
@RequestMapping("/endpoints")
public class EndpointController {

    @Autowired
    @Qualifier("requestMappingHandlerMapping")
    private RequestMappingHandlerMapping handlerMapping;

    @Operation(summary = "배포시 ALB 보안 규칙 필터링 endpoint 출력용 API")
    @GetMapping
    public List<String> getEndpoints() {
        List<String> endpoints = new ArrayList<>();

        handlerMapping.getHandlerMethods().forEach((requestMappingInfo, handlerMethod) -> {
            requestMappingInfo.getPathPatternsCondition().getPatternValues().stream()
                    .filter(pattern -> pattern.startsWith("/"))
                    .filter(pattern -> !pattern.equals("/")) // '/' 경로로 임의로 쏘는 요청이 너무 많이 들어와서 block
                    .forEach(endpoints::add);
        });

        // swagger-ui endpoint
        endpoints.add("/swagger-ui/*");

        return endpoints.stream()
                .distinct()
                .sorted()
                .toList();
    }
}

위의 컨트롤러 메서드를 호출하여 API 엔드포인트를 리스트 형태로 뽑아내는것이 가능합니다.

  1. Github Actions 에서 Spring Boot 소스코드를 빌드한다.
  2. 빌드한 JAR 파일을 실행해서 1의 API로 부터 전체 API 리스트를 가져온다.
      - name: Run Spring Boot application # Spring Boot 애플리케이션 실행 후 API 엔드포인트 추출
        run: |
          java -jar deploy/*.jar --spring.profiles.active=ci &
          sleep 15 # 애플리케이션 시작 대기

      - name: Extract API endpoints
        run: |
          curl http://localhost:8080/endpoints > deploy/api-endpoints.txt
          pkill java

위의 코드를 Github Actions 실행 스크립트의 JAR 파일 빌드 이후에 추가하여 API 엔드포인트를
deploy/api-endpoints.txt 로 변환하는 과정을 추가합니다.

  1. 3의 리스트를 Github Actions 스크립트상에서 AWS CLI를 사용하여ALB 필터링 규칙에 적용한다.
      # update alb rules
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2

      - name: Configure AWS CLI path
        run: |
          echo 'export PATH=$PATH:/usr/local/bin/aws' >> $GITHUB_ENV

      - name: Update ALB rules
        run: |
          AWS_CLI_PATH=$(which aws)
          echo "AWS CLI Path: $AWS_CLI_PATH"
          
          HTTPS_LISTENER_ARN="${{ secrets.HTTPS_LISTENER_ARN }}"
          TARGET_GROUP_ARN="${{ secrets.TARGET_GROUP_ARN }}"
          
          
          # 기존 HTTPS 규칙 삭제 (기본 규칙 제외)
          RULES=$($AWS_CLI_PATH elbv2 describe-rules --listener-arn $HTTPS_LISTENER_ARN --query 'Rules[?Priority!=`default`].RuleArn' --output text)
          for RULE_ARN in $RULES
          do
            $AWS_CLI_PATH elbv2 delete-rule --rule-arn $RULE_ARN
          done
          
          # API 엔드포인트를 ALB 규칙 조건 형식으로 변환 : users/{userId} -> users/* 변환 
          jq -r '.[] | select(. != "/swagger-ui.html") | gsub("\\{[^}]+\\}"; "*")' deploy/api-endpoints.txt > alb_paths.txt
          
          # 사용 가능한 다음 우선순위 찾기
          NEXT_PRIORITY=$($AWS_CLI_PATH elbv2 describe-rules --listener-arn $HTTPS_LISTENER_ARN --query 'max(Rules[?Priority!=`default`].Priority)' --output text)
          if [ "$NEXT_PRIORITY" == "None" ]; then
            NEXT_PRIORITY=1
          else
            NEXT_PRIORITY=$((NEXT_PRIORITY + 1))
          fi
          
           # 각 경로에 대해 개별 규칙 생성
          while IFS= read -r PATH
          do
            $AWS_CLI_PATH elbv2 create-rule \
              --listener-arn $HTTPS_LISTENER_ARN \
              --priority $NEXT_PRIORITY \
              --conditions '[{"Field": "path-pattern", "PathPatternConfig": {"Values": ["'"$PATH"'"]}}]' \
              --actions Type=forward,TargetGroupArn=$TARGET_GROUP_ARN
          
            NEXT_PRIORITY=$((NEXT_PRIORITY + 1))
          done < alb_paths.txt
          
          # 마지막 규칙으로 403 Forbidden 규칙 추가
          $AWS_CLI_PATH elbv2 create-rule \
            --listener-arn $HTTPS_LISTENER_ARN \
            --priority $NEXT_PRIORITY \
            --conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/*"]}}]' \
            --actions Type=fixed-response,FixedResponseConfig='{StatusCode=403,ContentType="text/plain",MessageBody="Forbidden"}'
            
          echo "ALB HTTPS rules updated successfully"
  1. 빌드한 JAR 파일을 EC2에 배포한다.

5번에서는 기존에 사용하던 EC2 배포 과정을 사용해서 배포를 진행합니다.

ALB에 필터 규칙 적용된 내용 확인

위 코드로 배포를 진행하면 배포시마다 요청을 허용할 API 엔드포인트가 생기는 것을 확인할 수 있습니다.

그리고 마지막으로 허용된 경로가 아닌 모든 요청은 403 Forbidden 을 반환하게 됩니다.

개선된 트래픽 지표로 확인하기

AWS EC2 패킷 트래픽 지표

위의 솔루션을 도입해서 평균 분당 15,000번 가량 튀어 오르던 악성 트래픽을 필터링하여 분당 100건 미만으로 개선하는 것에 성공했습니다.

패킷 캡쳐 정보 - 요청 API 엔드포인트

API 엔드포인트로 허용한 주소 외의 요청은 전부 필터링 된 것을 확인할 수 있습니다.

패킷 캡쳐 정보 - 요청한 IPv4 리스트

요청 보낸 ip 주소도 도메인 주소로 오는 요청만 허용된 것을 확인해 볼 수 있습니다.

결론

이번 문제 상황을 개선한 과정을 요약하면 다음과 같습니다.

  1. 문제가 되는 트래픽 패킷 정보를 모니터링 하여 무엇이 문제인지 파악
  2. 서버로 오는 요청을 도메인을 통한 요청으로 중앙화
  3. 문제가 되는 랜덤한 API 엔드포인트 를 서버에서 개발한 리스트만 허용
  4. ALB에서 3의 과정을 자동으로 필터링되게끔 Github Actions 배포 스크립트에 적용
  5. 개선한 결과를 트래픽 패킷 정보를 모니터링하여 수치로 확인

대단한 알고리즘을 작성하지도, 유행하는 신규 프레임워크를 사용한 것도 아니지만
생각보다 좋은 결과를 얻었고 어렵게만 생각되는 보안도 적당한 노력을 들여서 개선할 수 있다는 것을 배워서
의미있었던 시간이었습니다. 😄

post-custom-banner

0개의 댓글