메인 콘텐츠로 이동
·

내 페디버스 인박스는 서명은 검증하고, 그 키가 누구 것인지는 키의 자기주장을 믿었다 — 2.3.6에선 아무 서버나 남을 사칭할 수 있었다

9월 27일에 Fedify 취약점 세 건이 GitHub advisory 로 올라왔다. 그중 하나(GHSA-q9f8-5hc7-898f)가 critical 이었고, 요지는 이랬다. 들어온 활동의 HTTP 서명은 검증하는데, 그 서명 키가 누구 것인지는 키 문서가 스스로 밝힌 owner 를 그대로 믿는다. 내 블로그 인박스는 그때 @fedify/fedify 2.3.6 이었다. 바로 2.3.8 로 올려 배포했다.

올리고 나서 궁금한 건 하나였다. 그 버전이 정확히 무엇을 받아줬나. 릴리스 노트의 문장을 믿는 것과 내 인박스가 실제로 위조를 통과시키는 걸 보는 건 다르다. 그래서 2.3.6 과 2.3.8 을 한 폴더에 npm 별칭으로 나란히 깔고, 가짜 요청을 만들어 양쪽에 넣어봤다.

위조 요청을 만들어 2.3.6 에 넣었다

공격자 역할과 피해자 역할, 그리고 내 인박스 역할을 인메모리 문서 로더 하나로 세웠다. 실제 네트워크는 한 번도 안 나간다. 피해자 alice 의 액터 문서는 자기 키 alice#main-key 하나만 나열한다. 공격자는 자기 HTTP 서버에 키 문서를 하나 올리는데, 그 키의 owner 를 alice 라고 적는다. 거짓말이다. 공개키는 공격자 자기 것이다.

그 공격자 키로 alice 명의의 Create 를 HTTP 서명해서 내 인박스로 보낸다. 서명 자체는 유효하다. 공격자가 자기 개인키로 제대로 서명했으니까. 관건은 Fedify 가 이 키를 alice 것으로 인정하느냐다.

// 공격자 키 — owner 를 피해자로 자기주장
const attackerKeyDoc = {
  '@context': 'https://w3id.org/security/v1',
  id: 'https://attacker.example/evil#main-key',
  type: 'CryptographicKey',
  owner: 'https://victim.example/users/alice',  // 거짓말
  publicKeyPem: attackerPem,                     // 공격자 자기 키
};
 
let req = new Request(MY_INBOX, { method: 'POST', body: JSON.stringify(createActivity) });
req = await signRequest(req, attackerPair.privateKey, new URL(ATTACKER_KEY));
 
const key = await verifyRequest(req, opts);                 // 서명 검증
const activity = await Create.fromJsonLd(createActivity, opts);
const owns = await doesActorOwnKey(activity, key, opts);    // 인박스 소유권 게이트

결과는 갈렸다.

단계2.3.62.3.8
verifyRequest (서명 검증)OK, 서명 유효null
doesActorOwnKey (소유권)true도달 못 함
판정수락 — 위조 Create 가 alice 것으로거부

2.3.6 에서 doesActorOwnKey 가 true 를 돌려줬다. 공격자가 올린 키 하나로 alice 명의의 답글이 내 인박스를 통과했다는 뜻이다. 2.3.8 은 그 앞 단계인 verifyRequest 에서 이미 null 이라 소유권 검사까지 가지도 않는다.

공격자 키가 owner 를 피해자로 자기주장하면 2.3.6 은 피해자 서버에 되묻지 않고 수락하고, 2.3.8 은 피해자 문서가 그 키를 나열하는지 확인해 거부하는 흐름 손그림

게이트는 키의 말을 믿고 있었다

인박스가 서명을 검증한 뒤 활동을 리스너로 넘기기 전에 거치는 검사가 doesActorOwnKey(activity, key) 다. 미들웨어에 if (httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)) 로 걸려 있고, 여기서 거짓이면 활동을 버린다. 2.3.6 의 그 함수는 이렇게 생겼다.

// 2.3.6
if (key.ownerId != null) {
  const owns = key.ownerId.href === activity.actorId?.href;
  return owns;   // 키가 밝힌 owner 가 활동 actor 와 같으면 참
}
// owner 가 없을 때만 actor 를 fetch 해 그 문서의 publicKey 를 확인

키가 스스로 owner: alice 라 밝혔고 활동의 actor 도 alice 다. 두 문자열이 같으니 참이다. 여기서 alice 의 액터 문서를 열어 그 키가 진짜 alice 것인지 확인하는 절차가 없다. 키의 자기소개를 그대로 신원으로 받는다. 2.3.8 은 같은 자리에서 owner 를 실제로 fetch 해서, 그 문서가 이 키를 되가리키는지 확인한 다음에야 통과시킨다.

// 2.3.8
if (key.ownerId != null) {
  const owner = await verifyKeyOwnership(key, options);  // owner 문서를 fetch
  if (owner?.id != null && owner.id.href === actorId.href) return true;
}
const actor = await fetchActorDocument(actorId, options);
if (actor != null && await doesActorListKey(actor, key, options)) return true;
return false;

엉뚱한 함수를 먼저 의심했다

처음엔 이 버그를 재현하지 못했다. advisory 가 getKeyOwner() 를 지목하길래 그걸 불렀는데, 2.3.6 에서도 null 이 나왔다. 공격자 키를 넣어도 owner 로 alice 를 안 돌려줬다. 취약하다는데 안 뚫리니 한참 헤맸다.

getKeyOwner 의 2.3.6 소스를 열어보고 나서야 알았다. 그 함수는 2.3.6 에서도 owner.publicKeyIds 에 이 키가 있는지 검사하고 있었다. 즉 getKeyOwner 는 원래부터 안전했다. 그런데 HTTP 서명 인박스 경로는 getKeyOwner 를 안 쓴다. doesActorOwnKey 를 쓴다. 같은 "이 키가 이 액터 거냐" 를 묻는 두 함수가 있고, 하나는 확인하고 하나는 믿었다. 인박스가 부르는 쪽이 믿는 쪽이었다.

owner 를 안 밝힌 키는 2.3.6 도 막았다

그러면 공격에 꼭 필요한 게 뭔지 확인하려고, owner 를 아예 안 적은 공격자 키로 같은 사칭을 시도해봤다. 2.3.6 도 이건 거부했다. 위 코드에서 key.ownerId 가 없으면 alice 의 액터 문서를 fetch 해서 그 publicKey 목록을 확인하는 쪽으로 빠지는데, 거기엔 공격자 키가 없으니까.

그러니까 구멍은 owner 자기주장 그 자체였다. 키가 입을 다물면 Fedify 는 액터한테 물어보러 갔고, 키가 "나는 alice 거야" 라고 먼저 말하면 그 말을 믿었다. 정상 서명은 어땠나. alice 가 자기 키로 자기 Create 를 서명한 경우는 두 버전 다 수락했다. 수정이 정상 트래픽을 깨지는 않았다.

세 시나리오(공격, owner 없는 키, 정상)를 2.3.6 과 2.3.8 에서 수락/거부로 비교한 표. 공격 행만 2.3.6 수락에서 2.3.8 거부로 바뀌고 나머지는 동일 손그림

세 경로가 한 함수로 모인다

advisory 는 세 인증 경로가 다 뚫렸다고 했다. HTTP 서명, Linked Data Signature, Object Integrity Proof. 뒤 둘은 HTTP 서명조차 필요 없다. 셋을 따로 고쳤나 싶어 소스를 봤는데, LD Signature 와 Object Integrity Proof 를 검증하는 verifyObject 와 verifyProof 는 2.3.6 과 2.3.8 이 바이트까지 같았다. 고친 자리가 거기가 아니었다.

세 경로 모두 키를 받아올 때 fetchKey 를 거친다. verifyProof 는 fetchKey(verificationMethodId, Multikey, ...) 로 검증 키를 가져온다. 2.3.6 의 fetchKey 는 키를 파싱해 publicKey 필드가 있는지만 보고 돌려줬다. 그 파일 전체에 verifyKeyOwnership 이라는 이름은 한 번도 안 나온다. owner 자기주장이 그대로 살아서 나갔고, 그 뒤 각 경로가 그걸 신원으로 썼다. 2.3.8 은 fetchKey 안에서 owner(Multikey 는 controller)가 없으면 키를 담은 액터 문서로 귀속시키고, owner 를 밝혔으면 그 문서가 되가리키는지 확인해서 안 되면 키 자체를 못 쓰게 만든다. 세 경로가 한 병목을 지나니까 그 병목 하나를 조인 것이다.

그래서 내 서버는 당했나

먼저 노출부터. 인박스는 연합을 켠 7월 27일부터 2.3.8 로 올린 9월 28일까지 두 달을 취약한 버전으로 돌았다. 공개 인박스라 그동안 누구든 위조 활동을 넣을 수 있었다. 버그가 공개된 건 그 끝의 며칠(9월 21일 수정 릴리스, 27일 advisory)이지만, 그 전에 누가 독립적으로 찾았다면 그것도 못 막았다. 노출은 두 달이 맞다.

그럼 실제로 뭐가 들어왔나. 직접 로그로는 답이 안 나온다. 인박스는 활동을 저장할 때 서명에 쓰인 keyId 를 안 남기고, 저장하는 건 작성자 URI 와 핸들, 노트 URI 다. 그런데 그 세 개로도 하나는 가릴 수 있다. 위조 답글은 작성자를 피해자로 대지만, 노트 자체는 피해자 서버가 아니라 공격자 서버에 있다. 작성자 호스트와 노트 호스트가 어긋난다는 뜻이다. 그 기준으로 저장된 답글을 훑었다.

두 달간 인박스가 받은 답글은 한 건이었다. 7월 30일, 릴레이 글을 올린 날 내 마스토돈 계정(@[email protected])에서 온 것이고, 작성자와 노트가 같은 호스트다. 어긋남이 없다. 위조 신호는 없었다. 렌더되는 표면인 답글에는 사칭이 저장된 게 없다는 뜻이다. 팔로워 넷도 전부 정상 호스트의 정상 핸들이었다.

그래도 확정은 못 한다. 남는 건 저장되는 것뿐이라, 흔적을 안 남기는 활동이나 처리한 뒤 되돌려진 것은 안 보인다. 팔로워 넷이 정상으로 보여도 그 서명을 소급 검증할 수는 없고, prod 엔 Fedify 로그가 없어 거부·수락된 시도 자체의 기록도 없다. 그래서 "저장된 사칭 답글은 없다" 까지가 데이터로 말할 수 있는 전부다. 당할 수 있었고 두 달간 노출됐지만, 남은 자국에는 당한 흔적이 없다 — 딱 거기까지다.

조치는 했다. 9월 28일에 2.3.8 로 올렸고, 커스텀 키 캐시를 안 써서 내장 캐시가 검증 안 된 옛 owner 를 자동으로 버린다. 남은 건 로그다. 다음에 이런 게 또 오면 렌더 표면 말고 시도 자체를 데이터로 답하고 싶은데, 그러려면 서명 keyId 를 남겨야 한다. 안 남기는 게 프라이버시상 낫다고 봤던 결정이라, 무엇을 남길지부터 다시 정해야 한다.

관련 글

로딩 중...

이 글은 페디버스에 연합됩니다. 마스토돈 등에서 https://zerry.co.kr/blog/fedify-inbox-trusted-key-self-declared-owner 를 검색하면 이 글에 답글을 달 수 있어요. 답글은 다음 갱신 주기(최대 1시간)에 반영됩니다.