TOCTOU
time of check to time of use, TOCTTOU라고도 한다. 두 시점 사이 타이밍을 노리는 공격, 또는 그런 공격을 가능하게 하는 버그 유형이다. 위키백과 글에 있는 예를 보면 쉽게 이해할 수 있고 openat() 같은 함수들이 대응책 중 하나다. “Defensive Programming for Red Hat Enterprise Linux” 2.2절, CWE 항목도 참고.
커널 문서 seccomp_filter.rst에도 TOCTOU에 대한 언급이 있다.
seccomp 필터링을 통해 프로세스에서 시스템 호출을 입력으로 하는 필터를 지정할 수 있다. 필터는 소켓 필터에서처럼 버클리 패킷 필터(BPF) 프로그램으로 표현하되 대상 데이터가 현재 시스템 호출 관련 데이터, 즉 시스템 호출 번호와 시스템 호출 인자들이다. 오랜 사용 역사를 가진 필터 프로그램 언어와 단순 명확한 데이터 세트를 이용해 강력한 시스템 호출 필터링이 가능하다.
더불어 BPF는 seccomp 사용자가 시스템 호출 중재 프레임워크에서 흔한 검사 시점과 사용 시점 차이(TOCTOU) 공격의 희생물이 되지 않게 한다. BPF 프로그램에서 포인터 역참조를 할 수 없으므로 모든 필터에서 시스템 호출 인자들을 직접적으로만 평가하도록 제약된다.
어떤 기능을 제공하지 않으므로 그 기능을 제공하는 경우를 대상으로 한 공격에 취약하지 않다… 미묘한 유머일까?
시스템 호출 중재(interposition) 프레임워크로는 seccomp 말고도 ptrace나 각종 시스템 호출 래퍼, 기타 운영 체제별 메커니즘이 있다. 그런 프레임워크에서 인자 검사를 마친 시점과 이후 커널 내 시스템 호출 핸들러에서 (copy_from_user()
같은 걸로) 사용자 메모리의 인자 값을 읽어 오는 시점 사이에 동일 프로세스의 다른 스레드에서 그 값을 슬쩍 바꿀 수 있을 거다. 또는 개별적으로 보면 수상할 게 없지만 합쳐지면 수상할 수 있는 시스템 호출 조합(가령 chdir()
과 open()
)을 두 스레드에서 따로 호출할 수도 있을 거다.
TOCTOU를 좀 일반화하자면 도중의 필터에게 보이는 데이터와 최종 목적지에서 적용되는 데이터가 다르도록 하는 거다. 즉 전형적인 차단 vs 우회 구도이고 그래서 네트워크 쪽에도 비슷한 패턴이 있을 수 있다. 다만 이미 필터에게 간 패킷을 어찌할 수는 없으니 추가 패킷을 이용해야 한다. (참고: 비슷하다는 것이지 아래에 설명하는 내용을 TOCTOU라고 부르진 않는다.)
클라이언트와 서버가 TCP로 통신하고 그 사이에 필터 장치가 있다고 하자. 서버가 참 순종적이어서 연결 첫 데이터로 “YOU DIE”라는 메시지를 받으면 죽는다. 그래서 필터 장치는 그걸 탐지해서 차단하려고 한다.
가장 단순한 방법은 패킷 내 문자열 탐색이다. 말하자면 iptables의 string 매치 정도를 쓰는 거다. 우회하는 것도 쉽다. 메시지를 “YOU “와 “DIE”로 나눠서 보내면 된다. IP 계층에서 단편화 하든지 TCP 계층에서 나눠서 보내든지 하면 된다.
그렇다면 필터 장치에서 TCP 세션을 따라가면서 (가령 넷필터의 conntrack) 간단한 상태 머신을 돌려서 패턴을 찾을 수 있다. 다만 연결 상태만 유지하고 패킷 데이터를 버퍼링 하지는 않는다. 그럼 또 공격자는 패킷 순서를 바꿔서 우회할 수 있다. “DIE”(SEQ=4)를 먼저 보낸 다음 “YOU “(SEQ=0)를 보내면 된다. (수신 윈도우는 충분하다고 하자.)
이렇게 되면 필터 장치에서 일련번호를 고려해 데이터를 조합해 가며 확인할 수 있다. 어차피 세션 처음 일곱 바이트만 보면 되니까 추가로 필요한 메모리가 많지도 않다. 가급적 트래픽에 변경을 가하지 않는 게 바람직하니까 다음과 같이 동작하도록 만들 수 있을 거다.
-
7바이트 범위에 포함되는 부분을 세션별 버퍼에 복사
-
버퍼가 덜 찼으면 패킷 통과
-
버퍼가 다 찼을 때 “YOU DIE”인지 확인
a. 맞으면 패킷 폐기하고 세션 차단
b. 아니면 관련 자원 해제하고 이후 패킷 통과
멋지다. 근데 두 세그먼트의 (또는 IP 계층에서 두 단편의) 데이터가 겹치면 어떻게 될까?
요즘 대부분 운영 체제에서는 TCP 세그먼트가 겹칠 때 앞쪽 데이터를 우선한다. 물론 반대로 동작하는 운영 체제도 좀 있지만 눈 질끈 감고 무시하자. (IDS/IPS에서 중요한 이슈니까 궁금하면 그쪽 소스나 문서를 살펴보면 된다. 뒷쪽 데이터를 우선하는 경우에는 서버 수신 큐의 데이터를 응용에서 가져가는 시점까지 고려해야 할 수 있다.)
좋다, 1번 단계에서 버퍼로 데이터를 복사할 때 새 데이터 앞 부분과 겹치는 데이터가 버퍼에 이미 있으면 기존 데이터를 유지하고 그 외 방식으로 겹치는 데이터는 덮어 쓰면 된다. 드디어 끝이다.
근데 이건 어떨까?
패킷 #1: ['D' 'I' 'E']
패킷 #2: ['U' ' ' 'P']
패킷 #3: ['Y' 'O']
오케이, 그러면 새 데이터 앞 부분과 겹치는 오프셋 0부터 연속인 데이터가 버퍼에 이미 있으면 그걸 유지하고 그 외 방식으로 겹치는 데이터는 덮어 쓰면 된다. 이제 중간 필터와 최종 목적지에서 그 일곱 바이트를 ‘보는’ 방식이 같아졌으니 정확하게 공격을 탐지할 수 있다.
… 정말 그럴까? 충분히 안심할 수 있을까? 모르겠다. 여튼 이 정도가 패킷 기반 필터에서 할 수 있는 최선에 가깝다.
그런데 (가능성은 낮겠지만) 필터-서버 구간에서 패킷 순서가 바뀐다면? MPTCP를 쓴다면? 문제의 패턴을 스트림 중간에서도 찾아야 한다면? 프로토콜 문법 상 같은 바이트 패턴이 위치에 따라 위험할 수도 있고 아닐 수도 있다면? 아닌 경우에 트래픽을 차단하는 거짓 양성을 허용하기 어렵다면? 그렇다면 프록시 기반으로 바꾸고 복잡한 로직을 돌리는 걸 고려할 때다. 대신 상응하는 런타임 비용을 감수해야 한다.
여느 분야가 그렇듯 90%를 만드는 건 쉽지만 그걸 99%로 끌어올리는 건 어렵고, 그걸 다시 99.9%로 만드는 데는 큰 비용이 든다. 그래서 warning.or.kr처럼 광역으로 쓰는 솔루션은 적당한 지점에서 타협할 수밖에 없다. 물론 그 지점이 너무 낮다면 문제겠지만, 사용자 친화적인 다른 우회법이 얼마든 있는 상황에서 당장 심각하게 고민할 문제는 아닐 거다.
2018-10-27:
요즘 대부분 운영 체제에서는 TCP 세그먼트가 겹칠 때 앞쪽 데이터를 우선한다.
이 부분과 관련해 좀 긴가민가한 기억이 있어서 다시 확인해 봤다.
RFC 793 “Transmission Control Protocol”, 3.9 Event Processing 절, SEGMENT ARRIVES 이벤트:
…
그 외 경우에,
첫째, 일련 번호 검사하기
…
세그먼트들을 순서대로 처리한다. 도착 시 초기 검사를 통해 오래된(역주: 동일 세션 식별자를 가진 이전의 다른 연결에서 온) 중복 세그먼트를 폐기하며, 그 다음 처리는 SEG.SEQ 순서로 이뤄진다. 세그먼트 내용이 이전 것과 새 것 사이 경계에 걸쳐 있으면 새 부분만 처리해야 할 것이다(should).
…
이후 단계에서는 세그먼트가 RCV.NXT에서 시작하고 윈도를 초과하지 않는 이상적인 세그먼트라고 가정한다. 윈도를 벗어난 부분(SYN과 FIN 포함)을 잘라내 실제 세그먼트를 이 가정에 맞추고 나서 세그먼트가 RCV.NXT에서 시작하는 경우에만 이후 처리를 할 수도(could) 있을 것이다. 시작 일련 번호가 그보다 높은 세그먼트를 이후 처리를 위해 가지고 있을 수도(may) 있다.
앞쪽 데이터 우선, 맞다.