aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-29 12:07:02 +0100
committereunhhu <[email protected]>2026-09-29 12:07:02 +0100
commitf5ce5e2b6ff0f72cc506e464d0ebfad00bed3532 (patch)
tree0334d86afde150fe889973e2d5a427cef155f6fc
parent319c2e61fabc89e2e64add218be448a9c9b6b776 (diff)
downloadpure-f5ce5e2b6ff0f72cc506e464d0ebfad00bed3532.tar.gz
pure-f5ce5e2b6ff0f72cc506e464d0ebfad00bed3532.zip
Say what an attacker on the path could do to a link, and what now stops it (098)
-rw-r--r--docs/design/011-pure-transport.md5
-rw-r--r--docs/research/098-link-security.md106
-rw-r--r--link/test-link.c11
3 files changed, 119 insertions, 3 deletions
diff --git a/docs/design/011-pure-transport.md b/docs/design/011-pure-transport.md
index be25a86..c4df992 100644
--- a/docs/design/011-pure-transport.md
+++ b/docs/design/011-pure-transport.md
@@ -27,6 +27,11 @@ M6는 [097](../research/097-lossy-paths.md)에 있다. 처음에는 패킷 순�
아래 quinn의 21–41배, 무작위 손실 아래 같은 처리량, 같거나 짧은 꼬리 지연이다. TCP CUBIC 옆에서 대역의 0.48–0.55를 쓴다.
가드레일(±25 %) 안이다.
+보안 검사는 [098](../research/098-link-security.md)에 있다. 경로 위의 공격자가 할 수 있는 다섯 가지(변조, 재전송, 위장, 폭주,
+그리고 모든 검사에서의 논스 재사용)를 결정적 검사로 만들었더니 구멍 여섯이 나왔다. 가장 무거운 것은 쉬던 보내는 이가 돌아올 때의
+논스 재사용이었다. 모두 고쳐 통과한다. 새 주소는 CHALLENGE와 RESPONSE로 확인하고, 그 전에는 받은 바이트의 3배까지만 답한다.
+전방 비밀성(에폭 바꿈)은 아직 없다.
+
2026-09-29에 링크를 `link/`로 뗐다([009](009-layout.md)). 프로토콜은 이제 소켓, 스레드, 시계가 없는 상태이고
(`link/link-protocol.h`), UDP와 스레드는 그 위의 층이다. 그래서 한 스레드가 링크 여럿을 들 수 있다. M2를 이 보내는 쪽으로
다시 돌렸더니 결과가 같았다. 다만 재시작 뒤 가장 늦은 저장이 1초였다. 받았다고 했지만 저장 소식이 없는 기록을 1초 기다리기
diff --git a/docs/research/098-link-security.md b/docs/research/098-link-security.md
new file mode 100644
index 0000000..42099e4
--- /dev/null
+++ b/docs/research/098-link-security.md
@@ -0,0 +1,106 @@
+# 098 — 링크의 보안 검사: 경로 위의 공격자가 할 수 있는 것
+
+2026-09-29. [설계 011](../design/011-pure-transport.md)의 4단계 가운데 보안 검사다. 011의 중단 기준은 이렇다. "보안 검사(증폭,
+보낸 이 위장, 재전송, 변조)에 실패하면 인터넷용으로 켜지 않는다."
+
+착수 기준:
+
+- **바꿀 결정.** 링크를 인터넷 쪽에 켤 수 있는가.
+- **최소 확인.** 경로 위의 공격자가 할 수 있는 일을 하나씩 검사로 만든다.
+ - 패킷을 바꾼다.
+ - 지난 패킷을 다시 보낸다. 보낸 이의 주소로도, 자기 주소로도 보낸다.
+ - 허용되지 않은 키나 출처로 보낸다.
+ - 알려진 키 id로 지어낸 패킷을 쏟는다.
+ - 모든 검사에서 한 키가 같은 논스로 두 번 봉인하지 않는지 본다.
+- **중단 기준.** 위 가운데 하나라도 실패하면 고칠 때까지 인터넷용으로 켜지 않는다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - 코드를 공격자의 눈으로 읽고 검사를 만들었더니 구멍이 여섯 개 나왔다.
+ - 가장 무거운 것은 논스 재사용이었다. 쉬던 보내는 이가 돌아오면, 받는 쪽이 같은 키로 답의 번호를 0부터 다시 썼다.
+ AES-GCM에서는 평문이 새고 위조까지 가능해지는 결함이다.
+ - 다음은 지난 패킷 하나로 링크를 영원히 멈출 수 있던 것이다.
+ - 모두 고쳤다. 이제 다섯 가지 공격 검사를 모두 통과한다.
+ - 검사를 만들다 공격과 무관한 버그도 하나 찾았다. RTT 표본이 0이면 페이싱이 NaN이 되어 보내는 쪽이 영원히 멈췄다.
+- 아직 모르는 것: 전방 비밀성(에폭 바꿈)은 아직 없다. 지금은 두 노드의 정적 키가 새면 녹화한 지난 기록을 풀 수 있다.
+- 사용 경험의 차이: 경로 위의 누가 패킷을 바꾸거나 되풀이하거나 쏟아부어도, 기록은 한 번씩 그대로 적용되고 링크는 이어진다.
+
+## 한 것
+
+링크의 두 쪽을 가상 시간의 네트워크에 놓는 단위 검사([test-link.c](../../link/test-link.c))에 공격자를 더했다. 모두 씨앗에서
+결정적으로 돌고, `make check`에 들어 있다.
+
+| 검사 | 공격자가 하는 것 | 통과 조건 |
+|---|---|---|
+| 논스 | (모든 검사에서) | 보낸 모든 패킷의 (키를 정하는 것, run, 번호)가 한 번씩만 나온다. 답은 받을 보내는 이의 시작마다 따로 센다 |
+| 변조 | 오가는 모든 패킷을 바이트 하나 뒤집고, 자르고, 늘린 사본을 먼저 넣는다 | 어느 쪽에서도 열리지 않고, 답을 끌어내지 않는다 |
+| 재전송 | 걸음의 25 %마다 지난 패킷 하나를 다시 넣는다. 받는 쪽에는 보낸 이의 주소나 공격자의 주소로 넣는다. 손실, 재정렬, 양쪽 재시작, 쉼이 함께 있다 | 모든 기록이 한 번씩 그대로 적용된다. 공격자에게 간 바이트는 받은 것의 3배 이하다 |
+| 위장 | 허용되지 않은 키로 보낸다. 그 패킷에 허용된 키의 id를 단다. 허용된 키로 허용되지 않은 출처 이름을 쓴다 | 앞의 둘은 열리지 않는다. 셋 다 아무것도 전달되지 않는다 |
+| 폭주 | 허용된 키의 id로, 처음 보는 run의 지어낸 패킷 1만 개를 초당 10만 개로 넣는다 | 만든 키가 300개 이하, X25519는 1번이다. 공격자에게는 0 바이트가 간다. 뒤이은 진짜 보내는 이의 기록 200건이 모두 전달된다 |
+
+## 찾아 고친 것
+
+1. **논스 재사용.**
+ - 받는 쪽은 모든 답을 자기 run 하나로 봉인했다. 답의 키는 (받는 쪽 run, 보내는 쪽 run)에서 나온다.
+ - 쉬는 보내는 이의 상태를 비운 뒤 그 보내는 이가 같은 run으로 돌아오면, 새로 만든 칸이 답의 번호를 0부터 다시 썼다. 같은
+ 키, 같은 논스다.
+ - 이제 칸을 만들 때마다 답용 run을 새로 뽑는다. 보내는 쪽은 답의 run이 바뀌면 키를 다시 만든다(원래 그렇게 했다).
+2. **지난 패킷 하나로 링크가 멈춤.**
+ - 들은 번호를 아직 모르는 흐름(재시작이나 쉼 뒤)은 쥔 기록 가운데 가장 앞의 것부터 넘겨, 호스트의 답으로 들은 번호를 배운다.
+ - 공격자가 다시 보낸 옛 패킷에 이미 저장된 기록의 일부가 들어 있으면, 그것이 맨 앞에서 영원히 온전해지지 않았다.
+ - 이제 첫 온전한 기록부터 넘긴다. 호스트의 답으로 옛 조각은 버려진다.
+3. **답을 빼앗음.**
+ - 열리기만 하면 아무리 옛 패킷이라도 답이 갈 주소를 옮겼다.
+ - 이제 그 run에서 가장 큰 번호의 패킷만 주소를 옮긴다.
+ - 새 주소에는 CHALLENGE를 보내고, 보내는 쪽이 RESPONSE로 되돌려 주면 확인된 것으로 친다. 공격자는 봉인된 답을 읽지 못하니
+ 되돌려 줄 수 없다.
+ - 확인 전에는 그 주소로 받은 바이트의 3배까지만 답한다(RFC 9000 §8). 경로 바뀜(NAT 재바인딩, 망 전환)도 이 규칙으로
+ 따라간다.
+4. **열리지 않은 패킷이 답을 끌어냄.** 이미 받은 번호면 열기 전에 확인을 다시 보냈다. 번호는 평문 머리에 있으니 위조할 수
+ 있다. 이제 열고 나서 본다.
+5. **옛 답이 보내는 쪽을 묶음.**
+ - 옛 답의 크레딧 1이 보내는 쪽을 멈출 수 있었다. 옛 답의 틈 표시는 받은 것을 모두 다시 보내게 했다.
+ - 이제 보내는 쪽은 들은 번호와 크레딧으로 들은 가장 큰 한도까지 보낸다. 설계 011의 "최댓값으로만 받아들인다"이다.
+ - 틈 표시는 저장된 번호가 뒤로 가지 않은 답에서만 받는다.
+ - 옛 답이 가져온 옛 챌린지에 밀리지 않게, 최근 챌린지 네 개를 모두 되돌려 준다.
+6. **폭주가 키를 만들게 함.**
+ - 처음 보는 run의 패킷마다 HKDF를 두 번 했다.
+ - 이제 키를 만들었는데 열리지 않으면 토큰 하나를 쓴다. 토큰은 초당 256개로 채운다.
+ - 정상적인 대량 깨움(M2의 기기 1만 대)은 모두 열리니 토큰을 쓰지 않는다. 폭주 중에는 이미 아는 run의 패킷만 제 속도로 받고,
+ 새 run은 초당 256개까지만 받는다.
+ - X25519는 보내는 이마다 한 번이다. 비밀을 메모에 적어 두기 때문이다(095).
+
+변조 검사는 처음부터 통과했다. 머리를 연관 데이터로 넣은 AES-GCM 덕분이다. 위장 검사도 통과했다.
+
+## 결과
+
+- `make check`가 통과한다. UDP 링크 검사(재시작, 손실, 쉼, 메모)도 경로 확인이 들어간 채 통과한다.
+- 전송이 기록 하나에 쓰는 CPU는 61 B와 1 KiB에서 같다. 16 KiB는 5 % 높은데, 앞선 라운드들의 흔들림(19.7–22.5 µs) 안이다.
+- 검사 결과(가상 시간):
+
+| 경우 | 기록 | 가상 시간 | 결과 |
+|---|---:|---:|---|
+| 변조 | 1,500 | 3.9초 | 바뀐 패킷 11,583개, 열린 것도 답을 끌어낸 것도 0 |
+| 재전송(두 씨앗) | 3,000 | 15.8·17.3초 | 다시 넣은 패킷 2,354·2,403개. 모든 기록이 한 번씩. 공격자가 보낸 715·892 KB에 공격자에게 간 답은 0 바이트 |
+| 위장 | – | – | 전달 0 |
+| 폭주 | 1만 패킷 | 0.1초 | 키 282개, X25519 1번, 뒤이은 200건 모두 전달 |
+
+## 판정
+
+- 011의 보안 중단 기준(증폭, 보낸 이 위장, 재전송, 변조)은 검사한 범위에서 모두 통과한다.
+- 남은 약점은 설계가 알고 있던 둘이다.
+ - **전방 비밀성이 없다.** 키는 두 정적 키와 run에서만 나온다. 정적 키가 새면 녹화한 트래픽을 풀 수 있다. 011은 이것을
+ 주기적인 에폭 바꿈(임시 키 교환)으로 얻겠다고 했고, 아직 구현하지 않았다. 그때까지 인터넷용으로 켜기 전에 풀어야 할 첫
+ 과제다.
+ - **KCI.** 받는 쪽의 개인 키를 가진 자는 어떤 보내는 이로도 행세할 수 있다. 정적-정적 X25519의 성질이다. Noise KK의 첫
+ 메시지도 같다. 에폭 바꿈 뒤의 키에는 임시 키가 섞이므로 이 창이 첫 에폭으로 좁아진다.
+
+## 한계와 다음
+
+- 검사는 가상 네트워크의 결정적 흉내다. 실제 공격 도구로 쏟아부은 것은 아니다.
+- 폭주 중에는 새 run이 초당 256개로 묶인다. 정상 기기의 재연결이 그 사이에 늦을 수 있다. QUIC의 Retry처럼 주소를 증명하는
+ 토큰은 아직 없다.
+- 보내는 쪽은 옛 답을 받을 때마다 그 답의 run 키를 다시 만든다(HKDF). 옛 답을 되풀이하는 공격자는 보내는 쪽의 CPU를 조금
+ 쓰게 할 수 있다.
+- 다음은 011 4단계의 나머지다. 에폭 바꿈(전방 비밀성), 경로 바뀜의 실제 측정(주소가 바뀌는 동안 끊긴 시간), 저장소 옮기기(M3).
diff --git a/link/test-link.c b/link/test-link.c
index 4f68aa9..c69af83 100644
--- a/link/test-link.c
+++ b/link/test-link.c
@@ -72,7 +72,7 @@ typedef struct {
size_t nonce_count, nonce_capacity;
Packet *history;
size_t history_count;
- uint64_t attacker_in, attacker_out;
+ uint64_t attacker_in, attacker_out, bent, replayed;
unsigned loss, doubled, spread_ms; /* percent, percent, the most a packet is held back */
uint64_t now, sent;
/* The receiving app: its last heard (kept across the receiver's
@@ -199,6 +199,7 @@ static void tamper(World *w, PureLinkSender *s, PureLinkReceiver *r, const Packe
else bent[size++] = (unsigned char)next_random();
PureLinkCounts before, after;
unsigned answers = 0;
+ ++w->bent;
if (p->to_receiver) {
pure_link_receiver_counts(r, &before);
pure_link_receiver_take(r, bent, size, from, 7, w->now);
@@ -220,6 +221,7 @@ static void replay(World *w, PureLinkSender *s, PureLinkReceiver *r, const char
const Packet *h = &w->history[next_random() % (w->history_count < HISTORY ? w->history_count : HISTORY)];
const char *only = getenv("TEST_LINK_REPLAY");
if (only && strcmp(only, h->to_receiver ? (chance(50) ? "receiver-sender" : "receiver-attacker") : "sender")) return;
+ ++w->replayed;
if (!h->to_receiver) pure_link_sender_take(s, h->bytes, h->size, w->now);
else if (only ? !strcmp(only, "receiver-sender") : chance(50)) pure_link_receiver_take(r, h->bytes, h->size, from, 7, w->now);
else {
@@ -378,10 +380,13 @@ static Outcome run(const Case *c, const char *dir) {
CHECK(w.attacker_out <= 3 * w.attacker_in, "%s: %llu bytes to the attacker for its %llu", c->name,
(unsigned long long)w.attacker_out, (unsigned long long)w.attacker_in);
printf("{\"case\":\"%s\",\"records\":%llu,\"virtual_ms\":%llu,\"packets\":%llu,\"resent\":%llu,\"lost\":%llu,"
- "\"deliveries\":%llu,\"woken\":%llu,\"remembered\":%llu,\"swept\":%llu}\n",
+ "\"deliveries\":%llu,\"woken\":%llu,\"remembered\":%llu,\"swept\":%llu,\"changed\":%llu,\"replayed\":%llu,"
+ "\"attacker_in\":%llu,\"attacker_out\":%llu}\n",
c->name, (unsigned long long)c->records, (unsigned long long)o.seconds_ms, (unsigned long long)o.sent.packets,
(unsigned long long)o.sent.resent, (unsigned long long)o.sent.lost, (unsigned long long)w.deliveries,
- (unsigned long long)o.heard.woken, (unsigned long long)o.heard.remembered, (unsigned long long)o.heard.swept);
+ (unsigned long long)o.heard.woken, (unsigned long long)o.heard.remembered, (unsigned long long)o.heard.swept,
+ (unsigned long long)w.bent, (unsigned long long)w.replayed, (unsigned long long)w.attacker_in,
+ (unsigned long long)w.attacker_out);
pure_link_sender_free(s);
pure_link_receiver_free(r);
pure_seal_key_free(a);