Post

HTTP2

목차


개요

H1과 동일한 구조

Frame: HTTP/2 통신상의 제일 작은 정보의 단위이며, 기본적으로는 Header 혹은 Data 둘 중 하나를 말한다.

예시

1
2
3
4
Host: localhost
Accept-Encoding: gzip, deflate

{ "test": 1, "test2": 2 }

Message: HTTP1.1과 마찬가지로 요청 혹은 응답의 단위이며, 다수의 Frame으로 이루어진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
요청 메시지

GET / HTTP/1.1
Host: localhost
User-Agent: curl/7.64.1


응답 메시지

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Tue, 29 Nov 2022 10:03:37 GMT
Content-Type: text/html
Content-Length: 8
Connection: keep-alive
ETag: "63843f40-3b73"
Last-Modified: Tuesday, 29-Nov-2022 10:03:37 GMT
Cache-Control: no-store, no-cache
Accept-Ranges: bytes

......

H2의 추가된 내용

Stream: 클라이언트와 서버 사이에 맺어진 연결을 통해 양방향으로 주고받는 하나 혹은 복수개의 Message.
즉 요청과 응답이 합쳐져서 하나의 스트림이 되는 것.

즉, H2는 Frame 여러개가 모여 Message, Message 여러개가 모여 Stream이 되는 구조이다.

이미지 출처 : https://swiftymind.tistory.com/114

제목은 기억나지 않지만 (바윗돌 깨뜨려 돌덩이~ 돌덩이 깨뜨려 돌맹이~) 동요가 있는데.. 해당 동요로 연관지어 이해했다.
프레임(Frame)모여서 메시지(Message), 메시지(Message) 모여서 스트림(Stream)…

하나의 통신 (TCP 연결) 에는 여러개의 스트림이 존재할 수 있다. 예전에는 페이지와 favicon을 요청할 때
index.html 페이지 SYN -> SYN/ACK -> ACK 후
favicon.ico SYN -> SYN/ACK -> ACK
형식으로 하나의 사이트를 요청할 때 2번의 TCP 연결이 이루어졌다.
그러나 HTTP2의 경우 해당 요청 스트림 두개를 하나의 TCP 연결로 처리할 수 있다.

(하나의 tcp 연결 (와이어샤크 tcp.stream eq 0)에서 2개의 요청(‘/’, ‘/public/favicon.ico’)이 처리됨)

이미지 출처

이외에도 HTTP2는

  • 헤더 압축
    • 중복되는 헤더의 내용을 압축, String 헤더 데이터를 바이너리로 변환
  • 서버 푸쉬
    • 클라이언트가 요청하지 않은 웹페이지 컨텐츠에 대해서도 서버가 알아서 전송해줄 수 있음.
  • 우선순위
    • 웹페이지의 렌더링을 효과적으로 처리하기 위해 우선순위를 부여할 수 있음.
      • 페이지 렌더링에 필요한 리소스를 우선적으로 받을 수 있음. (css 파일 등)

등의 기능을 제공한다.
자세한 내용은 해당 블로그 참조

HTTP/2 프레임의 종류

모든 프레임은 다음과 같은 헤더로 시작한다.

이름길이비고
Length3 bytes프레임 페이로드 길이
Type1 byte프레임의 유형
Flags1 byte프레임의 유형별 플래그
R1 bit예약된 비트
Stream Identifier31 bits각 스트림의 고유 식별자
Frame Payload가변실제 프레임의 내용. 프레임 페이로드의 길이가 Length 필드에 표시됨.

프레임의 종류는 다음과 같다.

이름ID설명
DATA0x00특정 스트림의 내용
HEADERS0x01HTTP/2 메시지의 헤더
PRIORITY0x02스트림의 우선순위와 의존성 표시 또는 변경 기능
RST_STREAM0x03엔드포인트가 스트림을 닫도록 함.
(TCP의 RST 패킷과 같이 오류 시 강제종료 하기 위해 사용하는 경우가 많음.)
SETTINGS0x04HTTP/2 통신을 위한 설정값
PUSH_PROMISE0x05서버가 클라이언트에 보낼 것이 있음을 알림.
PING0x06연결의 상태를 확인하기 위해 사용
GOAWAY0x07상대방이 새로운 스트림을 수신했음을 엔드포인트에 알림
WINDOW_UPDATE0x08엔드포인트가 얼마나 많은 바이트를 수신할 수 있는지 알림
CONTINUATION0x09HEADER를 확장

표 출처 https://itchipmunk.tistory.com/272


SCAPY로 알아보는 HTTP2

준비

https://github.com/secdev/scapy scapy 레포지토리 링크에서 scapy를 다운받는다.

1
git clone https://github.com/secdev/scapy.git

이후 scapy 디렉토리에 들어가 example 디렉토리를 생성한다.

1
2
cd scapy
mkdir example

scapy 예제 소스를 만든다.

1
2
touch http2_example.py
chmod 755 http2_example.py

코드를 작성하기 전 아래 코드를 추가한다.

  • 나는 python3.7 환경에서 해당 코드를 실행시켰다.
    • scapy 문서에도 나와 있듯 python3.10 이후의 버전은 현재 scapy에서 지원하지 않으니 주의하길 바란다.
  • scapy의 example 디렉토리 내에서 실행시키기 때문에 import를 위해 sys.path.append를 사용하였다.
  • 요청/응답 내용을 와이어샤크에서 바로 볼 수 있게끔 sslkeylog도 설정하였다.
    • 해당 라이브러리를 임포트 하기 위해서는 pip3 install sslkeylog 명령어를 통해 설치해야 한다.
1
2
3
4
5
6
7
8
9
10
#!/usr/local/bin/python3.7

#/usr/local/bin/python3.5
import os
import sys
PARENT_PATH=os.path.dirname(os.path.abspath(os.path.dirname(__file__)))
sys.path.append(PARENT_PATH)

import sslkeylog
sslkeylog.set_keylog('/path/to/sslkeylog.log')

아래 두 문서를 참고하여 작성했음을 알린다.

https://github.com/secdev/scapy/blob/master/doc/notebooks/HTTP_2_Tuto.ipynb

https://github.com/secdev/scapy/blob/master/doc/notebooks/Scapy%20in%2015%20minutes.ipynb


TLS HANDSHAKING

먼저 socket 라이브러리를 통해 TLS handshaking을 맺는다.

연결할 사이트를 입력한다. 나는 je.iasdf.com (10.0.1.49)로 연결하였다.

1
dn = 'je.iasdf.com'

또한 서버와의 연결을 맺을 때 client_hello 단에서 h2를 지원하는 것을 명시한다.

1
ssl_ctx.set_alpn_protocols(['h2'])  # h2 is a RFC7540-hardcoded value

이후 서버에서 응답을 받고, 서버에서 h2를 지원하는지 확인한다. H2를 지원하지 않을 경우 asssert로 프로그램을 종료한다.

1
2
3
ssl_sock.connect(ip_and_port)
print(ssl_sock.selected_alpn_protocol())
assert('h2' == ssl_sock.selected_alpn_protocol())

output

1
h2

전체 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import os
import sys
PARENT_PATH=os.path.dirname(os.path.abspath(os.path.dirname(__file__)))
sys.path.append(PARENT_PATH)

import sslkeylog
sslkeylog.set_keylog('/path/to/sslkeylog.log')

from scapy.all import *
import socket
import ssl

dn = 'je.iasdf.com'

# Get the IP address of a Google HTTP endpoint
l = socket.getaddrinfo(dn, 443, socket.INADDR_ANY, socket.SOCK_STREAM, socket.IPPROTO_TCP)
assert len(l) > 0, 'No address found :('

s = socket.socket(l[0][0], l[0][1], l[0][2])
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
if hasattr(socket, 'SO_REUSEPORT'):
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
ip_and_port = l[0][4]

# Testing support for ALPN
assert(ssl.HAS_ALPN)

# Building the SSL context
# ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS)
# print(ssl.HAS_TLSv1_2)
# print(ssl.HAS_TLSv1_3)
# ssl_ctx.options |= ssl.OP_NO_TLSv1
# ssl_ctx.options |= ssl.OP_NO_TLSv1_1
ssl_ctx.options |= ssl.OP_NO_TLSv1_2
ssl_ctx.options |= ssl.OP_NO_TLSv1_3

# 지원하는 암호화 키 교환 알고리즘 지정
ssl_ctx.set_ciphers(':'.join([  # List from ANSSI TLS guide v.1.1 p.51
                'ECDHE-ECDSA-AES256-GCM-SHA384',
                'ECDHE-RSA-AES256-GCM-SHA384',
                'ECDHE-ECDSA-AES128-GCM-SHA256',
                'ECDHE-RSA-AES128-GCM-SHA256',
                'ECDHE-ECDSA-AES256-SHA384',
                'ECDHE-RSA-AES256-SHA384',
                'ECDHE-ECDSA-AES128-SHA256',
                'ECDHE-RSA-AES128-SHA256',
                'ECDHE-ECDSA-CAMELLIA256-SHA384',
                'ECDHE-RSA-CAMELLIA256-SHA384',
                'ECDHE-ECDSA-CAMELLIA128-SHA256',
                'ECDHE-RSA-CAMELLIA128-SHA256',
                'DHE-RSA-AES256-GCM-SHA384',
                'DHE-RSA-AES128-GCM-SHA256',
                'DHE-RSA-AES256-SHA256',
                'DHE-RSA-AES128-SHA256',
                'AES256-GCM-SHA384',
                'AES128-GCM-SHA256',
                'AES256-SHA256',
                'AES128-SHA256',
                'CAMELLIA128-SHA256'
            ]))
ssl_ctx.set_alpn_protocols(['h2'])  # h2 is a RFC7540-hardcoded value
ssl_sock = ssl_ctx.wrap_socket(s, server_hostname=dn)

ssl_sock.connect(ip_and_port)
print(ssl_sock.selected_alpn_protocol())
assert('h2' == ssl_sock.selected_alpn_protocol())

TLS Handshaking 통신하는 모습

172.31.213.231 IP는 클라이언트, 10.0.1.49는 서버

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Client           Server
|                     |
|---------------------|
|                     |
+--->>--->>--->>--->>>| client hello
|                     |
-<<<---<<---<<---<<---| server hello
|                     |
-<<<---<<---<<---<<---| server hello done / certificate
|                     |
+--->>--->>--->>--->>>| change cipher specs / Finished
|                     |
-<<<---<<---<<---<<---| change cipher specs / Finished
|                     |


SETTING HTTP2

TLS handshaking을 통해 h2를 지원하는 서버와 연결을 맺었다면, 이제 HTTP2를 설정한다.

HTTP2는 다음과 같은 특성을 지니고 있다.

HTTP2는 HTTP/1.1과 달리 TCP 연결을 유지하면서 여러 개의 요청을 동시에 처리할 수 있다. (multiplexing)
또한 헤더 압축을 통해 중복되는 헤더를 없애 헤더의 크기를 줄여준다. (header compression)

이러한 특성들을 제대로 사용하기 위해 HTTP2가 클라이언트와 서버 간 상호 흐름 제어를 위하여 미리 세팅해두는 것이다.

이렇게 프로토콜을 세팅하는 것에 대한 프레임을 세팅 프레임이라고 하는데, 세팅 프레임에서는 아래 내용들이 포함된다. 출처1, 출처2, 출처3

이름ID기본값설명
SETTINGS_HEADER_TABLE_SIZE0x14096 
SETTINGS_ENABLE_PUSH0x21 
SETTINGS_MAX_CONCURRENT_STREAMS0x3무제한HTTP2의 연결 당 동시 요청 수
SETTINGS_INITIAL_WINDOW_SIZE0x465535(2^16-1)스트림 당 보낼 수 있는 최대 메시지 크기
SETTINGS_MAX_FRAME_SIZE0x516384HTTP/2 프로토콜 프레임 본문의 최대 크기
SETTINGS_MAX_HEADER_LIST_SIZE0x6무제한 

TLS HANDSHAKING 에서 연결을 수립했던 ssl_sock 변수를 가지고 서버에서 HTTP2 세션에 대한 설정 정보를 클라이언트로 넘겨준다.

scapy/scapy/supersocket.py 파일의 SSLStreamSocket 클래스의 내용을 조금 수정하였다.

recv() 함수를 호출했을 때 아래 사진과 같이 남아있는 Padding 부분이 있을 경우 해당 부분을 따로 show할 수 있도록 remain 변수를 추가하였다.

그리고 show_remain() 함수를 추가하여 추가적인 정보를 확인할 수 있도록 하였다.

1
2
3
4
5
Client           Server
|                     |
|---------------------|
|                     |
-<<<---<<---<<---<<---| Server Setting Receive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import scapy.supersocket as supersocket
import scapy.contrib.http2 as h2
import scapy.config

def show_remain(ss):
    buf = ""
    if ss.remain:
        buf = str(ss._buf)[2:]
        buf = buf[:-1]
        buf = buf.replace("\\x", ' ')[1:]

    if len(buf) > 0:
        print("### [ REMAIN ] ###")
        print("hex :", buf)
        h2.H2Frame(ss._buf).show()
    else:
        print("### [ NO REMAIN ] ###")

scapy.config.conf.debug_dissector = True
ss = supersocket.SSLStreamSocket(ssl_sock, basecls=h2.H2Frame)
srv_set = ss.recv()
srv_set.show()
show_remain(ss)

서버는 SETTINGS_MAX_CONCURRENT_STREAMSSETTINGS_INITIAL_WINDOW_SIZE, SETTINGS_MAX_FRAME_SIZE의 정보를 넘겨주었다.

output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
###[ HTTP/2 Frame ]###
  len       = 0x12
  type      = SetFrm
  flags     = set()
  reserved  = 0
  stream_id = 0
###[ HTTP/2 Settings Frame ]###
     \settings  \
      |###[ HTTP/2 Setting ]###
      |  id        = Max concurrent streams
      |  value     = 128
      |###[ HTTP/2 Setting ]###
      |  id        = Initial window size
      |  value     = 65536
      |###[ HTTP/2 Setting ]###
      |  id        = Max frame size
      |  value     = 16777215

클라이언트에서 서버가 보낸 정보를 읽어 저장한다. 만약 서버가 해당 정보를 보내주지 않았을 경우에는 RFC의 기본값을 사용해야 하기에 초기값은 RFC의 기본값으로 설정한다.

1
2
3
4
5
6
7
8
9
10
11
srv_max_frm_sz = 1<<14      # 16384
srv_hdr_tbl_sz = 4096
srv_max_hdr_tbl_sz = 0
srv_global_window = 1<<14   # 16384
for setting in srv_set.payload.settings:
    if setting.id == h2.H2Setting.SETTINGS_HEADER_TABLE_SIZE:
        srv_hdr_tbl_sz = setting.value
    elif setting.id == h2.H2Setting.SETTINGS_MAX_HEADER_LIST_SIZE:
        srv_max_hdr_lst_sz = setting.value
    elif setting.id == h2.H2Setting.SETTINGS_INITIAL_WINDOW_SIZE:
        srv_global_window = setting.value

서버 설정을 승인하기 전에 클라이언트가 HTTP2 프로토콜을 이해하고 지원한다는 것을 서버에게 알리기 위해 HTTP2의 연결 서문인 상수 문자열을 만들어서 서버에게 전송한다. (RFC 7540 section 3.5)

1
2
3
#                                          HTTP/2 Connection Preface                                                   #  # noqa: E501
# From RFC 7540 par3.5
H2_CLIENT_CONNECTION_PREFACE = hex_bytes('505249202a20485454502f322e300d0a0d0a534d0d0a0d0a')  # noqa: E501

srv_global-window 에서 len(h2.H2_CLIENT_CONNECTION_PREFACE) 을 하는 이유를 알겠는가?

현재 요청하는 서버에서는 SETTINGS_INITIAL_WINDOW_SIZE 값이 65536 이기에, 한 스트림 당 보낼 수 있는 메시지의 크기는 최대 65536바이트이다. 스트림에서 보내는 메시지의 사이즈를 계산하기 위해 len(h2.H2_CLIENT_CONNECTION_PREFACE) 를 빼주고 있는 것이다.

len(h2.H2_CLIENT_CONNECTION_PREFACE)(24byte)를 뺀 값이 0보다 작다면 비정상적인 통신으로 간주하고 프로그램을 강제 종료한다.

1
2
3
4
5
6
7
Client           Server
|                     |
|---------------------|
|                     |
-<<<---<<---<<---<<---| Server Setting Receive
|                     |
+--->>--->>--->>--->>>| HTTP2 Magic Packet
1
2
3
4
5
6
7
8
import scapy.packet as packet

# We verify that the server window is large enough for us to send some data.
srv_global_window -= len(h2.H2_CLIENT_CONNECTION_PREFACE)
assert(srv_global_window >= 0)

send_byte = ss.send(packet.Raw(h2.H2_CLIENT_CONNECTION_PREFACE))
print(send_byte)

output

1
24

24바이트가 전송되었음을 알 수 있다.

그런 다음 해당 세팅을 승인하는 프레임 (ACK 플래그가 들어간 SETTINGS FRAME) 을 만들어 해당 세팅 프레임의 설정을 사용(승인)하겠다는 내용을 알린다. (RFC 7540 section 6.5)

1
2
3
4
5
6
7
ACK (0x1):  When set, bit 0 indicates that this frame acknowledges
   receipt and application of the peer's SETTINGS frame.  When this
   bit is set, the payload of the SETTINGS frame MUST be empty.
   Receipt of a SETTINGS frame with the ACK flag set and a length
   field value other than 0 MUST be treated as a connection error
   (Section 5.4.1) of type FRAME_SIZE_ERROR.  For more information,
   see Section 6.5.3 ("Settings Synchronization").

승인 프레임을 만들고 show 하여 정상적으로 만들어졌는지 확인한다.

1
2
set_ack = h2.H2Frame(flags={'A'})/h2.H2SettingsFrame()
set_ack.show()

output (잘 만들어졌음을 확인할 수 있다.)

1
2
3
4
5
6
7
8
###[ HTTP/2 Frame ]###
  len       = None
  type      = SetFrm
  flags     = {'ACK (A)'}
  reserved  = 0
  stream_id = 0
###[ HTTP/2 Settings Frame ]###
     \settings  \

클라이언트에서 세팅 프레임 값을 만들고 해당 프레임 값을 세팅(승인) 프레임에 붙여 같이 전송한다.

1
2
3
4
5
6
7
8
9
Client           Server
|                     |
|---------------------|
|                     |
-<<<---<<---<<---<<---| SETTINGS (Server Setting)
|                     |
+--->>--->>--->>--->>>| HTTP2 Magic Packet
|                     |
+--->>--->>--->>--->>>| SETTINGS (ack),  SETTINGS(Client Setting)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
own_set = h2.H2Frame()/h2.H2SettingsFrame()
max_frm_sz = (1 << 24) - 1
max_hdr_tbl_sz = (1 << 16) - 1
win_sz = (1 << 31) - 1
own_set.settings = [
    h2.H2Setting(id = h2.H2Setting.SETTINGS_ENABLE_PUSH, value=0),
    h2.H2Setting(id = h2.H2Setting.SETTINGS_INITIAL_WINDOW_SIZE, value=win_sz),
    h2.H2Setting(id = h2.H2Setting.SETTINGS_HEADER_TABLE_SIZE, value=max_hdr_tbl_sz),
    h2.H2Setting(id = h2.H2Setting.SETTINGS_MAX_FRAME_SIZE, value=max_frm_sz),
]

h2seq = h2.H2Seq()
h2seq.frames = [
    set_ack,
    own_set
]

srv_global_window -= len(str(h2seq))
assert(srv_global_window >= 0)

h2seq.show()
ss.send(h2seq)

output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
###[ HTTP/2 Frame Sequence ]###
  \frames    \
   |###[ HTTP/2 Frame ]###
   |  len       = None
   |  type      = SetFrm
   |  flags     = {'ACK (A)'}
   |  reserved  = 0
   |  stream_id = 0
   |###[ HTTP/2 Settings Frame ]###
   |     \settings  \
   |###[ HTTP/2 Frame ]###
   |  len       = None
   |  type      = SetFrm
   |  flags     = set()
   |  reserved  = 0
   |  stream_id = 0
   |###[ HTTP/2 Settings Frame ]###
   |     \settings  \
   |      |###[ HTTP/2 Setting ]###
   |      |  id        = Enable push
   |      |  value     = 0
   |      |###[ HTTP/2 Setting ]###
   |      |  id        = Initial window size
   |      |  value     = 2147483647
   |      |###[ HTTP/2 Setting ]###
   |      |  id        = Header table size
   |      |  value     = 65535
   |      |###[ HTTP/2 Setting ]###
   |      |  id        = Max frame size
   |      |  value     = 16777215

해당 프레임을 보내고 서버에서 설정을 읽어들인다. 서버에서 받은 첫 번째 프레임이 window update 프레임, ping 프레임일 수 있기 때문에 while루프를 돌려 이를 처리한다.

1
2
3
4
5
6
7
8
9
10
11
Client           Server
|                     |
|---------------------|
|                     |
-<<<---<<---<<---<<---| SETTINGS (Server Setting)
|                     |
+--->>--->>--->>--->>>| HTTP2 Magic Packet
|                     |
+--->>--->>--->>--->>>| SETTINGS (ack),  SETTINGS(Client Setting)
|                     |
-<<<---<<---<<---<<---| SETTINGS (ack)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# Loop until an acknowledgement for our settings is received
new_frame = None
while isinstance(new_frame, type(None)) or not (
        new_frame.type == h2.H2SettingsFrame.type_id
        and 'A' in new_frame.flags
    ):
    if not isinstance(new_frame, type(None)):
        # If we received a frame about window management
        if new_frame.type == h2.H2WindowUpdateFrame.type_id:
            # For this tutorial, we don't care about stream-specific windows, but we should :)
            if new_frame.stream_id == 0:
                srv_global_window += new_frame.payload.win_size_incr
        # If we received a Ping frame, we acknowledge the ping,
        # just by setting the ACK flag (A), and sending back the query
        elif new_frame.type == h2.H2PingFrame.type_id:
            new_flags = new_frame.getfieldval('flags')
            new_flags.add('A')
            new_frame.flags = new_flags
            srv_global_window -= len(str(new_frame))
            assert(srv_global_window >= 0)
            res = ss.send(new_frame)
        else:
            assert new_frame.type != h2.H2ResetFrame.type_id \
                and new_frame.type != h2.H2GoAwayFrame.type_id, \
                "Error received; something is not right!"
    try:
        new_frame = ss.recv()
        new_frame.show()
        show_remain(ss)
    except:
        import time
        time.sleep(1)
        new_frame = None

output (window update 프레임을 받고 루프를 더 돌아 세팅(승인) 프레임까지 출력하고 루프를 빠져나왔다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
###[ HTTP/2 Frame ]###
  len       = 0x4
  type      = WinFrm
  flags     = set()
  reserved  = 0
  stream_id = 0
###[ HTTP/2 Window Update Frame ]###
     reserved  = 0
     win_size_incr= 2147418112

### [ NO REMAIN ] ###

###[ HTTP/2 Frame ]###
  len       = 0x0
  type      = SetFrm
  flags     = {'ACK (A)'}
  reserved  = 0
  stream_id = 0

### [ NO REMAIN ] ###

여기까지의 전체 소스는 이곳 에서 다운받아 확인할 수 있다.


스트림의 상태

RFC 7540 섹션을 참고하여 스트림의 상태를 정리하면 다음과 같다.

HTTP2 패킷을 뜯어보다 요청하는 패킷에서END_STREAM 플래그를 설정하여
응답이 오지도 않았는데 왜 벌써 스트림을 닫는지 의문이 들었다.

그러나 이는 스트림의 life cycle을 보고 이해할 수 있었다. 스트림을 half close 상태로 만들어 유휴 상태로 전환해 응답이 오는 동안 컴퓨터 자원을 절약시키는 것으로 추측할 수 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
                                +--------+
                        send PP |        | recv PP
                       ,--------|  idle  |--------.
                      /         |        |         \
                     v          +--------+          v
              +----------+          |           +----------+
              |          |          | send H /  |          |
       ,------| reserved |          | recv H    | reserved |------.
       |      | (local)  |          |           | (remote) |      |
       |      +----------+          v           +----------+      |
       |          |             +--------+             |          |
       |          |     recv ES |        | send ES     |          |
       |   send H |     ,-------|  open  |-------.     | recv H   |
       |          |    /        |        |        \    |          |
       |          v   v         +--------+         v   v          |
       |      +----------+          |           +----------+      |
       |      |   half   |          |           |   half   |      |
       |      |  closed  |          | send R /  |  closed  |      |
       |      | (remote) |          | recv R    | (local)  |      |
       |      +----------+          |           +----------+      |
       |           |                |                 |           |
       |           | send ES /      |       recv ES / |           |
       |           | send R /       v        send R / |           |
       |           | recv R     +--------+   recv R   |           |
       | send R /  `----------->|        |<-----------'  send R / |
       | recv R                 | closed |               recv R   |
       `----------------------->|        |<----------------------'
                                +--------+

          send:   endpoint sends this frame
          recv:   endpoint receives this frame

          H:  HEADERS frame (with implied CONTINUATIONs)
          PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
          ES: END_STREAM flag
          R:  RST_STREAM frame


SEND REQUESTS

이제 페이지를 요청하기 위한 HTTP 헤더를 작성해보려고 한다. HTTP/2 Scapy를 이용하여 해당 헤더를 작성한다.

header 프레임은 다음과 같이 정의된다.

이름길이설명비고
Pad Length1 byte패딩 필드의 길이 지정PADDED 플래그가 설정된 경우에만 사용
E1 bit스트림 의존성이 배타적임을 지정.PRIORITY 플래그가 설정된 경우에만 사용
Stream Dependendency31 bits이 스트림이 의존하고 있는 스트림을 지정PRIORITY 플래그가 설정된 경우에만 사용
Weight1 byte스트림의 상대적 우선순위 (가중치) 지정PRIORITY 플래그가 설정된 경우에만 사용
Header Block Fragment가변메시지의 헤더 
Padding가변Pad Length 필드에 설정된 길이만큼의 바이트가 모두 0으로 설정 

HEADERS 프레임 플래그 종류

이름비트설명
END_STREAM0x01이 프레임이 스트림의 마지막 프레임임을 지정
END_HEADERS0x04이 프레임이 스트림의 마지막 HEADERS 프레임임을 지정.
이 플래그가 설정되지 않았을 경우 CONTINUATION 프레임이 이어서 전송된다.
PADDED0x08Pad Length와 Padding 필드 사용을 지정
PRIORITY0x20이 플래그가 설정되면 E, Stream Dependency, Weight 필드가 사용된다.

와이어 샤크에서 출력되는 모습


HTTP/2가 헤더의 길이를 줄이는 방법

HTTP2 프로토콜이 문자열 헤더를 압축하는 방법이다. RFC 7541 section 8.2

String 헤더와 매칭되는 부분을 매칭되는 바이너리 인덱스로 변환시켜버린다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+-------+-----------------------------+---------------+
| Index | Header Name                 | Header Value  |
+-------+-----------------------------+---------------+
| 1     | :authority                  |               |
| 2     | :method                     | GET           |
| 3     | :method                     | POST          |
| 4     | :path                       | /             |
| 5     | :path                       | /index.html   |
| 6     | :scheme                     | http          |
| 7     | :scheme                     | https         |
| 8     | :status                     | 200           |
| 9     | :status                     | 204           |
| 10    | :status                     | 206           |
| 11    | :status                     | 304           |
| 12    | :status                     | 400           |
| 13    | :status                     | 404           |
| 14    | :status                     | 500           |
| 15    | accept-charset              |               |
| 16    | accept-encoding             | gzip, deflate |
| 17    | accept-language             |               |
| 18    | accept-ranges               |               |
| 19    | accept                      |               |
| 20    | access-control-allow-origin |               |
| 21    | age                         |               |
                   ... (중략) ...
| 60    | via                         |               |
| 61    | www-authenticate            |               |
+-------+-----------------------------+---------------+

따라서 Accept-Encoding 헤더의 경우 0001 0000 으로 변환된다.
(raw : 의 맨 첫번째 값이 \x0f (16 - 0001 0000) 임을 알 수 있다.)
해당 raw의 내용은 추후 설명하도록 하겠다.

1
2
3
4
b'accept-encoding: gzip\n'
idx : None
idx : 16
raw : b'\x0f\x01\x83\x9b\xd9\xab'

RFC 7541 section 6.1
만약 accept-encoding: gzip, deflate와 같이 Header Value가 정해진 내용과 동일하다면
앞에 1을 붙이고 그 뒤에 인덱스를 붙인다.
따라서 1001 0000 된다.

1
2
3
4
5
6
7
8
9
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 | 0   0   1   0   0   0   0 |
+---+---------------------------+

고로 90 이 된다. 문자열이 엄청나게 압축됨을 알 수 있다.

1
2
3
b'accept-encoding: gzip, deflate\n'
idx : 16
raw : b'\x90 (1001 0000)

그렇다면 정의되지 않은 헤더 및 value의 경우는 어떻게 될까?

value는 헤더에 저장할 때 string을 통째로 저장하는 것이 아닌 허프만 압축 알고리즘을 이용하는 HPACK 압축 알고리즘을 사용한다.

이미지 출처 및 허프만 알고리즘 설명 포스트

중복 내용의 반복 비율이 높을수록 높은 압축 효율을 자랑하기에 중복이 상당한 HTTP 헤더의 경우 hpack 압축 알고리즘이 상당한 효과를 볼 수있음을 알 수 있다.

pip3 install hpack 으로 hpack 모듈을 설치하고 간단하게 테스트해보았다.

1
2
3
4
5
6
7
8
9
10
11
12
from hpack import Encoder, Decoder

headers = {"accept-encoding": "gzip"}
e = Encoder()
encoded_bytes = e.encode(headers)

print(encoded_bytes)

d = Decoder()
decoded_headers = d.decode(encoded_bytes)

print(decoded_headers)

output

1
2
b'P\x83\x9b\xd9\xab'
[('accept-encoding', 'gzip')]

해당 내용들이 이렇게 압축이 되는 것을 알 수 있다.

이는 RFC 7541 - 6.2.2 섹션 을 참고하면 된다.

accept-encod: gzip 은 어떻게 들어가는지 확인해보았다.

00 88 19 08 5a d2 b1 6a 21 e4 83 9b d9 ab 와이어샤크 패킷을 뜯어보니 이런 출력결과가 나옴. 이를 RFC 문서를 통해 대입해보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |       0       |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+

  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | 0   0   0   0 | (00)
+---+---+-----------------------+
| 1 | 0   0   0   1   0   0   0 | (88) (옥텟비트가 1이므로 8비트 단위로 쪼갬, length는 8)
+---+---------------------------+
| 0   0   0   1   1   0   0   1 | (19) (header name 정보)
+-------------------------------+
| 0   0   0   0   1   0   0   0 | (08)
+-------------------------------+
| 0   1   0   1   1   0   1   0 | (5a)
+-------------------------------+
| 1   1   0   1   0   0   1   0 | (d2)
+-------------------------------+
| 1   0   1   1   0   0   0   1 | (b1)
+-------------------------------+
| 0   1   1   0   1   0   1   0 | (6a)
+-------------------------------+
| 0   0   1   0   0   0   0   1 | (21)
+-------------------------------+
| 1   1   1   0   0   1   0   0 | (e4) (header name 끝)
+---+---------------------------+
| 1 | 0   0   0   0   0   1   1 | (83) (옥텟비트가 1이므로 8비트 단위로 쪼갬, length는 3)
+---+---------------------------+
| 0   0   0   0   0   0   0   0 | (9b) (header value 정보)
+-------------------------------+
| 0   0   0   0   0   0   0   0 | (d9)
+-------------------------------+
| 0   0   0   0   0   0   0   0 | (ab) (header value 끝)
+-------------------------------+

정상적으로 규약에 맞게 들어가는 것을 확인할 수 있다.

그렇다면 accept-encoding: gzip의 경우는 어째서 0f 01 83 9b d9 ab 일까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+

+---+---------------------------+
| 1 | 0   0   0   0   0   1   1 | (83) (옥텟비트가 1이므로 8비트 단위로 쪼갬, length는 3)
+---+---------------------------+
| 0   0   0   0   0   0   0   0 | (9b) (header value 정보)
+-------------------------------+
| 0   0   0   0   0   0   0   0 | (d9)
+-------------------------------+
| 0   0   0   0   0   0   0   0 | (ab) (header value 끝)
+-------------------------------+

대충 hex 값을 살펴보았을 때 83 9b d9 ab는 유추할 수 있었으나 0f 01 이 뭔지 알 수 없었다.
RFC 문서도 이리저리 찾아보았으나 규격과 정확히 딱 맞아 떨어지는 부분을 찾지 못했다.
아무리 찾아도 해당 내용인데 RFC 7541 Section 5.1 문서였다.

accept-language: ko-KR 의 변환은 51 84 ea 75 b3 6d
accept: text/html 의 변환은 53 87 49 7c a5 89 d3 4d 1f
해당 16진수들은 아래 RFC 규격에 맞게 변환될 수 있는데

1
2
3
4
5
6
7
8
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| ? | ? | ? |       Value       |
+---+---+---+-------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+

인덱스 15번 (accept-charset) 인덱스 16번 (accept-encoding) 은 변환이 특별하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
인덱스 16번 accept encoding:gzip -> 0f 01 83 9b d9 ab
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0   1   1   1   1 | (0f)
+-------------------------------+
| 0   0   0   0   0   0   0   1 | (01)
+---+---------------------------+
| 1 | 0   0   0   0   0   1   1 | (83)
+---+---------------------------+
9b d9 ab 이하 생략

인덱스 15번 accept-charset: utf-8 -> 0f 00 84 b5 32 ac f7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0   1   1   1   1 | (0f)
+---+---------------------------+
| 0   0   0   0   0   0   0   0 | (00)
+---+---------------------------+
| 1 | 0   0   0   0   1   0   0 | (84)
+---+---------------------------+
b5 32 ac f7 이하 생략

15, 16일때만 뒤에 00, 01이 들어가는 것을 봐서 해당 인덱스의 경우 특별한 처리를 해주는 것이 아닐까 추측하고 있었으나… 아니었다. via: 0 헤더 또한 특별한 처리를 해주는 것이 아닌가?

어떤 것은 헤더 값이 첫번째 라인에 바로 넣고, 어떤 것은 0000 1111 + 이하 인덱스로 처리하니 헷갈렸다.
하지만 이는 곧 밝혀지게 되니 지금 당장은 넘어가도록 하자.

1
2
3
4
5
6
7
8
9
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0   1   1   1   1 | (0f)
+---+---------------------------+
| 0   0   1   0   1   1   0   1 | (2D)
+---+---------------------------+
| 0 | 0   0   0   0   0   0   1 | (01)
+---+---------------------------+
| 0   0   1   1   0   0   0   0 | (30)
+-------------------------------+

아래는 헤더 프레임을 만들어내는 코드이다.

1
2
3
4
5
Client           Server
|                     |
|---------------------|
|                     |
+--->>--->>--->>--->>>| Stream 1 (message - req HEADER FRAMES)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
tblhdr = h2.HPackHdrTable()
qry_frontpage = tblhdr.parse_txt_hdrs(
    b''':method GET
:path /
:authority je.iasdf.com
:scheme https
accept-encoding: gzip, deflate
accept-language: ko-KR
accept: text/html
user-agent: Scapy HTTP/2 Module
''',
    stream_id=1,
    max_frm_sz=srv_max_frm_sz,
    max_hdr_lst_sz=srv_max_hdr_lst_sz,
    is_sensitive=lambda hdr_name, hdr_val: hdr_name in ['cookie'],
    should_index=lambda x: x in [
        'x-requested-with',
        'user-agent',
        'accept-language',
        ':authority',
        'accept',
    ]
)
qry_frontpage.show()

output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
###[ HTTP/2 Frame Sequence ]###
  \frames    \
   |###[ HTTP/2 Frame ]###
   |  len       = None
   |  type      = HdrsFrm
   |  flags     = {'End Headers (EH)', 'End Stream (ES)'}
   |  reserved  = 0
   |  stream_id = 1
   |###[ HTTP/2 Headers Frame ]###
   |     \hdrs      \
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 2
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 4
   |      |###[ HPack Literal Header With Incremental Indexing ]###
   |      |  magic     = 1
   |      |  index     = 1
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = Compressed
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(je.iasdf.com)'
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 7
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 16
   |      |###[ HPack Literal Header With Incremental Indexing ]###
   |      |  magic     = 1
   |      |  index     = 17
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = Compressed
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(ko-KR)'
   |      |###[ HPack Literal Header With Incremental Indexing ]###
   |      |  magic     = 1
   |      |  index     = 19
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = Compressed
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(text/html)'
   |      |###[ HPack Literal Header With Incremental Indexing ]###
   |      |  magic     = 1
   |      |  index     = 58
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = None
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(Scapy HTTP/2 Module)'

헤더를 string으로 출력해본다.

1
2
for i in range(max(tblhdr._static_entries.keys()) + 1, max(tblhdr._static_entries.keys()) + 1 + len(tblhdr._dynamic_table)):
    print('Header: "{}" Value: "{}"'.format(tblhdr[i].name(), tblhdr[i].value()))

output

1
2
3
4
Header: "user-agent" Value: "Scapy HTTP/2 Module"
Header: "accept" Value: "text/html"
Header: "accept-language" Value: "ko-KR"
Header: ":authority" Value: "je.iasdf.com"

HTTP2는 멀티플렉싱을 지원한다. (멀티플렉싱 : 하나의 TCP 연결을 통해 여러개의 HTTP 요청을 동시에 처리하는 것)

최상위 (‘/’) 페이지 뿐만 아니라 favicon.ico 등의 리소스도 같이 요청해보자.

1
2
3
4
5
6
Client           Server
|                     |
|---------------------|
|                     |
+--->>--->>--->>--->>>| Stream 1 (message - req HEADER FRAMES)
|                     | Stream 3 (message - req HEADER FRAMES)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
qry_icon = tblhdr.parse_txt_hdrs(
    b''':method GET
:path /public/favicon.ico
:authority je.iasdf.com
:scheme https
accept-encoding: gzip, deflate
accept-language: ko-KR
accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
user-agent: Scapy HTTP/2 Module
content-type: text;
''',
    stream_id=3,
    max_frm_sz=srv_max_frm_sz,
    max_hdr_lst_sz=srv_max_hdr_tbl_sz,
    is_sensitive=lambda hdr_name, hdr_val: hdr_name in ['cookie'],
    should_index=lambda x: x in [
            'x-requested-with',
            'user-agent',
            'accept-language',
            ':authority',
            'accept',
        ]
)
qry_icon.show()

혹시 출력 결과에서 첫 번째 최상위 페이지를 요청했을 때와 다른 점이 보이는가? HTTP2가 기존의 헤더가 있는지 확인하고 있을 경우 압축시켜버려 헤더의 크기가 줄었다.

다이나믹 테이블에 기존 헤더의 정보를 stack 형식으로 저장한다. RFC 7541 section appendix-C.3

첫 번째 페이지를 요청할 때 사용했던 헤더들을 dynamic table에 다음과 같이 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
:method GET
:path /
:authority je.iasdf.com
:scheme https
accept-encoding: gzip, deflate
accept-language: ko-KR
accept: text/html
user-agent: Scapy HTTP/2 Module

dynamic table

두번째 최신 헤더
idx : 0 - user-agent: Scapy HTTP/2 Module
idx : 1 - accept: text/html
idx : 2 - accept-language: ko-KR
idx : 3 - :authority: je.iasdf.com

이후 두 번째 헤더에서부터는 해당 다이나믹 테이블을 참고하여 압축시킨다.

1
2
3
4
5
6
7
8
9
:method GET
:path /public/favicon.ico
:authority je.iasdf.com                            -> dynamic table에 있음!
:scheme https
accept-encoding: gzip, deflate
accept-language: ko-KR                             -> dynamic table에 있음!
accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
user-agent: Scapy HTTP/2 Module                    -> dynamic table에 있음!
content-type: text;

이렇게 다이나믹 테이블의 _static_table의 끝번 61에 다이나믹 테이블 idx + 1을 붙여 인덱스를 생성한다. 즉 _static_table_last_index(61) + dynamic_table_idx + 1 로 압축된 인덱스를 생성할 수 있다.

그런데 이상한 점을 발견. 왜 user-agent: Scapy HTTP/2 Module의 index가 63이지? 62여야 하는 게 아닌가?

1
2
3
4
5
6
7
8
user-agent 예상 값
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 62
user-agent 실제 값
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 63

해당 이유는 다이나믹 테이블에 accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 해당 헤더 부분이 추가되었기 때문이다.

accept-language: ko-KR 헤더까지는 다이나믹 테이블의 인덱스가 변경되지 않아 61 + 2(accept-lang) + 164가 되었지만

accept: ... 헤더는 다이나믹 테이블로 정보가 추가되어 인덱스가 변경되었다.
따라서 인덱스가 다음과 같이 변경되었다.

1
2
3
4
5
6
dynamic table
idx : 0 - accpet: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8
idx : 1 - user-agent: Scapy HTTP/2 Module
idx : 2 - accept: text/html
idx : 3 - accept-language: ko-KR
idx : 4 - :authority: je.iasdf.com

변경된 다이나믹 테이블의 인덱스를 기준으로 user-agent 인덱스를 생성하다보니 61 + 1 + 163 인덱스를 가지게 되었음을 알 수 있다.

다이나믹 테이블을 유심히 관찰하다 알게된 사실. 다이나믹 테이블에 들어가는 헤더의 경우와 다이나믹 테이블에 들어가지 않는 헤더는 binary 표현이 달랐다.

다이나믹 테이블에 들어가는 헤더의 경우 해당 형식을 가지지만

1
2
3
4
5
6
7
8
9
`accept-language: ko-KR` 의 변환은 `51 84 ea 75 b3 6d` \
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 | 0 | 1   0   0   0   1 | (index 17)
+---+---+---+-------------------+
| 1 | 0   0   0   0   1   0   0 |
+---+---------------------------+
| value string (ea, 75, b3, 6d) |
+-------------------------------+

다이나믹 테이블에 들어가지 않는 헤더의 경우 (accept-encoding: gzip, deflate)

1
2
3
4
5
6
7
8
9
10
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0   1   1   1   1 | (0f)
+---+---+---+-------------------+
| 0   0   0   0   0   0   0   1 | (index id - 15) 값이 저장 따라서 1
+---+---------------------------+
| 1 | 0   0   0   0   0   1   1 | (83)
+---+---------------------------+
| value string (9b, d9, ab)     |
+-------------------------------+

해당 형식으로 저장됨을 알 수 있다.

따라서 최종 헤더 값은 다음과 같다.

output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
###[ HTTP/2 Frame Sequence ]###
  \frames    \
   |###[ HTTP/2 Frame ]###
   |  len       = None
   |  type      = HdrsFrm
   |  flags     = {'End Headers (EH)', 'End Stream (ES)'}
   |  reserved  = 0
   |  stream_id = 3
   |###[ HTTP/2 Headers Frame ]###
   |     \hdrs      \
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 2
   |      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
   |      |  magic     = 0
   |      |  never_index= Don't Index
   |      |  index     = 4
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = Compressed
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(/favicon.ico)'
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 65
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 7
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 16
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 64
   |      |###[ HPack Literal Header With Incremental Indexing ]###
   |      |  magic     = 1
   |      |  index     = 19
   |      |  \hdr_value \
   |      |   |###[ HPack Header String ]###
   |      |   |  type      = Compressed
   |      |   |  len       = None
   |      |   |  data      = 'HPackZString(image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8)'
   |      |###[ HPack Indexed Header Field ]###
   |      |  magic     = 1
   |      |  index     = 63

파비콘 요청이 기존의 헤더에 종속적임(의존성이 존재함)을 알린다.

이후 프레임들을 조합하여 요청을 보낸다.

1
2
3
4
5
6
Client           Server
|                     |
|---------------------|
|                     |
+--->>--->>--->>--->>>| [ Stream 1 (message - req, HEADER FRAMES (/)) ]
+                     | [ Stream 3 (message - req, HEADER FRAMES (/public/favicon.ico)) ]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
real_qry_icon = h2.H2Frame(
    stream_id=qry_icon.frames[0][h2.H2Frame].stream_id,
    flags={'+'}.union(qry_icon.frames[0][h2.H2Frame].flags),
) / h2.H2PriorityHeadersFrame(
    hdrs=qry_icon.frames[0][h2.H2HeadersFrame].hdrs,
    stream_dependency=1,
    weight=32,
    exclusive=0
)
real_qry_icon.show()


h2seq = h2.H2Seq()
h2seq.frames = [
    qry_frontpage.frames[0],
    real_qry_icon
]
srv_global_window -= len(str(h2seq))
assert(srv_global_window >= 0)
send_data = ss.send(h2seq)
print("send_data :", send_data)

output

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
###[ HTTP/2 Frame ]###
  len       = None
  type      = HdrsFrm
  flags     = {'End Headers (EH)', 'End Stream (ES)', 'Priority (+)'}
  reserved  = 0
  stream_id = 3
###[ HTTP/2 Headers Frame with Priority ]###
     exclusive = 0
     stream_dependency= 1
     weight    = 32
     \hdrs      \
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 2
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 4
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = None
      |   |  data      = 'HPackZString(/public/favicon.ico)'
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 65
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 7
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 16
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 64
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 19
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = None
      |   |  data      = 'HPackZString(image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8)'
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 63

send_data : 148

SCAPY가 헤더를 만드는 과정 scapy/scapy/contrib/http2.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# For each line in the headers str to parse
for hdr_line in sio:
    hdr_name, hdr_value = self._parse_header_line(hdr_line)
    if hdr_name is None:
        continue

    new_hdr, new_hdr_len = self._convert_a_header_to_a_h2_header(
        hdr_name, hdr_value, is_sensitive, should_index
    )
    new_hdr_bin_len = len(raw(new_hdr))

    if register and isinstance(new_hdr, HPackLitHdrFldWithIncrIndexing):  # noqa: E501
        self.register(new_hdr)

    # The new header binary length (+ base frame size) must not exceed
    # the maximum frame size or it will just never fit. Also, the
    # header entry length (as specified in RFC7540 par6.5.2) must not
    # exceed the maximum length of a header fragment or it will just
    # never fit
    if (new_hdr_bin_len + base_frm_len > max_frm_sz or
            (max_hdr_lst_sz != 0 and new_hdr_len > max_hdr_lst_sz)):
        raise Exception('Header too long: {}'.format(hdr_name))

    if (max_frm_sz < len(raw(cur_frm)) + base_frm_len + new_hdr_len or
        (
            max_hdr_lst_sz != 0 and
            max_hdr_lst_sz < cur_hdr_sz + new_hdr_len
    )
    ):
        flags = set()
        if isinstance(cur_frm, H2HeadersFrame) and not body:
            flags.add('ES')
        ret.frames.append(H2Frame(stream_id=stream_id, flags=flags) / cur_frm)  # noqa: E501
        cur_frm = H2ContinuationFrame()
        cur_hdr_sz = 0

    hdr_list = cur_frm.hdrs
    hdr_list += new_hdr
    cur_hdr_sz += new_hdr_len
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def _convert_a_header_to_a_h2_header(self, hdr_name, hdr_value, is_sensitive, should_index):  # noqa: E501
    # type: (str, str, Callable[[str, str], bool], Callable[[str], bool]) -> Tuple[HPackHeaders, int]  # noqa: E501
    """ _convert_a_header_to_a_h2_header builds a HPackHeaders from a header
    name and a value. It returns a HPackIndexedHdr whenever possible. If not,  # noqa: E501
    it returns a HPackLitHdrFldWithoutIndexing or a
    HPackLitHdrFldWithIncrIndexing, based on the should_index callback.
    HPackLitHdrFldWithoutIndexing is forced if the is_sensitive callback
    returns True and its never_index bit is set.
    """

    # If both name and value are already indexed
    idx = self.get_idx_by_name_and_value(hdr_name, hdr_value)
    if idx is not None:
        return HPackIndexedHdr(index=idx), len(self[idx])

    # The value is not indexed for this headers

    _hdr_value = self._optimize_header_length_and_packetify(hdr_value)

    # Searching if the header name is indexed
    idx = self.get_idx_by_name(hdr_name)
    if idx is not None:
        return HPackLitHdrFldWithoutIndexing(header_name=idx, header_value=_hdr_value, ...)
            or HPackLitHdrFldWithIncrIndexing(header_name=idx, header_value=_hdr_value, ...)

    # Failed to parse idx
    _hdr_name = self._optimize_header_length_and_packetify(hdr_name)
    return HPackLitHdrFldWithoutIndexing(hdr_name=_hdr_name,hdr_value=_hdr_value, ...)
        or HPackLitHdrFldWithIncrIndexing(hdr_name=_hdr_name,hdr_value=_hdr_value, ...)

RECV DATA

RECV DATA에서는 DATA FRAME을 통해 데이터를 받을 수 있다.

DATA 프레임은 다음과 같이 정의된다.

이름길이설명비고
Pad Length1 byte패딩 필드의 길이 지정PADDED 플래그가 설정된 경우에만 사용
Data가변프레임의 내용 
Padding가변Pad Length 필드에 설정된 길이만큼의 바이트가 모두 0으로 설정됨 

DATA 프레임 플래그 종류

이름비트설명
END_STREAM0x01이 프레임이 스트림의 마지막 프레임임을 지정
PADDED0x08Pad Length와 Padding 필드 사용을 지정

요청했던 / 페이지와 /public/favicon.ico 를 받아와보자.

1
2
3
4
5
6
7
8
9
10
11
12
Client           Server
|                     |
|---------------------|
|                     |
+--->>--->>--->>--->>>| [ Stream 1 (message - req, HEADER FRAMES (/)) ]
+                     | [ Stream 3 (message - req, HEADER FRAMES (/public/favicon.ico)) ]
|                     |
|                     |
-<<<---<<---<<---<<---| [ Stream 1 (message - res, HEADER FRAMES (/) 200 OK) ]
-                     | [ Stream 3 (message - res, HEADER FRAMES (/public/favicon.ico) 200 OK) ]
|                     |
|                     |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# The stream variable will contain all read frames; we will read on until stream 1 and stream 3 are closed by the server.
stream = h2.H2Seq()
# Number of streams closed by the server
closed_stream = 0

new_frame = None
while True:
    if not isinstance(new_frame, type(None)):
        if new_frame.stream_id in [1, 3]:
            stream.frames.append(new_frame)
            if 'ES' in new_frame.flags:
                closed_stream += 1
        # If we read a PING frame, we acknowledge it by sending the same frame back, with the ACK flag set.
        elif new_frame.stream_id == 0 and new_frame.type == h2.H2PingFrame.type_id:
            new_flags = new_frame.getfieldval('flags')
            new_flags.add('A')
            new_frame.flags = new_flags
            ss.send(new_frame)

        # If two streams were closed, we don't need to perform the next operations
        if closed_stream >= len(h2seq.frames):
            break
    try:
        print("recv!")
        new_frame = ss.recv()
        new_frame.show()
    except:
        import time
        time.sleep(1)
        new_frame = None

output (print("recv!") 출력은 생략하였음)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
###[ HTTP/2 Frame ]###
  len       = 0x91
  type      = HdrsFrm
  flags     = {'End Headers (EH)'}
  reserved  = 0
  stream_id = 1
###[ HTTP/2 Headers Frame ]###
     \hdrs      \
      |###[ HPack Dynamic Size Update ]###
      |  magic     = 1
      |  max_size  = 0
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 8
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 54
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 17
      |   |  data      = 'HPackZString(nginx/1.18.0 (Ubuntu))'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 33
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 22
      |   |  data      = 'HPackZString(Wed, 30 Nov 2022 01:34:26 GMT)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 31
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 7
      |   |  data      = 'HPackZString(text/html)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 28
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Literal
      |   |  len       = 5
      |   |  data      = 'HPackLiteralString(15219)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 44
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 22
      |   |  data      = 'HPackZString(Mon, 28 Nov 2022 04:55:28 GMT)'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 3
      |   |  data      = 'HPackZString(etag)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 13
      |   |  data      = 'HPackZString("63843f40-3b73")'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 9
      |   |  data      = 'HPackZString(cache-control)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 13
      |   |  data      = 'HPackZString(no-store, no-cache)'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 9
      |   |  data      = 'HPackZString(accept-ranges)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 4
      |   |  data      = 'HPackZString(bytes)'

###[ HTTP/2 Frame ]###
  len       = 0x2000
  type      = DataFrm
  flags     = set()
  reserved  = 0
  stream_id = 1
###[ HTTP/2 Data Frame ]###
     data      = '<!DOCTYPE html>\n<html lang="en-us">\n\n  <head>\n  <link href="http://gmpg.org/xfn/11" rel="profile">\n  <meta http-equiv="X-UA-Compatible" content="IE=edg ...(이하 데이터 생략)...'
###[ HTTP/2 Frame ]###
  len       = 0x1b73
  type      = DataFrm
  flags     = {'End Stream (ES)'}
  reserved  = 0
  stream_id = 1
###[ HTTP/2 Data Frame ]###
     data      = 'ost">\n    <h1 class="post-ti ...(이하 데이터 생략)...'

###[ HTTP/2 Frame ]###
  len       = 0x91
  type      = HdrsFrm
  flags     = {'End Headers (EH)'}
  reserved  = 0
  stream_id = 3
###[ HTTP/2 Headers Frame ]###
     \hdrs      \
      |###[ HPack Indexed Header Field ]###
      |  magic     = 1
      |  index     = 8
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 54
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 17
      |   |  data      = 'HPackZString(nginx/1.18.0 (Ubuntu))'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 33
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 22
      |   |  data      = 'HPackZString(Wed, 30 Nov 2022 01:34:26 GMT)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 31
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 9
      |   |  data      = 'HPackZString(image/x-icon)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 28
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Literal
      |   |  len       = 5
      |   |  data      = 'HPackLiteralString(15086)'
      |###[ HPack Literal Header With Incremental Indexing ]###
      |  magic     = 1
      |  index     = 44
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 22
      |   |  data      = 'HPackZString(Mon, 28 Nov 2022 04:55:28 GMT)'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 3
      |   |  data      = 'HPackZString(etag)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 12
      |   |  data      = 'HPackZString("63843f40-3aee")'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 9
      |   |  data      = 'HPackZString(cache-control)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 13
      |   |  data      = 'HPackZString(no-store, no-cache)'
      |###[ HPack Literal Header Without Indexing (or Never Indexing) ]###
      |  magic     = 0
      |  never_index= Don't Index
      |  index     = 0
      |  \hdr_name  \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 9
      |   |  data      = 'HPackZString(accept-ranges)'
      |  \hdr_value \
      |   |###[ HPack Header String ]###
      |   |  type      = Compressed
      |   |  len       = 4
      |   |  data      = 'HPackZString(bytes)'

###[ HTTP/2 Frame ]###
  len       = 0x2000
  type      = DataFrm
  flags     = set()
  reserved  = 0
  stream_id = 3
###[ HTTP/2 Data Frame ]###
     data      = '\x00\x00\x01\x00\x03\x0000\x00\x00 ...(이하 데이터 생략)...'
###[ HTTP/2 Frame ]###
  len       = 0x1aee
  type      = DataFrm
  flags     = {'End Stream (ES)'}
  reserved  = 0
  stream_id = 3
###[ HTTP/2 Data Frame ]###
     data      = 'x^\\xb0\\xc4w\\xe3\\xaf\\xc2v\\xff\\xaf ...(이하 데이터 생략)...'

정상적으로 받아와졌다.

해당 페이지는 아직 Window Size 설정이 필요하지 않은 단순한 텍스트 페이지라 Window Update Frame을 직접 조작하지 않아도 모든 응답을 받을 수 있었다.


대용량 파일은 어떻게?

그렇다면 대용량의 첨부 파일을 다운받아보자.
헤더를 아래와 같이 주고 그대로 응답 코드의 소스 변경 없이 그대로 요청을 보냈더니…

1
2
3
4
5
6
7
8
9
10
b''':method GET
:path /text/vim.tgz
:authority je.iasdf.com
:scheme https
accept-encoding: gzip
accept-language: ko-KR
accept: text/html
via: 0
user-agent: Scapy HTTP/2 Module
'''

output

1
2
3
4
5
6
7
8
9
10
recv!
recv!
recv!
recv!
recv!
recv!
recv!
recv!
recv!
recv!

응답 데이터가 전부 받아와지지 않았고 클라이언트는 서버에서 데이터를 주기만을 무한정 대기하고 있다.

이는 HTTP2의 흐름제어 구조 때문인데 HTTP2의 Window Size를 업데이트하지 않았기 때문에 서버는 상대측이 아직 데이터를 받아 처리하지 못했다고 생각하고 데이터를 보내지 않는다.

따라서 Window Size를 업데이트해주어야 한다. Window Update Frame 표에 따라 윈도우 사이즈를 업데이트하는 코드를 추가한다.

이름길이비고
R1 bit예약된 비트
Window Size Increment31 bits현재 윈도우에서 증가시킬 바이트 수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
new_frame = None
window_size = 1 << 16 - 1 #65535
cur_size = 0
while True:
    print("recv")
    if not isinstance(new_frame, type(None)):
        if new_frame.stream_id in [1, 3]:
            stream.frames.append(new_frame)
            if 'ES' in new_frame.flags:
                closed_stream += 1
        # If we read a PING frame, we acknowledge it by sending the same frame back, with the ACK flag set.
        elif new_frame.stream_id == 0 and new_frame.type == h2.H2PingFrame.type_id:
            new_flags = new_frame.getfieldval('flags')
            new_flags.add('A')
            new_frame.flags = new_flags
            ss.send(new_frame)

        # If two streams were closed, we don't need to perform the next operations
        if closed_stream >= len(h2seq.frames):
            break
    # Window Update Frame
    if cur_size >= window_size:
        cur_size = 0
        window_update_frame = h2.H2Frame()/h2.H2WindowUpdateFrame()
        window_update_frame.reversed = 0
        window_update_frame.win_size_incr = window_size
        h2seq = h2.H2Seq()
        h2seq.frames = [
            window_update_frame
        ]
        h2seq.show()
        ss.send(h2seq)
    try:
        print("recv")
        new_frame = ss.recv()
        cur_size += len(new_frame)
    except:
        import time
        time.sleep(1)
        new_frame = None

위 로직대로라면 파일 사이즈가 30만이라고 가정할 때 아래와 같이 동작할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
file size : 300,000
window size : 65,536

C <-----     65536     <----- S
C -----> WINDOW UPDATE -----> S
C <-----     65536     <----- S
C -----> WINDOW UPDATE -----> S
C <-----     65536     <----- S
C -----> WINDOW UPDATE -----> S
C <-----     65536     <----- S
C -----> WINDOW UPDATE -----> S
C <-----     37856     <----- S
C -----> WINDOW UPDATE -----> S
(...)

이번에는 정상적으로 데이터를 받아왔다. 출력 결과는 이전 요청 내용과 비슷하므로 생략하도록 하겠다.

connection 레벨에서의 초기 윈도우 사이즈 (Initial Window Size)는 65536 (64K) 이다. 그러나 초기 단계에서부터 윈도우 사이즈를 크게 하고 싶다면 SETTINGS 프레임 뒤 바로 WINDOW UPDATE 프레임을 보내면 된다.

nginx가 나(클라이언트)에게 위 방식으로 윈도우 사이즈를 업데이트해주고 있다.

윈도우 사이즈와 관련된 자세한 내용은 아래 문서에서 확인할 수 있다.

https://www.monitorapp.com/ko/http2-%ED%9D%90%EB%A6%84%EC%A0%9C%EC%96%B4/

여기까지의 전체 소스는 이곳 에서 확인할 수 있다.

이외의 특징

우선순위

웹페이지의 랜더링을 효과적으로 처리하기 위해 우선순위를 지정할 수 있다. 이러한 우선순위를 지정하는 플래그로 PRIORITY 를 사용한다.

이름길이설명비고
E1 bit스트림 의존성이 배타적임을 지정. 
Stream Dependendency31 bits이 스트림이 의존하고 있는 스트림을 지정 
Weight1 byte스트림의 상대적 우선순위 (가중치) 지정 

서버 푸시

서버가 클라이언트에게 클라이언트가 요청하지 않은 리소스를 전송하겠다고 알리는 기능이다. 이 프레임은 클라이언트가 전송하는 HEADERS 프레임에 대응하는 서버측 프레임이다.

이름길이설명비고
Pad Length1 byte패딩 필드의 길이 지정PADDED 플래그가 설정된 경우에만 사용
R1 bit예약된 비트 
Promised Stream ID31 bits송신자가 사용할 Stream ID를 지정 
Header Block Fragment가변푸시된 메시지의 헤더 
Padding가변Pad Length 필드에 설정된 길이만큼의 바이트가 모두 0으로 설정 

HEADERS 프레임 플래그 종류

이름비트설명
END_HEADERS0x04이 프레임이 스트림의 마지막 HEADERS 프레임임을 지정.
이 플래그가 설정되지 않았을 경우 CONTINUATION 프레임이 이어서 전송된다.
PADDED0x08Pad Length와 Padding 필드 사용을 지정

프레임 설명

출처

FRAME의 헤더

DATA FRAME

HEADERS FRAME

PRIORITY FRAME

RST FRAME

클라이언트나 서버를 즉시 종료하기 위해 사용되는 프레임이다. 이 프레임은 보통 오류 상태에 대한 응답을 설명한다. 오류 상태 (Error Code)의 크기는 4bytes 이며, 오류 상태는 RFC 7540 section 7 을 참고하라.

SETTINGS FRAME

PUSH PROMISE FRAME

PING FRAME

핑 프레임은 엔드포인트간 왕복 시간을 측정하는 데 사용한다. PING 프레임은 ACK 플래그 (0x01) 하나만 존재한다. 엔드포인트는 ACK가 설정되지 않은 핑 프레임을 수신하면 ACK 플래그를 설정하고 동일한 Opaque Data를 포함한 핑 프레임을 회신한다.

핑 프레임은 상호간의 연결 수준을 테스트하는 프레임이기에 다른 어떤 프레임과도 연관이 없다. 따라서 스트림의 식별자는 반드시 0x00이어야 한다.

GOAWAY FRAME

GOAWAY 프레임은 연결을 정상 종료하기 위해 사용한다. 이 프레임 또한 연결 수준의 프레임이다. 따라서 스트림 식별자는 반드시 0x0 이어야 한다.

GOAWAY 프레임을 전송하면, 엔드포인트는 프레임의 수신 여부와 GOAWAY 프레임을 전송하는 이유를 수신자에게 알릴 수 있다.

문제가 발생한 경우, 오류 코드를 설정한다. (오류 코드는 위 RST_STREAM 내용 참조)
Last Stream ID에는 처리가 완료된 가장 높은 스트림 ID가 설정된다.

오류 없이 연결을 해제하려는 경우, Last Stream ID 값을 2^31-1 로 설정하여 NO_ERROR (0x0) 코드를 전송한다.

이름길이설명비고
R1 bit예약된 비트 
Last Stream ID31 bits송신자가 사용할 Stream ID를 지정 
Error Code4 bytesPad Length 필드에 설정된 길이만큼의 바이트가 모두 0으로 설정 
Additional Debug Data가변푸시된 메시지의 헤더 

WINDOW UPDATE FRAME

CONTINUATION FRAME

CONTINUATION 프레임은 HEADERS, PUSH_PROMISE, CONTINUATION 프레임의 추가 헤더로 구성된다.

이름길이설명
Header Block Fragment가변메세지의 헤더 (HEADERS 프레임과 동일)

HTTP2를 조작할 수 있는 툴

  • scapy
    • 특징 : 파이썬 소스로 직접 조작할 수 있는 라이브러리
  • mitmproxy
    • 특징 : HTTP2 프로토콜을 show하고 타이밍을 조작할 수 있는 프록시 서버
  • Fiddler
    • 특징 : HTTP2 프로토콜을 show하고 타이밍을 조작할 수 있는 프록시 서버

와이어샤크에서 HTTP2 패킷을 보는 방법

SSLKEYLOG 설정

윈도우

  • 시스템 > 속성(고급 시스템 설정) (Windows키 + R 눌러 sysdm.cpl 치고 엔터 > 고급)
  • 환경변수
  • XXX에 대한 사용자 변수에서 새로만들기 버튼 클릭

1
2
변수 이름 : SSLKEYLOGFILE
변수 값 : C:\Users\사용자이름\sslkeylog.log

저장

맥, 리눅스, WSL

~/.bashrc 또는 ~/.zshrc 아래 내용 추가 (배쉬를 사용한다면 .bashrc, 즈쉬를 사용한다면 .zshrc)

1
export SSLKEYLOGFILE=/path/to/sslkeylog.log

나는 윈도우 WSL도 사용하므로 윈도우 wsl 우분투의 ~/.zshrc에 아래 내용을 추가했다.

1
export SSLKEYLOGFILE=/mnt/c/Users/<user_name>/sslkeylog.log

이렇게 하면 윈도우 wsl에서 curl 사용 시에도 sslkeylog.log 파일에 키를 저장할 수 있다.

변경된 ~/.bashrc 또는 ~/.zshrc 적용

1
source ~/.zshrc

와이어샤크 실행 및 설정

  • 상단의 Edit > Preferences
  • Preferences 창에서 Protocols > TLS
  • (Pre)-Master-Secret log filename에 sslkeylog.log 파일 경로 입력


참고 문헌

HTTP2 RFC 7540

HTTP2 RFC 7541

http2 툴

scapy, scapy-http2 예제, scapy http2 소스, scapy http2 api

mitmproxy

책 : 러닝 HTTP/2: 핵심만 쏙쏙, HTTP/2 적용 실무 가이드

HTTP2 프레임 표

This post is licensed under CC BY 4.0 by the author.