2024년 7월 1일, Qualys가 OpenSSH 서버의 원격·비인증·루트 권한 코드 실행 버그를 공개했다. 이름은 regreSSHion, 추적 번호는 CVE-2024-6387. 이 이름 하나에 이야기 전부가 들어 있다. 새 버그가 아니었다. 2006년에 잡았던 옛 버그가 2021년에 코드로 기어 돌아와, 인터넷에서 가장 많이 노출된 네트워크 서비스 안에, 3년 동안 아무도 눈치채지 못한 채 앉아 있었던 것이다.
기술적 디테일도 알아둘 값어치가 있지만, 밤잠을 설치게 만드는 대목은 익스플로잇이 아니다. 발견되고, 이해되고, 패치까지 된 버그가 어떻게 몇 년 뒤 정문으로 되돌아올 수 있느냐 — 그것도 ‘조심하는 것’이 직업인 사람들이 운영하는 성숙한 프로젝트에서.
버그 자체
OpenSSH 서버 sshd는 들어오는 모든 접속에 인증할 유예 시간을 준다. 접속만 해놓고 아무 말 없이 그냥… 가만히 있으면, sshd는 결국 포기한다. 기본값은 120초 — LoginGraceTime 설정이다. 내부적으로 sshd는 alarm()으로 타이머를 걸고, 그게 만료되면 OS가 SIGALRM 시그널을 보내 실행 중이던 흐름을 낚아채 버려진 접속을 정리하는 시그널 핸들러로 점프시킨다.
여기가 지뢰밭이다. 시그널은 어느 순간에도 도착할 수 있다 — 아주 예민한 작업 한복판을 포함해서. 그래서 시그널 핸들러가 실행해도 되는 코드는 엄격히 제한된다. “async-signal-safe(비동기 시그널 안전)“해야 한다 — 자기 자신을 중단시켜도 깨지지 않는다고 보장된 함수 목록이다. 그 목록에 없는 대표 주자가 메모리 할당이다. malloc()과 free()는 내부 장부로 힙을 일관된 상태로 유지하는데, 실행되는 동안엔 그 장부가 잠깐 불일치 상태가 된다. malloc 도중에 시그널이 터지고 핸들러가 또 할당을 부르면, 이제 두 코드가 같은 힙 구조를 동시에 고치게 된다. 힙이 망가진다.
regreSSHion의 핸들러는 syslog()를 불렀다. 그리고 glibc 리눅스에서 syslog()는 malloc()을 부를 수 있다. 순서는 이렇다: 접속이 멈추고, sshd가 마침 자기 메모리 할당 안에 있을 때 알람이 터지고, 핸들러가 syslog()를 돌리고, syslog()가 편집 중이던 힙에 손을 뻗고, 그 전체가 공격자가 조종할 수 있는 상태로 탈선한다. 경쟁 조건(race condition)이다 — 공격자는 알람을 정확히 잘못된 마이크로초에 떨어뜨려야 한다 — 하지만 다시 시도할 수 있는 경쟁이란 아직 안 딴 자물쇠일 뿐이다.
”이 경쟁은 못 이겨”는 수정이 아니다
Qualys는 이론만 설명한 게 아니라 경쟁을 이겼다. 주소공간 무작위화(ASLR)를 켠 32비트 리눅스 표적을 상대로, 타이밍을 한 번 맞춰 루트 셸을 따는 데 약 1만 번의 접속 시도 — 여섯에서 여덟 시간의 쉼 없는 두드림 — 이 걸렸다. 64비트는 무작위화가 더 깊어 시연하지 않았지만, “실험실에서 다 못 끝냈다”와 “안전하다”는 같은 문장이 아니고, 그 방에 있던 누구나 그걸 알았다.
여섯 시간이 길게 들리다가도 표적이 뭔지 떠올리면 달라진다. 이건 sshd, 22번 포트에서 듣고 있는, 인터넷을 굴리는 서버의 상당수에 깔린 그것이다. 공격자는 스톱워치와 경주하지 않는다 — 봇넷과 무한한 인내심이 있다. Qualys는 Censys와 Shodan으로, 취약할 수 있는 버전을 돌리며 인터넷에 노출된 OpenSSH 인스턴스를 1,400만 대 넘게 셌다. 익스플로잇은 느렸고 모집단은 거대했으며, 느림 곱하기 거대함은 여전히 재앙이다.
CVSS 점수는 8.1 — 비인증 원격 루트라면 기대할 법한 만점 10.0이 아니라 “높음”에 안착했다. 그 격차가 난이도 세금이다. 경쟁 조건이 어렵게 만들 뿐, 불가능하게 만들진 않는다. 딱 “다음 사이클에 패치하지 뭐”로 분류되기 좋은 점수인데, 천장이 ‘전부에 대한 루트’일 때는 틀린 직감이다.
리그레션
이제 핵심이다. 이건 어디서 왔나?
2006년에서 왔다. 똑같은 부류의 버그가 그때 CVE-2006-5051로 보고됐다 — sshd의 안전하지 않은 시그널 핸들러 — 그리고 OpenSSH가 고쳤다. 수정은 버텼다. 4.4p1 이후 버전들은 몇 년간 취약하지 않았다. 위험한 호출을 시그널 경로 밖으로 지켜내는 가드를 코드가 지니고 있었기 때문이다.
그러다 2021년 초에 나온 OpenSSH 8.5p1에서 그 가드가 빠졌다. 사보타주도, 교묘한 공급망 공격도 아니고 — 평범한 코드 진화를 통해서다. 무언가 리팩터되고, 한 조각이 옮겨졌고, 2006년 버그를 불가능하게 만들던 그 특정 보호가 더는 거기 없었다. 2006년 취약점이 기능적으로 되돌아온 것이다. 그리고 배포됐다. 8.5p1, 8.6, 8.7, 2024년 3월 9.7p1까지 — 3년간 모든 릴리스가 되살아난 원격 루트 구멍을 실었고, 프로젝트의 정상적인 리뷰·테스트·감사 절차는 잡아내지 못했다. 그 절차 중 무엇도 이 특정한 죽은 것이 되살아나는지를 보고 있지 않았기 때문이다.
이것이 리그레션의 정의이고, 새 버그를 찾는 것과는 근본적으로 다른 실패다. 새 버그는 소프트웨어를 쓰는 비용이다 — 몰랐던 걸 몰랐다고 탓할 수는 없다. 리그레션은 잊어버린 비용이다 — 뭔가를 고치고, 교훈을 얻고, 그 교훈을 대신 기억해줄 무엇도 남기지 않은 대가.
버그의 수정은 패치가 아니라 테스트다
여기가 내 의견이고, 분명히 말하겠다. 버그가 다시 못 돌아온다고 단언하는 테스트가 딸리지 않은 보안 패치는 절반짜리 수정일 뿐이다. 어쩌면 절반도 안 된다.
패치는 오늘 구멍을 막는다. 테스트는 내일도 그게 막힌 채로 있게 하는 유일한 물건이다 — 당신과 그 사건을 기억하는 모두가 떠나고, 그 파일을 최선의 의도로 리팩터하는 새 사람이 왔을 때. 코드 리뷰는 이런 리그레션을 안정적으로 잡지 못한다. 리뷰어는 눈앞의 diff를 볼 뿐, 그 diff가 조용히 되돌리는 열여덟 살짜리 CVE를 보지 않기 때문이다. 사람은 ‘무언가의 부재’를 알아채는 데 서툴다. 테스트는 정확히 그걸 잘한다 — 테스트는 특정 동작이 바뀌면 울리는 인계철선이고, 지루해하지 않고, 잊지 않으며, 인턴이 2006년을 알 거라고 가정하지 않는다.
함정은 regreSSHion 부류의 버그가 테스트를 쓰기 진짜 어렵다는 것이다. “어떤 스케줄링 교차 상황에서도 SIGALRM 핸들러가 할당하는 무언가를 절대 부르지 않는다”를 유닛 테스트로 짜기는 쉽지 않다. 시그널 핸들러의 경쟁 조건은 타이밍 의존적이고 원할 때 재현하기가 거의 불가능하다. 이건 사실이고, 리그레션이 살아남은 이유의 일부다. 하지만 “테스트하기 어렵다”는 더 어려운 테스트에 투자할 이유이지 — 시그널 핸들러 감사, 시그널 경로 안의 non-async-signal-safe 호출을 잡아내는 정적 분석, 유예 타이머 경로 퍼징 — 유지보수자들의 집단 기억에 기대라는 이유가 아니다. 그 집단 기억이야말로 실패한 바로 그것이다.
OpenBSD엔 애초에 없었다
가장 조용히 뼈아픈 디테일: OpenSSH가 실제로 나온 곳인 OpenBSD는 regreSSHion에 한 번도 취약한 적이 없었다. 2006년 형태로도, 2024년 형태로도. 상류 프로젝트는 2001년부터 이 정확한 조건을 안전하게 다뤄왔다 — 핸들러 안에서 힙에 손대지 않는 시그널 안전 로깅 경로로. 이 버그는 OpenSSH의 “포터블” 버전 — 리눅스를 비롯한 다른 모든 것에서 돌도록 이식된 쪽 — 에만 있었다. 거기서 glibc syslog() 동작과 빠져버린 가드가 결합해 구멍이 됐다.
곱씹어 보라. 같은 코드 가문이 두 갈래로 나뉘었는데, 한 갈래는 문제의 부류를 리팩터로도 되살릴 수 없을 만큼 철저히 풀어놨고, 다른 갈래는 2006년에 특정 사례를 풀었다가 그 수정을 잃어버렸다. 그게 버그를 고치는 것과 버그가 가능했던 이유를 고치는 것의 차이다. 하나는 당신 자신의 미래의 부주의에서도 살아남는다. 다른 하나는 언젠가 되돌려지길 기다리는 패치다.
여기서 실제로 챙길 것
OpenSSH 9.8p1 이상으로 패치하라. 그 부분은 흥미롭지 않고 아마 이미 했을 것이다. LoginGraceTime을 0으로 두는 건 응급 완화책이었다 — 타이머를, 따라서 알람을, 따라서 버그 있는 핸들러를 꺼버린다 — 가벼운 서비스 거부 방어를 하나 없애는 대가로. 그 시기엔 합리적인 맞바꿈이었다.
오래 남는 교훈은 “업데이트 돌려라”보다 작고 더 어렵다. “그거 우리가 고쳤어”에는 유통기한이 있다는 것이다. 패치하고 돌아서는 모든 버그는 딱 한 곳 — 그 자리에 있던 사람들의 기억 — 에 저장된 사실이고, 그 저장 매체는 퇴사하고, 잊고, 리팩터한다. regreSSHion은 왜 그 수정이 거기 있었는지에 대한 기억보다 수정이 더 오래 살아남았을 때 벌어지는 일이다. 처음 버그를 찾는 데 열여덟 해, 되살리는 데 일상적인 코드 정리 한 번, 다시 누군가 찾아내기까지 1,400만 대 서버로 3년간 배포. 버그는 애초에 흥미로운 대목이 아니었다. 잊어버림이 흥미로운 대목이었다.