특정 문제의 풀이나 플래그는 다루지 않는다. 원리 설명도 최소한만 한다. 개념은 이해했는데 코드가 동작하지 않을 때 필요한 것들에 집중한다.
이해했는데 안 될 때
Microarchitecture Exploitation 도장을 풀면서 짠 코드가 스무 개쯤 된다. 그중 대부분은 처음에 동작하지 않았다.
원리를 몰라서가 아니었다. 권한 검사보다 데이터 로드가 먼저 완료되고 그 사이에 투기적으로 실행된 명령이 캐시에 흔적을 남긴다는 것, 분기 예측기를 훈련시키면 경계 검사 밖의 코드가 잠깐 실행된다는 것. 이건 설명을 읽으면 이해된다. 이해한 상태로 코드를 짜도 아무것도 나오지 않는다.
여기서부터가 문제다. 일반적인 익스플로잇은 실패하면 원인을 볼 수 있다. 디버거를 붙이고, 브레이크포인트를 걸고, 메모리를 덤프한다. 이 분야는 그게 안 된다. 투기 실행 중의 레지스터는 아키텍처 상태가 아니라서 관측할 수 없고, 캐시 상태는 접근 시간으로 간접 추론만 가능하며, 그 추론마저 노이즈에 묻힌다.
그래서 여기서 코드를 짠다는 건 프로그램을 만드는 일이라기보다 실험 장치를 만드는 일에 가깝다. 결과를 얻는 코드가 아니라, 실패했을 때 왜 실패했는지 말해주는 코드를 먼저 만들어야 한다.
이 글은 그 장치를 만들면서 알게 된 것들이다.
게이트만 바뀐다
여러 문제를 풀고 나면 하나의 뼈대가 보인다.
무엇이 일어나선 안 되는 접근을 유발하는가(게이트), 그 접근으로 얻은 값이 어떻게 관측 가능한 부작용으로 바뀌는가(가젯), 그 부작용을 어떻게 읽는가(채널), 노이즈 속에서 어떻게 안정적으로 복원하는가(신호처리).
이 넷 중에서 게이트만 문제마다 바뀐다. Spectre v1이면 조건 분기, v2면 간접 분기 목적지, Meltdown이면 권한 위반 로드다. 그리고 나머지 셋은 그대로 재사용된다. 한 번 제대로 만든 Flush+Reload 하네스는 게이트를 갈아 끼우며 계속 쓴다.
이게 얼마나 그대로인지는 v1용 코드와 v2용 코드를 나란히 놓으면 드러난다. 커널 모듈을 상대하는 두 문제에서 내 쪽 코드의 차이는 한 줄이었다.
| |
채널도, 스코어링도, 캘리브레이션도, 재시도 로직도 건드리지 않았다. 조건 분기를 속이느냐 간접 분기 목적지를 속이느냐는 커널 모듈 안에서 갈리고, 내가 한 일은 파라미터 하나를 넘긴 것뿐이다.
이걸 알고 나면 새 문제를 볼 때 던지는 질문이 하나로 줄어든다. 여기서 게이트가 뭔가.
그리고 게이트는 대개 코드에 힌트를 남긴다. 경계 검사에 쓰이는 피연산자를 굳이 clflush하는 코드가 있다면, 그건 검사를 느리게 만들어 투기 창을 넓히라는 뜻이다. 간접 호출 직전에 함수 포인터를 flush한다면 BTI를 노리라는 뜻이고. 커널 모듈이 secret을 만지는데 정상 경로로는 그 값을 되돌려 볼 방법이 없다면, 그 비대칭 자체가 사이드 채널을 쓰라는 설계다.
게이트가 네이티브 코드가 아닌 경우도 있다. 커스텀 VM이 올라간 문제에서는 게이트가 그 VM의 시스템콜 구현 안에 있고, 내가 하는 일은 그 VM의 명령어로 프로그램을 짜는 것이 된다. 파일을 열고, 읽고, secret 바이트를 인덱스로 쓰는 명령을 호출하는 프로그램이다.
| |
바이트 하나를 leak할 때마다 프로그램을 다시 만들지 않고 즉치값 한 바이트만 패치한다. 게이트가 데이터로 표현되니 공격 루프가 그만큼 가벼워진다.
이런 힌트를 읽는 눈이 생기면 리버싱 단계가 짧아진다. 반대로 이걸 못 보면 VM ISA를 전부 복원하고도 어디를 공격해야 할지 모른 채 멈춘다.
게이트를 내가 만들어야 할 때
Meltdown은 게이트를 만들 필요가 없다. 커널 주소를 그냥 읽으면 폴트가 나고, 그 폴트가 곧 게이트다. Spectre는 다르다. 예측기가 틀리게 예측하도록 내가 상황을 만들어줘야 한다.
처음 짠 코드에는 훈련이 아예 없었다. 목표 인덱스를 써넣고 피해자를 깨우는 것만 반복했다.
| |
당연히 나오지 않았다. 예측기 입장에서는 매번 같은 상황이고, 틀리게 예측할 이유가 없다.
다음 버전에서 훈련을 넣었다. 고정된 인덱스로 예측기를 여러 번 기울인 뒤, 마지막 한 번만 목표 인덱스를 넣는다.
| |
여기서 세 가지를 의도했다.
훈련 횟수를 난수로 잡은 건 고정 길이 패턴 자체를 예측기가 학습해버리는 걸 피하려는 것이다. 10번씩 정확히 반복하면 예측기가 “11번째는 다르다"를 배울 여지가 생긴다. 실제로 이 변경의 기여도를 따로 측정하지는 못했다.
프로브 배열을 공격 시점에만 flush하는 것도 의도가 있다. 훈련 라운드마다 256개를 전부 flush하면 그 자체가 캐시를 흔들고 시간도 잡아먹는다. 훈련은 예측기만 건드리면 되니 캐시는 건드리지 않는 편이 낫다.
가장 효과가 컸던 건 _mm_clflush(index_ptr)다. 피해자의 경계 검사가 이 값에 의존하는데, 그 값이 캐시에 없으면 검사 결과가 나오기까지 시간이 걸린다. 그 지연이 곧 투기 창이다. Spectre에서 창을 넓히는 정공법이 이거다. 뒤집어 말하면, 문제 코드에 검사 피연산자를 굳이 flush하는 줄이 있다면 그건 출제자가 창을 열어둔 것이다.
그리고 이 코드에는 지연 루프도 하나 들어 있다.
| |
나중에 Meltdown에서 똑같이 생긴 걸 썼다가 완전히 실패한다. 같은 코드인데 결과가 갈린 이유는 목적이 달라서다. 여기서는 다른 프로세스가 투기 실행할 시간을 벽시계로 기다리는 것이고, Meltdown에서는 내 명령 흐름 안의 투기 창을 늘리려던 것이었다. 앞의 것은 되고 뒤의 것은 안 된다. 그 얘기는 마지막에 하겠다.
실패를 분리할 수 없으면 아무것도 못 고친다
처음 짠 코드는 실패했을 때 할 수 있는 게 없었다. 값이 안 나오는데 채널이 깨진 건지, 투기 창이 안 열린 건지, 주소가 틀린 건지 구분이 안 됐다. 그래서 뭔가를 바꾸고, 안 되고, 또 바꾸고, 안 되고를 반복했다.
나중에는 코드를 모드로 나눠 짰다.
| |
MODE 0이 실패하면 캐시 채널 문제고, MODE 1이 실패하면 fault 억제나 투기 창 문제고, MODE 2만 실패하면 주소나 L1 상주 문제다. 실패 지점이 원인을 특정한다. 이 구조를 만들고 나서 디버깅 시간이 자릿수로 줄었다.
같은 발상이 대상 선택에도 적용된다. 커널 메모리를 처음 읽을 때 플래그를 노리면 안 된다. 값이 안 나와도 그게 실패인지 원래 그런 건지 모르니까. 대신 정답을 아는 것부터 읽는다. task_struct의 comm 필드에는 프로세스 이름이 ASCII로 들어 있어서, 화면에 babyarch_molten이 찍히는 순간 게이트와 주소와 채널이 한꺼번에 검증된다.
권한 문제로 디버거를 못 붙이는 환경이라도 이건 된다. 내 프로세스의 이름은 내가 아니까.
한 바이트가 읽히면 나머지는 평범한 일이 된다
comm이 화면에 찍히고 나면 손에 들어오는 건 딱 하나다. 커널 주소에서 1바이트를 읽는 함수.
| |
이걸 여덟 번 부르면 qword가 되고, 그 순간 커널 메모리가 그냥 읽을 수 있는 배열이 된다. task_struct에서 mm을 꺼내고, mm에서 pgd를 꺼내는 건 오프셋 산술일 뿐이다. 평범한 포인터 추적이다.
여기가 이 계열 문제의 구조적 특징이라고 생각한다. 기법 설명은 “커널 메모리를 읽을 수 있다"에서 끝나지만, 실제 목표는 대개 다른 프로세스의 유저 메모리에 있다. 그 사이를 잇는 건 사이드 채널이 아니라 커널 자료구조에 대한 지식이다. Meltdown은 사슬의 첫 고리일 뿐이고, 나머지는 리눅스 메모리 관리를 아는 사람이면 누구나 하는 작업이다.
다만 주소가 세 종류로 섞여 나온다는 게 혼란의 원인이다. 피해자가 보는 유저 가상주소가 있고, leak_byte에 넣을 수 있는 커널 가상주소가 있고, 페이지테이블 엔트리 안에 담겨 있는 물리주소가 있다. 셋을 구분하지 않으면 어느 줄에서 뭘 다루고 있는지 금방 놓친다.
셋을 잇는 다리가 direct map이다. 리눅스는 부팅 시 물리 메모리 전체를 커널 주소 공간에 선형 매핑해두는데, 그 덕에 PAGE_OFFSET + 물리주소가 곧 읽을 수 있는 주소가 된다. “물리 주소를 알면 읽을 수 있다"가 성립하는 순간 워크가 가능해진다.
워크 자체는 지루하다. 가상주소의 비트를 9개씩 잘라 인덱스를 만들고, 엔트리에서 다음 테이블의 물리주소를 꺼내고, 네 번 반복한다. CPU가 하드웨어로 하는 일을 C로 옮긴 것뿐이다.
어려운 건 다른 데 있다. 모든 읽기가 확률적이라는 것. 한 단계에서 한 바이트가 잘못 나오면 다음 테이블의 주소가 틀리고, 그 아래로는 전부 쓰레기가 된다. 그리고 아무런 에러 메시지도 없다. 그냥 이상한 값이 나올 뿐이다.
그래서 단계마다 검증을 넣는다. present 비트가 서 있는지, 나온 물리주소가 이 시스템의 메모리 크기 안에 들어오는지, 같은 주소를 여러 번 읽었을 때 일관된 값이 나오는지. 엔트리가 0으로 나왔을 때 그게 매핑이 없는 건지 읽기가 실패한 건지 구분하려면 이 정도는 있어야 한다.
구조체 오프셋은 추측하면 안 된다. 커널 버전마다 다르고, 하나 틀리면 그 아래가 전부 무너지는데 원인을 찾기가 지독하게 어렵다. 모듈에 디버그 정보가 남아 있으면 pahole로 확정할 수 있고, 없으면 /sys/kernel/btf/vmlinux에서 뽑는다. 이건 5분이면 되는 일이라 아낄 이유가 없다.
정리하면 이 사슬 전체가 원시 기능 하나 위에 얹혀 있다. 그러니 그 하나를 최대한 단단하게 만드는 데 시간을 쓰는 게 맞다. 아래 얘기들이 전부 거기에 관한 것이다.
신호가 약할 때 무엇을 보는가
히스토그램을 항상 출력하는 습관이 큰 도움이 됐다. 최종 판정만 보면 원인을 못 짚지만, 라운드별로 어느 페이지가 몇 표를 받았는지 보면 실패의 모양이 보인다.
표가 전혀 없으면 게이트가 안 열린 것이다. 산발적으로 흩어져 있으면 신호는 있는데 약한 것이고, 이건 반복으로 이길 수 있다. 그리고 표가 전부 page 0에 몰려 있으면 그건 fault 후 레지스터가 0으로 편향된 전형적인 모습이다. 값이 새지 않고 0으로 읽힌 것이니, page 0을 카운트에서 빼면 그 아래 묻혀 있던 진짜 신호가 드러난다.
측정 지표를 바꾼 것도 컸다. 처음에는 라운드마다 가장 빠른 페이지 하나에만 표를 줬다.
| |
신호가 약하면 노이즈가 그 한 표를 계속 훔쳐간다. 임계값 이하인 페이지를 전부 누적하도록 바꾸니 확연히 좋아졌다.
| |
secret은 게이트가 성공할 때마다 매번 득점하고 노이즈는 산발적이라, 시행을 쌓을수록 격차가 벌어진다. 시도 횟수도 50에서 500, 1000으로 올렸는데 누적 방식으로 바꾼 뒤에야 그 증가가 의미를 가졌다.
한 번에 다 안 나오는 문제도 있었다. 안 새는 바이트가 실행할 때마다 달라졌는데, 이건 실패가 구조적이 아니라 확률적이라는 뜻이다. 바이트별 히스토그램을 여러 패스에 걸쳐 누적하고, 확신이 선 바이트는 건너뛰며 남은 자리에 시도를 몰아주는 식으로 처리했다. 쉬운 위치부터 채워지고 어려운 위치는 독립적인 재시도 기회를 얻는다.
그리고 “모름"을 값으로 표현해야 한다.
| |
실패와 “값이 0"을 구분하지 않으면 상위 로직에서 원인을 추적할 수 없다. 이건 사이드 채널만의 얘기가 아니라 불확실한 측정을 다루는 모든 코드에 해당한다.
도메인을 알면 노이즈를 줄일 수 있다
읽으려는 대상에 대해 아는 것이 있다면, 그걸 필터로 쓰면 노이즈가 통째로 사라진다.
플래그는 출력 가능한 ASCII다. 그러면 후보 페이지를 32~126으로 제한할 수 있고, 상위 바이트 노이즈가 argmax를 훔치는 일이 애초에 없어진다. 프로브하고 flush할 라인도 줄어드니 한 라운드가 빨라지는 건 덤이다.
포인터와 페이지테이블 엔트리는 더 강한 구조를 갖는다. 페이지 정렬이면 하위 12비트가 0이고, direct-map 주소면 상위 바이트가 고정이며, present 비트는 서 있어야 한다. 이 불변식을 알고 있으면 1바이트 오류를 자동으로 잡아낼 수 있다.
실제로 이게 검출기로 동작한 순간이 있다.
| |
mm은 하위 1비트가 잘못 서 있고 pgd는 한 바이트가 통째로 빠졌다. 둘 다 “커널 포인터가 이렇게 생길 리 없다"는 조건에 걸렸고, 그래서 재교정이 트리거됐고, 다음 시도에서 제대로 나왔다.
채널과 출구는 별개 문제다
Flush+Reload를 오래 쓰다 보면 그게 유일한 선택지인 것처럼 느껴진다. 그런데 캐시 라인의 상태를 읽는 방법도, 읽어낸 값을 밖으로 꺼내는 방법도 각각 다른 문제다.
한 문제에서는 prefetcht0의 지연 자체를 채널로 썼다. 매핑된 페이지와 매핑되지 않은 페이지의 프리페치 소요 시간이 다르다는 점을 이용해 주소 공간을 훑는다.
| |
여기서 얻는 건 데이터가 아니라 주소다. 데이터 leak과 주소 탐색이 같은 도구로 되는 셈인데, 이것도 생각해보면 당연하다. 캐시나 TLB 상태에 흔적을 남기는 모든 동작은 채널이 될 수 있고, 그 흔적이 값을 말해주느냐 존재 여부를 말해주느냐가 다를 뿐이다.
후보를 찾으면 바로 받아들이지 않고 다섯 번 더 확인한다.
| |
주소 공간 전체를 훑으면 한 번쯤은 노이즈로 빠르게 나온다. 다섯 번 연속으로 빠를 확률은 훨씬 낮다. 스캔처럼 후보가 많은 작업에서는 이런 확인 단계가 없으면 오탐이 결과를 지배한다.
출구는 더 극단적이었다. 공유 메모리가 없어서 프로세스 종료 코드로 한 바이트씩 꺼냈다.
| |
한 바이트마다 프로세스를 새로 띄운다. 대역폭으로 보면 최악이지만, 채널이 하나도 없는 것보다는 낫다. 그리고 이 관점이 생기면 다른 것들도 출구로 보이기 시작한다. 종료 코드, 실행 시간, 시그널 종류, 로그 한 줄.
여기서 임계값을 정하는 방식도 달랐다. 측정해서 유도하는 대신, 후보 목록을 두고 전부 시도한 뒤 출력이 그럴듯한 것을 채택했다.
| |
플래그가 출력 가능한 ASCII라는 사실이 임계값 선택기 역할을 한다. 캘리브레이션할 방법이 마땅치 않은 환경에서 쓸 만한 우회다. 다만 검증 조건이 느슨하면 잘못된 임계값이 우연히 통과할 수 있으니, 도메인 제약이 충분히 좁을 때만 성립한다.
캘리브레이션은 고정값이 아니다
초기 코드에는 이런 줄이 있었다.
| |
주석이 이미 문제를 다 말하고 있다. 시스템마다 다르다는 걸 알면서 상수로 박아둔 것이다. 나중에 매 실행 시작할 때 재도록 바꿨고, 결과가 터무니없으면 기본값으로 되돌리는 안전장치를 붙였다.
| |
여기까지는 어렵지 않았다. 몰랐던 건 한 실행 안에서도 튄다는 것이었다.
임계값이 174로 잡혀서 잘 돌던 코드가, 어느 순간 287로 잡히더니 그 뒤로 읽는 값이 전부 쓰레기가 됐다. 워밍업을 넣고 여러 번 재측정한 뒤 보수적인 값을 채택하도록 바꿔서 빈도는 줄였지만, 완전히 없애지는 못했다.
평균 대신 중앙값을 쓰는 것도 도움이 됐다. 타이밍 분포는 꼬리가 길어서 인터럽트 한 번이 평균을 통째로 끌어올린다.
| |
캘리브레이션 대상을 바꾸는 방법도 있었다. 합성된 접근으로 HIT을 재는 대신, 정답을 짐작할 수 있는 실제 공격 경로를 그대로 돌려서 재는 것이다. 플래그가 pwn.college{로 시작한다면 첫 글자는 p일 테니, 그 페이지가 캐시에 올라오는 시간이 곧 이 공격 경로에서의 HIT이다. 짐작이 틀리면 캘리브레이션도 틀리지만, 합성 측정과 실제 경로의 특성이 다를 때는 이쪽이 정확하다.
그래서 위에 나온 구조 검사를 재교정 트리거로 붙였다. 결과가 구조적으로 불가능하면 다시 재고 다시 시도한다. 로그에 재교정이 찍히고 그다음 읽기가 정상화되는 걸 확인했을 때는 꽤 만족스러웠다.
그런데 이 장치가 나중에 발목을 잡았다.
자동 복구가 무한 루프가 될 때
피해 프로세스를 공격자와 같은 코어에 고정해본 적이 있다. 캐시를 공유하니 더 잘 되지 않을까 하는 생각이었다.
정반대였다. 잘 되던 읽기가 무너졌다. 피해 프로세스의 무한 루프가 공격자의 캐시 상태를 계속 파괴해서, mm과 pgd 단계부터 값이 나오지 않았다. 고정을 빼자 바로 정상으로 돌아왔다.
여기서 재교정 로직이 무한 루프에 빠졌다. 구조 검사가 계속 실패하니 계속 재교정하는데, 원인이 캘리브레이션이 아니라 캐시 간섭이라 아무리 다시 재도 정상화될 리가 없었다.
재교정 자체가 잘못된 건 아니다. 다만 자동 복구 장치는 자기가 대응하도록 설계된 실패에만 유효하다. 다른 원인의 실패를 만나면 조용히 무한 루프가 되고, 그러면 진짜 원인을 찾는 시간이 오히려 늘어난다. 자동화가 실패를 감춰버리면 자동화하지 않느니만 못하다.
다른 문제에서 쓴 코드에는 마침 이 문제가 없었다. 재시도 상한을 두고, 초과하면 그때까지의 최선을 표시와 함께 받아들인다.
| |
(WEAK) 표시가 핵심이다. 값을 받아들이되 신뢰도가 낮다는 걸 남긴다. 그러면 전체 결과를 보고 어느 자리가 의심스러운지 알 수 있고, 나머지 문자로 문맥이 잡히면 손으로 메울 수도 있다. 멈추지도 않고 실패를 감추지도 않는 절충이다.
그리고 이 경험이 남긴 더 중요한 감각이 있다. 누가 캐시를 채우고 누가 파괴하는가를 항상 의식해야 한다는 것. co-location은 상황에 따라 득이 되기도 하고 독이 되기도 한다.
덧붙여 솔직하게 밝혀두면, 공격자 쪽 코어 고정은 처음부터 켜둔 채로 진행했고 끄고 켠 비교는 하지 않았다. 코어 고정이 필요하다는 근거는 이론상의 것이고 이 환경에서 그 차이를 실측한 건 아니다.
아무도 자세히 말해주지 않는 문제
Meltdown은 대상이 L1에 있을 때만 읽힌다. L2나 메모리에 있으면 데이터가 도착하기 전에 권한 검사가 먼저 끝난다.
설명은 이 조건을 한 줄로 짚고 넘어간다. 실제로는 여기서 가장 많이 막힌다. 읽고 싶은 그 바이트를, 내가 읽기 직전에, 어떻게 L1에 올려둘 것인가.
답은 매번 달랐다. 커널이나 VM 로직 중에 secret을 순회하는 경로가 있으면 그걸 호출해서 대신 읽게 만든다. 유저 포인터를 역참조하는 ioctl이 있으면 값을 안 돌려줘도 그 캐시 라인은 L1에 남으니 사실상 프리로더가 된다. 실제로 이 프리로드를 넣기 전후로 2000회 중 1991회 일치라는 수치가 나왔다.
어떤 인터페이스는 커널이 페이지테이블을 순회하게 만들어서 간접적으로 원하는 라인을 올려준다. 다만 권한 검사가 워킹보다 먼저 걸리는 경우, 예를 들어 setuid 프로세스를 대상으로 하면 아무 효과가 없다. 이 실패를 다른 실패와 구분하지 못하면 한참 헤맨다.
피해 프로세스가 계속 돌고 있다면 그 자체에 기댈 수도 있다. 자기 데이터를 자기 코어 캐시에 유지하고 있을 테니까. x86 L1D는 물리 주소로 인덱싱되므로, 같은 물리 라인이면 어느 가상주소로 접근해 캐시됐든 같은 엔트리다. 피해가 자기 유저 VA로 올려둔 것을 내가 direct-map 주소로 읽을 수 있는 이유가 이것이다.
priming을 어떻게 할 것인가는 결국 “이 시스템에서 그 메모리를 만지는 주체가 누구인가"를 묻는 일이다. 그리고 그 질문은 시스템의 구조를 이해하지 않으면 답할 수 없다. 사이드 채널이 순수한 하드웨어 문제가 아니라 시스템 문제인 지점이다.
C 소스는 CPU와의 계약이 아니다
가장 오래 붙잡았고, 가장 많이 배운 지점이다.
투기 창을 넓히려고 이렇게 썼다.
| |
부동소수점 나눗셈은 느리니까 권한 검사가 늦어지고 투기 창이 넓어질 거라고 생각했다. 히스토그램이 완전히 비었다. 반복 횟수를 바꿔봐도, volatile을 더 붙여봐도 그대로였다.
인라인 어셈블리로 바꾸니 즉시 동작했다.
| |
같은 의도인데 결과가 갈렸다. 컴파일러는 의미만 보존하면 명령 순서를 바꾸고, 명령을 없애고, 레지스터를 재배치할 자유가 있다. volatile이 보장하는 건 “이 접근을 제거하지 마라"까지다. 주변 명령의 배치는 보장하지 않는다.
생각해보면 당연하다. C의 의미론은 추상 기계 위에 정의되어 있고, 그 추상 기계에는 투기 실행도 캐시도 분기 예측기도 없다. 내가 다루려는 대상은 전부 그 추상화 아래에 있다. 추상화 위에서 아래를 조종하려 한 셈이다.
이 영역에서 인라인 어셈블리는 취향이 아니라 필수인 지점이 있다. 그리고 그것보다 더 중요한 습관 하나는, 핵심 루프를 항상 objdump -d로 확인하는 것이다. 내가 쓴 C와 CPU가 실행할 명령을 대조해보는 것. 이 습관이 없으면 왜 안 되는지 영원히 모른다.
jz 1b 재시도 루프가 왜 효과적인지도 짚어둘 만하다. 값이 0이면 다시 루프를 도는 분기를 두면, CPU가 그 안을 투기적으로 반복하면서 프로브 접근에 도달할 기회가 늘어난다. 투기 창을 “넓히는” 게 아니라 “여러 번 시도하게” 만드는 접근이다.
정리
이 도장에서 얻은 게 사이드 채널 지식이라고는 생각하지 않는다. 그건 찾아보면 어디에나 있다.
남은 건 관측할 수 없는 대상을 다루는 방식이다. 정답을 아는 것부터 검증하고, 실패가 원인을 특정하도록 구조를 짜고, 상수를 추측하지 말고 측정해서 유도하고, 확실하지 않은 결과를 “모름"으로 표현하고, 안 될 때 뭔가를 더하는 대신 덜어내면서 한 번에 하나씩만 바꾸는 것.
이건 사이드 채널에만 쓰이는 게 아니다. 불확실한 관측을 다루는 모든 작업에 그대로 옮겨간다.
그리고 하나 더. 개발자가 상정하지 않은 상태에 시스템을 밀어넣는다는 관점은 이 분야 밖에서도 계속 만난다. 커널 레이스는 시간 축에서 그렇게 하고, 스마트 컨트랙트의 불변식 위반은 호출 순서 축에서 그렇게 한다. 축만 다르고 묻는 질문은 같다. 이 상태 기계가 도달할 수 있지만 아무도 생각하지 않은 상태는 어디인가.