캠퍼스 내 모든 프로젝터와 카메라 제어하기 위한 네트워크 스캐닝 최적화 과정
요약
캠퍼스 내 장치 제어를 위한 네트워크 스캐닝 시스템을 Rust로 구현하며 겪은 최적화 과정과 시행착오를 다룹니다. 순열 생성 방식의 변경, 비동기 작업의 메모리 누수 해결, 그리고 DNS 서버 부하로 인한 네트워크 장애 사례를 상세히 기술합니다.
핵심 포인트
- Rust의 itertools 대신 정수를 36진수로 변환하는 방식이 순열 생성 속도 면에서 더 효율적임
- Bash 스크립트를 활용해 프로세스 오프셋을 분산함으로써 병렬 처리 효율을 높임
- Tokio 비동기 스택에서 발생하는 메모리 누수를 방지하기 위해 쿼리 생성 속도를 제어하는 차단(backpressure) 메커니즘 적용
- 과도한 DNS 쿼리로 인해 캠퍼스 전체 네트워크 장애를 유발할 수 있는 위험성 확인
저의 첫 번째 시도에서는 Rust의 itertools 크레이트(crate)에 있는 다중 데카르트 곱(multi cartesian product) 함수를 사용했습니다. 하지만 얼마 지나지 않아 정수를 증가시킨 후 이를 36진수(base 36)로 변환하는 것이 훨씬 빠르다는 것을 깨달았습니다. 순열(permutation) 스레드의 속도가 상당히 중요해졌는데, 이는 해당 부분이 대량의 CPU 작업을 수행하며 쉽게 병렬화(parallelize)할 수 없는 유일한 부분이기 때문입니다. Python 버전에서는 동일한 순열을 사용하여 접두사(prefix)를 생성한 다음 각 접두사를 스레드에 할당했지만, Rust에서 이 작업을 직접 처리하고 싶지는 않았습니다. 대신 Bash 스크립트를 사용하여 프로그램에 대해 여러 프로세스를 생성하고, 각 프로세스에 전체 프로세스 수와 특정 오프셋(offset)을 알려주었습니다. 각 프로세스는 자신의 오프셋 값에서 시작하여 (1 대신) 전체 프로세스 수만큼 IP를 나타내는 정수를 증가시킴으로써, 모든 프로세스 사이의 모든 값을 커버하도록 했습니다.
Rust에서는 DNS 서버 연결을 처리하는 UDP 포트에 직접 접근할 수 있었으며, 이는 읽기(reading)와 쓰기(writing)가 서로 다른 비동기 함수(async functions) 내에서 발생할 수 있음을 의미했습니다. 저는 리더(reader)가 제가 요청을 보낸 횟수만큼 소켓으로부터 읽을 때까지 루프를 계속하도록 할당하였고, NXDOMAIN(도메인이 존재하지 않음)으로 응답하지 않은 것들만 출력하도록 했습니다. 이 부분을 처리하는 것이 상당히 중요한데, 초기에는 도메인을 필터링하기 위해 grep을 사용했으나 이것이 프로세스 자체만큼이나 많은 CPU를 소모하기 시작했기 때문입니다. 라이터(writer)는 상당히 간단합니다. 정수를 36진수 값으로 변환하고, DNS 쿼리를 소켓에 쓴 다음 종료됩니다.
어느 시점에, 프로세스의 RAM 사용량이 수백 GB까지 치솟는 상당히 심각한 메모리 누수 (memory leak) 문제를 겪었습니다. 이는 프로세스가 끝날 때 모든 라이터 (writer) 함수가 종료되기를 기다리기 위해, 각 라이터 함수 호출을 리스트에 추가했기 때문에 발생했습니다. 이를 해결하기 위해 처음에는 이 핸들 (handles)들을 버리고 대신 리더 (reader)가 완료될 때까지 기다리는 방식을 시도했지만, 이 방법으로도 누수를 막을 수 없었습니다. 저는 쿼리 (queries)를 실행을 위해 이동시키는 속도보다 더 빠르게 생성 (spawning)하고 있었기 때문에, 실제로는 Rust 비동기 라이브러리 중 하나인 tokio 내부의 비동기 스택 (async stack)에서 누수가 발생하고 있다는 결론을 내렸습니다. 이 부분을 해결하기 위해, 일정 수치에 도달하면 쿼리를 차단하고 모든 작업이 완료된 후에 다시 진행하도록 했습니다. 완벽한 해결책은 비동기 스택 (async stack)을 항상 일정 크기로 유지하는 것이겠지만, 실제 상황에서는 프로그램의 많은 부분을 다시 작성해야 하는 반면 성능 향상은 그리 크지 않을 것이기에 현재 상태를 유지했습니다.
최적화를 통해 결국 스레드 2개당 200 Mibps까지 성능을 끌어올렸습니다 (제가 작업하던 서버는 500개 스레드로 제한되어 있고, 어차피 사용할 수 있는 코어가 96개뿐이었기에 스레드 수는 상당히 중요했습니다). 이를 480개 스레드로 계산하면 약 50.33 Gbps가 나오지만, 최대 용량으로 테스트한 마지막 버전은 4.04 Gbps에 그쳤으며, 어차피 저는 약 96개 스레드(또는 약 10.06 Gbps)의 용량으로 제한되었을 것입니다.
그러던 중 DNS 서버가 더 이상 감당하지 못하고 중단되는 임계점에 도달했습니다. 나중에 알게 된 사실이지만, 이로 인해 관리형 컴퓨터들이 네트워크 드라이브를 마운트하기 위한 DNS 조회 (DNS lookup)를 할 수 없게 되어 캠퍼스 전체에 약 15분 동안 장애가 발생했습니다. 이 사건 이후 IT 부서에서는 저에게 DNS 서버에 스팸을 보내는 행위를 멈춰달라고 정중하게 요청했고, 저는 그렇게 했습니다.
IT 부서는 어떻게 제가 범인인지 알았을까요? 제가 2주 동안이나 떠들고 다녔거든요!
이제 이를 통해 상당한 양의 서브도메인 (subdomains)을 얻을 수 있었지만, $37^n$개의 쿼리를 처리하는 것이 비현실적이 되는 지점까지였습니다. 다행히, 여기서 저는 또 다른 비밀스러운 네 번째 옵션을 배우게 되었습니다:
이것은 IP를 다시 도메인으로 매핑하는 DNS 레코드의 또 다른 형태입니다. 예를 들어, 10.0.0.0을 meow.mines.edu로 다시 매핑할 수 있습니다. 이는 제 네트워크 내에 할당된 IP의 개수만큼만 스캔하면 된다는 것을 의미합니다. 이 부분을 위해, 저는 제가 알고 있는 대학 소유의 도메인이나 네트워크 내에 있는 모든 도메인에 대해 DNS 쿼리 (DNS query)를 보내는 훨씬 더 간단한 Rust 스크립트를 작성했습니다. 이것은 꽤 잘 작동했고, 네트워크상의 우스꽝스러운 장치들을 볼 수 있었습니다. 불행히도 computer-precision-tower-5810과 같이 흥미롭지 않은 것들이 대부분이었습니다 :(
이제 모든 컴퓨터를 알게 되었으니... 그것들로 무엇을 할 수 있을까요? 보통의 경우 네트워크는 IT 서버가 아닌 다른 무엇인가에 대해 연결을 여는 것을 허용하지 않습니다. 제가 자세히 다루지는 않겠지만, 특정한 상황에서는 이를 허용하기도 합니다. 이것은 저를 더 알고 싶게 만들었고, 그래서 포트 스캐너 (port scanner)를 만들기 시작했습니다.
이는 네트워킹을 위해 tokio의 TcpStream 구현을 사용하여, 단순히 연결이 가능한지 확인한 후 연결을 끊는 간단한 것에서 시작되었습니다. /16 서브넷 (subnet) 내의 모든 IP를 열거하고 포트 세트를 스캔했으며, 한 번에 기기당 약 4,000개의 포트만 스캔되도록 차단(blocking)했습니다. 이것은 잘 작동했지만, 어디에 도달하기까지 너무 많은 CPU를 소모했고, 저는 더 빨라질 수 있다는 것을 알고 있었습니다.
이 무렵 저는 커널의 네트워크 스택 (network stack)을 (다양한 정도로) 우회할 수 있는 네트워킹 소켓 생성을 허용하는 Linux 커널의 일부인 AF_XDP를 조사하기 시작했고, 이를 기반으로 convoy라는 새로운 프로그램을 작성했습니다. 커널은 NIC 드라이버와 인터페이스하기 위해 4개의 큐 (queue)와 Umem이라고 불리는 메모리 섹션을 생성합니다. convoy의 경우, 가장 복잡한 상호작용은 Umem, TX 큐 (TX queue), 그리고 완료 큐 (Completion queue) 사이의 상호작용에서 발생합니다. Umem이 할당된 후, 패킷이 그곳에 기록되며 해당 패킷에 대응하는 Umem 내의 오프셋 (offset, 프레임 설명이라고도 함)이 TX 큐를 통해 드라이버로 전송됩니다. 해당 패킷들이 처리되면, 드라이버는 convoy에 오프셋을 다시 보내어 더 많은 패킷을 쓸 수 있도록 합니다.
이번 버전에서는 수평적 스캐닝 (horizontal scanning)도 병행하고 있습니다. 즉, 다음 포트(port)를 스캔하기 전에 모든 머신의 특정 포트 하나를 먼저 스캔하는 방식입니다. 이를 통해 머신에 가해지는 동시 부하를 분산할 수 있습니다 (네트워크 부하도 어느 정도 줄어듭니다). 이 방식은 꽤 잘 작동하며, 무료 VM의 단일 코어에서 초당 약 30만 개의 포트를 스캔할 수 있는 반면, 이전 방식보다 대역폭 (bandwidth)은 훨씬 적게 사용합니다. 이를 테스트하던 중, 처음에는 제 스캐너의 버그라고 생각했던 현상을 발견했습니다. LAN 상의 장치들이 절대로 응답해서는 안 되는 상황임에도 응답을 하고 있었던 것입니다. 이것이 정확히 무엇인지 확인하는 데 꽤 오랜 시간이 걸렸고, 제가 본 것을 보고 정말 걱정이 되었습니다.
그리고 나서 저는 정말 무서운 것을 보았습니다.
카메라 스트림 (camera stream)에 연결을 시도해 보았으나 (RTSP 및 RTMP 서버 설정을 모두 시도함), 아무것도 나오지 않았습니다. 제가 판단하기로는, 학교 네트워크의 일부에서 Palo Alto를 사용하고 있기 때문에, 해당 서브넷 (subnet)에서 RTSP 및 RTMP를 대상으로 하는 심층 패킷 검사 (Deep Packet Inspection, DPI) 규칙에 의해 차단되고 있는 것 같습니다. 학교의 방화벽 중 하나일 가능성도 있지만, 우리 학교 IT 부서가 그런 상황을 처리할 만큼 신뢰가 가지는 않습니다.
저는 36대의 카메라를 제어할 수 있는 권한이 있음을 발견했고, 웹 인터페이스를 역공학 (reverse engineered)하여 얻은 API와 인터페이스할 수 있는 Bash 스크립트를 작성했습니다. 그리고 각 카메라가 마치 귀여운 고양이들이 뛰어다니는 것처럼 동시에 움직이도록 명령을 내렸습니다.
그 후에는 훨씬 더 황당한 것을 발견했습니다! 캠퍼스 내 거의 모든 강의실의 제어 권한이 있어, 입력을 전환하거나 스크린을 확장 또는 수축시킬 수 있었습니다.
이 사실이 얼마나 말도 안 되는 일인지 알기에, 발견한 직후 곧바로 IT 부서에 보고했습니다. 또한 강의실 카메라를 통해 스토킹당하고 싶지도 않았습니다. 그들은 여름 중에 패치 (patch)될 것이라고 말했지만 (???), 현재는 대부분 패치된 것으로 보입니다. 이 네트워크 세그먼트 (network segment)가 일부 프로젝터에 무선 캐스팅 (wireless casting) 기능이 필요해서 개방되었다고 들었습니다 (실제로 이 기능이 있는 프로젝터는 2개밖에 만나지 못했습니다). 저는 보상을 받지 못했습니다 :<
Google에서 명확한 답변을 얻지 못했던 단일 Rust 스코프 (scope) 문제 해결을 위해 AI를 사용했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN AI Posts의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기