문단속

터미널 멀티플렉서를 하나 만들어 놓고 보니 이 프로그램의 안전은 결국 문 하나로 좁혀졌습니다. 그 문 하나를 여러 방식으로 공격해 본 몇 주를 정리해봤습니다.

문단속

아침에 출근하려고 현관문을 닫고 나서다가 문이 잠기는 소리를 못 들은 것 같으면 다시 돌아가 확인합니다. 분명 문을 밀어 닫아 잠그고도 몇 걸음 걷다 보면 정말 걸렸던가 싶어 되돌아가 손잡이를 흔들어 봅니다. 손잡이를 흔들어 보는 것은 코드를 다시 읽는 것과 다릅니다. 문이 어떻게 생겼는지 아무리 잘 알아도 실제로 밀어 보기 전에는 잠겼는지 확신하기 어렵습니다. 지난 'pytmux'에서 클로드코드 세션을 여러 개 돌리려다 윈도우에서 tmux를 띄우는 게 너무 귀찮아 결국 터미널 멀티플렉서를 직접 만들었다는 이야기를 했습니다. 다 만들어 놓고 한동안 잘 쓰다 보니 그 프로그램에도 똑같은 문단속이 필요하다는 걸 뒤늦게 깨달았습니다. 참고로 이 프로그램은 처음부터 끝까지 제가 손으로 작성한 것이 아니라 클로드코드로 만들었고 만든 뒤의 보안 점검도 상당 부분 기계와 함께 했습니다. 개인적으로 AI를 종종 기계라고 부릅니다. 글에서 AI와 기계는 같은 대상을 가리키고 섞어 씁니다.

터미널 멀티플렉서가 낯선 분을 위해 잠깐 설명하면 창 하나를 여러 개로 쪼개고 여러 셸 세션을 탭으로 묶어 한 화면에서 오가게 해 주는 도구입니다. tmux가 그 대표입니다. 다만 tmux는 유닉스 계열에서만 제대로 돌아 윈도우와 맥을 오가는 제 환경에서는 쓰기가 번거로웠습니다. 특히 클로드코드 세션을 여러 개 띄워 놓고 오가면서 작업하려니 양쪽에서 똑같이 쓸 도구가 마땅치 않았습니다. 그래서 화면을 나누고 탭으로 묶는 기본기 위에 제게 필요한 것들을 더해 만들었습니다. 세션을 유지한 채 프로그램을 재시작하고 ssh 너머의 다른 기계에 접속해 화면을 이어 보고 클로드코드를 여러 세션 돌릴 때 편하도록 토큰 사용량과 프롬프트 기록을 화면에 띄우고 끊긴 세션을 자동으로 다시 잇는 기능까지 더했습니다. 5일 정도 클로드코드로 만든 것이고 화면 갈무리와 더 자세한 설명은 pytmux.woojinkim.org에 정리해 두었습니다.

pytmux 화면 — 한 창을 세 패널로 나누고 위에 탭 표시줄과 아래에 상태줄을 둔 모습입니다.

프로그램의 보안을 살펴보기 시작하면서 가장 먼저 정리한 것은 이 프로그램에 지킬 문이 사실상 하나뿐이라는 사실이었습니다. 화면을 그리는 클라이언트와 셸을 유지하고 있는 서버가 소켓 하나로 대화하고 그 소켓이 유일한 출입구입니다. 소켓 너머에서 접속하는 상대는 전부 사용자 본인이라고 가정합니다. 그래서 이 프로그램에서 클라이언트가 임의의 셸 명령을 실행시킬 수 있다는 사실 자체는 취약점이 아닙니다. 그건 tmux가 하는 일과 똑같은 의도된 기능이고 애초에 집주인은 자기 집 문을 열 권리가 있습니다. 진짜 질문은 그 문 앞까지 누가 올 수 있느냐입니다. 무슨 정교한 자물쇠 기술을 쓰느냐가 아니라 애초에 그 문 앞까지 아무나 걸어올 수 있느냐 없느냐가 이 프로그램 보안의 거의 전부입니다. 지켜야 할 것이 문 하나라면 물어야 할 것도 그 문에 관한 것 하나입니다.

그런데 그 하나뿐인 문에 운영체제에 따라 자물쇠가 아예 없었습니다. 유닉스에서는 소켓이 파일 권한으로 보호되어 소켓이 놓인 디렉토리와 파일 모두 같은 사용자만 열 수 있습니다. 윈도우에서는 그런 파일 권한 대신 로컬 TCP를 사용합니다. 로컬 TCP는 바깥 네트워크에서는 도달하지 못하니 흔히 안전하다고 여깁니다. 하지만 같은 기계 안에서는 다른 사용자든 다른 프로세스든 아무나 그 문 앞까지 걸어올 수 있습니다. 로컬은 네트워크 경계일 뿐 사용자 경계가 아닙니다. 게다가 서버는 문 앞에 온 상대가 누구인지 한 번도 확인하지 않았습니다. 그러니 같은 컴퓨터에 있는 누군가가 제 세션에 슬그머니 접속해 원하는 키 입력을 집어넣고 팝업 기능으로 임의의 셸 명령을 실행하고 서버를 통째로 꺼 버리고 모든 창의 화면을 훔쳐볼 수 있었습니다. 사실상 로컬 권한 상승이자 세션 탈취입니다. 완화는 개념적으로 단순합니다. 서버가 뜰 때 무작위 비밀 문자열을 하나 만들어 같은 사용자만 읽을 수 있는 파일에 적어 두고 문 앞에 선 쪽이 첫 인사에 그 문자열을 실어 보내지 못하면 즉시 돌려보냅니다. 문자열을 대조할 때 글자를 앞에서부터 비교하다 틀리는 순간 멈추면 응답 시간의 미세한 차이로 정답을 한 글자씩 알아낼 수 있으므로 길이가 같으면 늘 끝까지 비교합니다. 유닉스에서는 여기에 더해 커널에게 소켓 상대의 사용자 번호를 직접 물어 서버 자신과 같은지 한 겹 더 확인합니다. 인증 없는 로컬 서비스가 화를 부른 사례는 드물지 않습니다. 기본 설정이 무인증이던 시절의 레디스가 원격에서 열쇠를 심어 대량으로 털린 일이나 노출된 도커 API로 컨테이너가 통째로 장악당한 일이 다 같은 결의 문제입니다.

pytmux 안의 micro 편집기에 띄운 실제 인증 코드 — 무작위 토큰을 만들어 파일에 적어 두고 hmac.compare_digest로 상수시간 비교하는 부분입니다

문에 자물쇠를 달고 안심하려다 그 자물쇠의 열쇠를 어디에 두었는지가 마음에 걸렸습니다. 앞서 비밀 문자열을 같은 사용자만 읽을 수 있는 파일에 적어 둔다고 했지만 코드가 그렇게 하고 있다고 믿는 것과 실제로 그런지는 별개입니다. 그래서 실제 윈도우 기계에서 그 파일의 권한을 직접 떠봤습니다. 파이썬이 파일을 만들 때 지정하는 유닉스식 권한 값이 윈도우에서는 사실상 아무 효력이 없는 죽은 코드였고 파일의 기밀성은 오로지 상위 디렉토리에서 물려받은 접근 제어 목록에 기대고 있었습니다. 다행히 다른 일반 사용자는 그 파일을 못 읽었지만 목록을 뜯어 보니 로컬 관리자 권한을 가진 프로세스는 어느 사용자의 열쇠든 읽을 수 있었습니다. 회사에서 지급하는 PC는 사용자 본인이 관리자 그룹에 속한 경우가 흔하니 실제 경계는 제가 코드를 읽고 짐작했던 "같은 사용자만"보다 넓은 "같은 사용자거나 아무 로컬 관리자나"였습니다. 이건 새 취약점이라기보다 코드가 그럴 것이라고 가정한 내용을 실제 기계에서 재 보는 일이 왜 중요한지를 보여 주는 사례였습니다. 코드에 자물쇠라고 적어 두었어도 그 자물쇠가 정말 잠기는지는 손으로 흔들어 봐야 압니다.

문이 하나뿐이라고 했지만 정확히는 정상적으로 잠글 수 있는 문이 하나였을 뿐입니다. 유닉스에서도 흔히 뚫리는 옆길이 하나 있습니다. 원격 접속으로 로그인한 상황처럼 시스템이 임시 디렉토리 위치를 따로 정해 주지 않으면 이 프로그램은 소켓과 상태 파일을 누구나 쓸 수 있는 공용 임시 디렉토리 아래 예측 가능한 이름으로 만듭니다. 문제는 그 디렉토리를 만들 때 이미 남이 같은 이름으로 선점해 두었어도 그냥 이어서 쓰고 뒤늦게 권한을 좁히려는 시도는 디렉토리 주인이 아니라는 이유로 조용히 실패한다는 점이었습니다. 그러면 공격자가 그 경로를 미리 자기 소유로 만들어 두고 기다렸다가 피해자가 그 안에 소켓을 만들거나 거기 놓인 가짜 소켓에 접속하게 유도할 수 있습니다. 그렇게 가짜 서버가 중간에 끼면 피해자의 모든 키 입력을 가로챕니다. 거기서 친 비밀번호까지 손에 넣고 화면도 마음대로 위조합니다. 검사한 시점과 실제로 쓰는 시점 사이의 틈을 파고드는 공용 임시 디렉토리의 오래된 선점 공격입니다. 고친 방법은 디렉토리를 쓰기 전에 이게 심볼릭 링크가 아닌지 정말 지금 사용자 소유인지를 먼저 확인하고 아니면 아예 거부하는 것입니다. 확인을 통과한 뒤에야 권한을 좁히고 시스템이 임시 디렉토리를 따로 지정해 주었다면 그쪽을 우선합니다.

micro 편집기의 ipc.py — 상태 디렉토리를 쓰기 전에 os.lstat으로 심볼릭 링크가 아니고 현재 사용자 소유인지 확인해 선점을 막는 부분입니다.

여기까지가 코드로 짚어 찾은 것들이라면 코드만 봐서는 안 보이는 것들은 실제로 공격해 봐야 나왔습니다. 진짜 서버를 띄워 놓고 프로토콜 형식만 갖춘 엉뚱한 메시지를 소켓에 마구 던지자 코드상으로는 어차피 오가는 것은 JSON이니 안전하다고 생각했던 곳이 정작 그 JSON이 제가 정의한 형태인지는 한 번도 의심하고 확인하지 않았기 때문에 문제를 일으켰습니다. 첫 메시지로 딕셔너리가 올 거라 믿고 곧장 딕셔너리에서 값을 꺼내려는 코드에 리스트나 숫자를 던지면 엉뚱한 예외가 나면서 연결이 지저분하게 끊겼습니다. 인증을 통과한 뒤에 오는 제어 명령에서도 명령 줄이 항상 문자열일 거라 가정한 곳에 숫자를 넣자 같은 예외가 났습니다. 무작위에 가까운 입력을 대량으로 밀어 넣는 방식으로는 다른 결을 찾았습니다. 화면 출력을 해석하는 파서가 인자 개수가 안 맞는 제어 문자열을 만나면 곧바로 예외를 던졌습니다. 이 예외가 그 창의 출력을 처리하던 작업 전체를 죽여 이후 출력이 통째로 사라졌습니다. 세 가지가 조금씩 다르지만 뿌리는 하나입니다. 파싱에 성공했다는 것이 곧 제가 정의한 형태라는 뜻은 아니라는 것. 받는 쪽은 너그럽게라는 오래된 통신 원칙은 신뢰 경계 앞에서는 오히려 위험합니다. 경계를 넘어 들어오는 입력은 내용을 따지기 전에 타입과 형태부터 막아야 했습니다. 흥미로운 것은 이 셋을 각각 다른 방법이 찾았다는 점입니다. 코드 읽기로는 안 보였고 손잡이 흔들기가 둘을 찾고 낯선 열쇠 꽂기가 하나를 찾았습니다. 한 가지 점검으로 전부를 대신할 수 없었습니다.

밖에서 공격해 보는 이 방식에는 이름이 있습니다. 레드팀 테스트라고 부릅니다. 시스템을 지키는 쪽이 아니라 공격하는 쪽에 서서 제가 만든 것을 실제로 공격해 보는 일입니다. 코드를 읽는 정적 분석이 이 경로로는 위험한 값이 지나가지 않을 것이라고 추론한다면 레드팀 테스트는 그 추론이 어디서 틀리는지를 실제 공격으로 확인합니다. 코드를 아무리 꼼꼼히 읽어 안전을 증명해도 그 증명이 기댄 가정이 틀리면 증명도 함께 실패합니다. 앞의 타입 결함들이 바로 그런 경우였습니다. 파서가 받는 값이 늘 딕셔너리라고 가정했지만 공격자는 리스트도 숫자도 보낼 수 있었고 그 가정은 실제로 이상한 값을 던져 봐야 깨졌습니다. 그래서 이 프로그램에도 밖에서 서버를 공격해 보는 도구를 하나 만들었습니다. 진짜 서버를 하나 띄우고 인증 없는 프레임과 손상된 프레임을 수천 번 전송합니다. 연결을 천천히 열어 둔 채 정상 손님이 굶지 않는지 재 보고 인증을 통과한 뒤의 명령까지 공격합니다. 그러는 동안 서버의 메모리와 열어 둔 파일 수를 계속 재면서 서버가 끝까지 살아 있는지 무인가 접속을 한 건이라도 받아들이는지 자원이 조금씩 불어나 쌓이지는 않는지를 숫자로 확인합니다. 한 번 공격해 보고 끝내면 이 확인은 오래가지 못합니다. 새 기능이 새 문을 만들고 이번에 잠근 문이 다음 달에 다시 열리기도 하기 때문입니다. 그래서 이 공격을 한 번의 점검으로 끝내지 않고 빌드 파이프라인에 넣어 코드가 바뀔 때마다 자동으로 다시 돌립니다. 그래야 어제 닫은 문이 오늘 소리 없이 열린 것을 사람이 아니라 파이프라인이 먼저 알아챕니다. 보안 점검은 한 번 하고 끝나는 행사가 아니라 코드가 사는 내내 되풀이해야 하는 일입니다.

공격 비용이 공격자에게는 저렴하고 서버에게는 비싼 종류도 있었습니다. 신뢰할 수 없는 출력이 문제였습니다. 누가 건네준 악성 로그나 무심코 화면에 띄운 원격 셸 출력이 아주 긴 특정 시퀀스를 뱉으면 화면 파서가 그 내용을 글자 하나가 추가될 때마다 버퍼 전체를 새로 복사하느라 계산량이 글자 수의 제곱으로 불어났습니다. 파이썬 문자열은 조건에 맞으면 복사 없이 문자열 길이를 늘리는 최적화가 있지만 하필 이 버퍼가 그 조건을 벗어난 곳에 있어 최적화가 적용되지 않았습니다. 3메가바이트짜리 출력 하나에 서버가 200초 넘게 통째로 멈춰 서는 걸 실제로 재 봤습니다. 무인가 접근도 필요 없습니다. 신뢰 못 할 내용을 화면에서 보기만 해도 그동안 모든 세션의 입력과 화면 갱신이 다 같이 멈춥니다. 입력은 작은데 계산은 터무니없이 커지는 이런 복잡도 공격은 규모가 큰 서비스도 자주 마비시킵니다. 2011년에는 조작한 요청 하나로 여러 언어의 웹 플랫폼이 동시에 마비된 해시 충돌 공격이 있었고 2019년에는 클라우드플래어가 정규식 표현 하나 때문에 전 세계 트래픽을 27분간 마비시켰습니다[^Details of the Cloudflare outage on July 2, 2019]. 둘 다 작은 입력이 최악의 계산을 부른 같은 유형입니다. 고치는 방법은 대개 단순합니다. 글자마다 더하는 대신 시퀀스가 끝나는 지점을 한 번에 찾아 통째로 잘라 오게 바꾸자 같은 3메가바이트를 1밀리초 안에 처리했습니다.

micro 편집기의 vtparse.py — OSC 본문을 글자마다 잇는 대신 종결자를 text.find로 한 번에 찾아 통째로 잘라 오는 부분입니다.

연결을 천천히 열어 두는 쪽도 자원을 갉아먹었습니다. 서버는 인증을 확인하기도 전에 상대가 보내는 첫 메시지를 끝까지 읽으려고 기다립니다. 이 기다림에는 제한 시간도 동시에 열 수 있는 연결 수의 상한도 없었습니다. 그러니 무인가 사용자가 연결을 여러 개 열어 놓고 각 연결에 큰 메시지를 아주 조금씩만 보내면 서버는 끝나지 않을 메시지를 하염없이 기다리느라 자원을 놓지 못합니다. 대역폭이 아니라 서버의 인내심을 노리는 슬로로리스라고 불리는 오래된 수법입니다[^Slowloris DDoS Attack]. 이건 원래 스레드 기반 웹서버를 소수의 연결로 마비시키던 공격이지만 인증 전 단계에 시간 제한이 없는 곳이면 어디든 다시 나타납니다. 문 앞에서 시간을 정해 두고 그 안에 용건을 못 밝히면 문을 닫고 인증 전에는 아주 작은 첫 인사만 허용하고 동시에 매달릴 수 있는 연결 수에 상한을 두는 것으로 막았습니다.

micro 편집기의 serverio.py — 인증 전 읽기를 HANDSHAKE_TIMEOUT과 작은 상한으로 감싸고 동시 연결 수를 캡해 슬로로리스를 막는 부분입니다.

그런데 이번 점검에서 가장 섬뜩했던 것은 문이 아니라 창문이었습니다. 정확히는 창문이라고 생각조차 안 했던 곳이 실은 안으로 들어오는 통로였습니다. 이 프로그램에는 ssh로 다른 기계에 접속했을 때 그쪽에서도 같은 프로그램이 돌면 자동으로 감지해 탭을 병합해 주는 편의 기능이 있습니다. 이 감지 신호를 화면에 지나가는 출력 스트림 안에 특수한 제어 시퀀스를 끼워 보냅니다. 문제는 그 신호의 출처를 아무도 묻지 않았다는 점입니다. 진짜 접속 래퍼가 보낸 신호인지 아니면 화면에 그냥 섞여 든 임의의 바이트인지 구분하지 못했습니다. 터미널에 찍히는 바이트는 누구나 만들 수 있습니다. 악성 파일을 하나 열어 화면에 내용을 띄우기만 해도 그 안에 신호 패턴이 들어 있으면 서버는 그걸 신뢰할 만한 기록으로 받아들였습니다. 게다가 신호에는 접속할 목적지가 실려 옵니다. 목적지가 로컬 주소나 특정 형식이면 원격 접속 대신 그 주소로 연결할 수 있었고 이 경로는 정작 임의 목적지를 막으려고 세워 둔 검사를 통째로 건너뛰었습니다. 그래서 공격자가 그 신호에 낯선 주소를 목적지로 심어 두면 피해자는 그 파일을 화면에서 보기만 했을 뿐이지만 자기 데몬이 그 주소로 실제 바깥 연결을 맺었습니다. 접속이 성립하면 그 뒤로는 키 입력이 그 낯선 서버로 전달됩니다. 그럴듯한 로그인 화면을 그려 비밀번호를 받아 내는 것까지 이어집니다. 도둑이 자물쇠는 손도 안 대고 문틈으로 쪽지를 밀어 넣어 집주인이 제 발로 걸어 나가 대문을 열도록 유도한 것입니다.

이게 유독 무서웠던 것은 어느 도구도 이걸 못 잡았기 때문입니다. 파서는 이 신호를 완벽하게 아무 예외 없이 정상적으로 해석했습니다. 그러니 낯선 열쇠를 꽂아 보던 도구는 예외가 안 났으니 통과라고 보고했습니다. 손잡이를 흔들던 런타임 시험은 소켓만 공격했지 화면 출력은 건드리지 않았습니다. 더 고약한 것은 이 동작을 검증하던 테스트가 오히려 취약한 동작을 정답으로 정해 두고 있었다는 점입니다. 출처를 안 물어도 신호가 설정되는 것을 정상으로 단언하고 있었으니 공격의 핵심 단계를 테스트가 매번 통과시키고 있었습니다. 결국 이걸 잡은 것은 도구가 아니라 "이 신호를 대체 왜 믿고 있지"라고 의심하고 데이터가 지나온 길을 거슬러 올라간 사람의 눈이었습니다. 무작위 입력으로 뭔가를 부수는 퍼징은 실제로 부서지는 걸 잘 찾지만 아무것도 안 부서진 채 잘못 신뢰하고 있는 곳은 못 찾습니다. 그런 결함은 이 기능이 이 입력을 신뢰하면 안 된다는 조건을 먼저 검사 가능한 속성으로 기록해 둬야 찾아낼 수 있습니다. 그래서 완화도 두 겹으로 작성했습니다. 서버가 진짜 래퍼에게만 몰래 심어 준 비밀 값을 신호에 포함하게 해 출처를 증명하도록 하고 화면 출력이 지시한 목적지로는 검사를 건너뛰는 지름길을 아예 막았습니다. 여기에 더해 신뢰할 수 없는 화면 출력을 먹인 뒤 허용된 곳 말고는 바깥 연결이 한 건도 없어야 한다는 조건을 직접 확인하는 시험을 새로 만들어 이번엔 도구가 이 종류의 결함을 찾아낼 수 있게 했습니다.

micro 편집기의 serverpty.py — 화면 출력에 실려 온 NEST 신호의 출처를 hmac.compare_digest로 검증해 위조된 목적지를 막는 부분입니다.

그리고 이건 저 혼자 저지른 어처구니없는 실수가 아니었습니다. 널리 쓰이는 터미널 프로그램인 iTerm2에도 7년 동안 거의 똑같은 창문이 열려 있었습니다. ssh 연동용 제어 시퀀스의 출처를 확인하지 않고 화면 출력을 곧이곧대로 믿어 악성 서버에 접속하거나 조작된 URL을 curl하거나 악성 파일을 여는 것만으로 임의 명령이 실행됐습니다[^7-Year-Old Critical RCE Flaw Found in Popular iTerm2 macOS Terminal App]. 공격 경로까지 제 경우와 판박이입니다. 이렇게 서버를 꼬드겨 대신 요청하도록 하는 공격을 크게 묶어 SSRF라고 부릅니다. 규모로 치면 2019년 캐피털 원에서 1억 건이 넘는 고객 정보가 유출된 사고가 대표적입니다. 방화벽 뒤에 있던 서버를 속여 클라우드 내부의 메타데이터 주소에서 자격증명을 받아 내는 정확히 같은 결의 문제였습니다[^What We Can Learn from the Capital One Hack – Krebs on Security]. 편해 보이는 자동 기능일수록 그 편의가 어디서 오는 신호에 기대는지를 물어야 한다는 걸 제 프로그램과 남의 프로그램에서 나란히 확인했습니다.

바깥과 연결되는 기능은 창문 하나로 끝이 아니었습니다. 이 프로그램은 원격 접속에 시스템의 ssh 명령을 그대로 부릅니다. 접속할 목적지를 클라이언트가 준 값에서 검사 없이 받아 넘겼습니다. 셸을 거치지 않고 인자 목록으로 넘기니 흔히 말하는 명령 주입은 막힙니다. 하지만 ssh는 목적지로 앞에 붙임표가 달린 값이 오면 그걸 목적지가 아니라 옵션으로 해석하고 그 옵션 중에는 접속 과정에서 임의의 명령을 실행하도록 지정하는 것이 있습니다. 그러니 셸을 안 거쳐도 목적지 문자열 하나로 원격 명령을 실행할 수 있습니다. 명령 주입과는 구별되는 인자 주입 혹은 옵션 주입이라 부르는 부류입니다. 목적지 앞에 붙임표나 공백이 오면 거부하고 옵션과 목적지를 나누는 표식을 넣어 그 뒤로는 전부 목적지로만 해석하게 막았습니다. 여기에 더해 클라이언트가 매번 목적지를 마음대로 정하는 대신 서버 쪽에 허용 목적지 목록을 두어 관리자만 바꿀 수 있는 설정으로 다뤘습니다. 접속 시 처음 보는 호스트 키를 조용히 수락하는 위험한 옵션은 애초에 켜지 않아 낯선 호스트 키는 그냥 접속 실패로 처리되게 두었습니다.

원격이 얽히면 신뢰의 방향도 뒤집힙니다. 지금까지는 남이 제 문 앞에 서는 걸 걱정했지만 원격 접속으로 남의 서버에 제가 접속하는 경우에는 그 상대가 보내는 데이터를 제 클라이언트가 소화해야 합니다. 그런데 침해된 원격 서버가 필수 항목이 빠진 엉뚱한 화면 프레임을 내려보내면 그걸 그대로 받아 처리하던 클라이언트가 없는 값을 꺼내려다 예외를 내고 앱이 통째로 종료됐습니다. 정작 로컬 소켓으로 들어오는 데이터에는 형태를 검사하는 방어가 있었지만 원격에서 중계되어 들어오는 경로에는 같은 방어가 빠져 있었습니다. 신뢰 못 할 쪽에서 오는 입력을 로컬만큼만 방어한 것이 화근이라 로컬에 있던 형태 검사를 원격 경로에도 똑같이 달아 대칭을 맞췄습니다.

인증을 한 곳에 달았다고 온 집에 달린 것도 아니었습니다. 윈도우에서 세션을 유지한 채 재시작하려고 화면 담당 프로세스를 서버 본체와 분리해 두었습니다. 이 둘이 대화하는 별도의 통로에는 정작 본체 문에 달아 둔 그 비밀 문자열 검사도 상대 사용자 확인도 하나도 재사용되지 않았습니다. 유닉스에서는 그 통로도 파일 권한이 막아 주지만 윈도우에서는 본체와 똑같이 로컬 TCP라 앞서 막았던 그 구멍이 형제 통로에서 그대로 다시 열려 있었습니다. 이 통로로 접속하면 임의의 프로세스를 띄우고 아무 화면에나 키를 집어넣고 전체 출력을 훔쳐볼 수 있었으니 본체에 낸 자물쇠를 옆문이 무력화했습니다. 새 통로에도 본체와 똑같은 비밀 문자열 검사와 사용자 확인을 그대로 배선하는 것으로 막았습니다. 새 문을 낼 때 기존 문의 자물쇠가 저절로 따라오지 않는다는 걸 그래서 통로를 하나 더 팔 때마다 인증을 다시 달았는지 확인해야 한다는 걸 이 건에서 배웠습니다.

지킬 문이 대문 하나뿐이라고 정리했지만 잘 잠근 집도 정작 대문이 아닌 곳에서 문제가 생깁니다. 하나는 열쇠공 쪽입니다. 이 프로그램의 배포 자동화는 남이 만든 빌드용 부품을 가져다 씁니다. 그걸 버전 태그로 참조하면 그 태그가 나중에 악성 코드를 가리키도록 바꿔치기될 수 있습니다. 2025년 3월에 널리 쓰이던 한 깃헙 액션이 정확히 이렇게 뚫려 태그가 몰래 악성 커밋으로 옮겨지면서 수만 개 저장소의 빌드 비밀이 로그로 유출됐습니다[^tj-actions/changed-files (CVE-2025-30066) · GitHub Advisory Database]. 그래서 태그 대신 절대 안 바뀌는 커밋 해시로 고정했습니다. 다른 하나는 제가 공개한 스크린샷입니다. 공개 사이트에 올리는 화면 갈무리를 가릴 때 정해진 몇 가지 단어만 지우고 나머지 글자는 통째로 벡터 그림으로 구워 버리는 방식이었습니다. 그러면 나중에 텍스트 검색으로도 걸러 낼 수 없습니다. 지금 올라간 것들은 다행히 합성 데이터라 유출된 게 없었지만 미래에 실제 데이터를 물면 검색으로는 발견되지도 않은 채 조용히 유출될 구조였습니다. 가린 줄 알았다가 되살아나는 사고의 대표가 2023년의 이른바 aCropalypse입니다. 잘라 낸 스크린샷의 잘려 나간 원본이 그대로 복원됐습니다[^aCropalypse - Wikipedia]. 검색 도구로는 발견할 수 없어서 결국 배포 전에 사람이 눈으로 한 번 확인하는 절차를 두는 것으로 정리했습니다. 마지막으로 이 프로그램은 개발용으로 패널 출력을 통째로 파일에 기록하는 기능이 기본으로 켜져 있었습니다. 화면에 스친 비밀번호나 토큰이 그대로 남는 데다 그 파일이 누구나 읽을 수 있는 권한으로 만들어졌습니다. 파일을 만드는 순간부터 소유자만 읽게 권한을 좁히고 나중에는 이 기록 기능 자체를 기본 꺼짐으로 바꿔 내려받아 처음 쓰는 사람이 자기도 모르게 화면을 녹화하지 않도록 했습니다.

지난 '열쇠'에서 AI에게 쥐여 주는 실행 권한을 열쇠에 빗대면서 열쇠는 누가 자기를 돌리는지 묻지 않고 그저 가장 급하게 말하는 목소리에 열린다고 적었습니다. 이번에는 그 열쇠가 꽂히는 문 자체를 직접 만들어 놓고 같은 문제를 반대편에서 마주했습니다. 게임을 만들 때 몸에 익힌 원칙 중에 절대 클라이언트를 믿지 말라는 말이 있습니다. 서버가 자기 앞에 온 데이터의 출처와 형태를 매번 다시 의심해야 한다는 이 원칙이 게임 서버 바깥에서도 똑같이 통한다는 걸 이번에 다시 확인했습니다. 돌아보면 이렇게 수정한 보안 문제들은 하나같이 교과서에 이름이 나오는 유형이었습니다. 인증 없는 로컬 서비스, 검사와 사용 사이의 틈, 타입을 안 따지는 입력, 작은 입력이 부르는 큰 계산, 느리게 열어 두는 연결, 데이터에 섞여 온 제어 신호, 인자 주입, 공급망, 제거했다고 생각한 민감정보. 대단히 새로운 것이 없다는 점이 핵심입니다. 개인이 주말에 만든 프로그램이라도 이 유형의 문제에 노출되면 어떻게 될지 하나씩 점검해 보면 결함을 찾아낼 수 있습니다.

이 모든 점검을 상당 부분 기계와 함께 했습니다. 문을 만든 것도 밖에서 공격해 보는 도구를 만든 것도 취약한 곳을 처음 짚어 준 것도 상당 부분 기계였습니다. 다만 "이 신호를 대체 왜 믿고 있지" 같은 의심만은 아직 사람 몫으로 남아 있었습니다. 기계는 코드를 빠르게 읽고 낯선 열쇠를 지치지 않고 꽂아 보지만 아무것도 안 부서진 채 잘못 신뢰하고 있는 곳을 스스로 의심하는 일은 아직 잘 못합니다. 그런 곳은 결국 사람이 가정을 거슬러 올라가야 나왔습니다. 문단속은 한 번으로 끝나는 일이 아닙니다. 새 기능을 더할 때마다 새 문이 생기고 방금 형제 통로 이야기에서처럼 이번에 잠근 문이 다음 달엔 옆에서 다시 열려 있기도 합니다. 그래서 이 집도 종종 한 바퀴 더 돌면서 손잡이를 흔들어 봐야 할 것 같습니다. 잠갔다고 믿는 것과 정말 잠긴 것 사이의 거리는 아무래도 직접 밀어 보기 전에는 좁혀지지 않는 모양입니다.