C.W.K.
Stream
Lesson 04 of 05 · published

양쪽이 검증한다

~15 min · identity-handoff, defense-in-depth, path-traversal, validation

Level 0릴 입문자
0 XP0/39 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"양쪽이 검증해. Recall 이 allowlist 된 DB 경로를 해석하고; Ashen Reel 이 열기 전에 돌려받은 canonical 파일과 루트를 독립적으로 검사해."

검사 둘, 하나가 아니라

딥링크가 도착하면 Ashen Reel 은 아직 아무것도 안 열어. 설정된 loopback Recall 서비스 — 들어온 링크가 못 덮어쓰는 고정 로컬 base URL — 에 질의하고, Recall 한테 video_id 를 진짜 경로로 바꿔달라고 해. Recall 이 자기 일을 해: id 를 데이터베이스에서 찾고 이미 allowlist 한 경로를 돌려줘. 거기서 이야기가 끝날 수도 있어. 안 끝나.

그다음 Ashen Reel 이 그 경로를 자기 경계 쪽에서 다시 검증해. 돌려받은 파일을 canonicalize 하고, 진짜 regular file 인지 확인하고, 모든 symlink 를 해석한 뒤에도 설정된 Recall 미디어 루트 안에 사는지 검사해. 그때서야 열어. 두 독립 당사자가 각각 검사해, 어느 하나만으론 단일 실패점이니까.

신뢰는 검증이 아냐. 네가 넘는 모든 경계의 네 쪽에서 검증해. '다른 서비스가 이미 검사했어' 는 한 컴포넌트의 버그 하나가 시스템 전체의 뚫림이 되는 방식이야. 심층 방어는 각 층이 다른 층이 틀렸을 수 있다고 가정하는 거야.

symlink 탈출

'이 경로가 내 폴더 안이야?' 를 순진하게 하면 놓치는 미묘한 공격이 여기 있어. 허용된 루트가 민감한 어딘가로 나가는 symlink 를 담고 있다고 해봐. literal 경로에 대한 문자열-접두사 검사는 "응, 허용 루트로 시작해" 라며 통과시켜 — 링크를 따라가면 밖에 떨어지는데도. 고침은 연산 순서야: symlink 를 먼저 해석하고, 그다음 containment 를 검사. 경로가 표면적으로 뭐라 주장하는지가 아니라 실제로 어디로 이어지는지를 검증해.

비교하기 전에 canonicalize 해. 원시 문자열에 대해 동작하는 경로 검사는 거의 항상 우회 가능해 — .. 세그먼트, symlink, 인코딩 트릭으로. 경로를 진짜의, 절대의, symlink 없는 형태로 먼저 해석하고; 그걸 비교해. 비교는 그 앞의 canonicalization 만큼만 믿을 만해.

독립 재검사는 짧고, 두 단계의 순서가 보안 전체야:

왜 앱이 base URL 을 소유하나

조용한 방어 하나 더: Ashen Reel 이 말하는 loopback Recall 주소는 자기 설정 값이야, 들어온 링크의 필드가 절대 아니라. URL 이 "이 id 를 http://evil.example/api 에 대고 해석해" 라고 말할 수 있으면, 공격자가 자기를 제약하려던 바로 그 조회의 답을 공급하는 거야. 그래서 base URL 은 협상 불가야 — 링크는 영상을 이름 붙이고, 어느 신뢰된 서비스가 그걸 해석할지는 앱 혼자 결정해. 공격자가 자기를 끼워넣었을 모든 자리를, 설계가 테이블에서 치웠어.

Code

독립 재검사 — symlink 를 '먼저' 해석하고, 그다음 containment 검사·swift
/// Recall already resolved video_id -> path from its allowlisted DB.
/// Ashen Reel STILL checks, because trust is not verification.
func validateResolved(_ path: String, roots: [URL]) -> Bool {
    // Canonicalize: resolve symlinks and '..' to the REAL absolute path.
    let url = URL(fileURLWithPath: path)
        .resolvingSymlinksInPath()
        .standardizedFileURL

    // 1. Must be a real, regular file -- not a directory, socket, or device.
    let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]))?
        .isRegularFile == true
    guard isFile else { return false }

    // 2. After symlinks are resolved, it must STILL be inside an allowed root.
    let real = url.path
    return roots.contains { root in
        let base = root.resolvingSymlinksInPath().standardizedFileURL.path
        return real == base || real.hasPrefix(base + "/")
    }
}
// The order matters: a prefix check BEFORE resolving symlinks is bypassable;
// a prefix check AFTER resolving them checks where the path really leads.

External links

Exercise

네 시스템에서 컴포넌트 A 가 뭘 해석하거나 검증해서 그 결과를 컴포넌트 B 한테 넘기는 경계를 찾아. B 가 재검사해, A 의 결과를 믿어? 이제 경로가 허용 디렉토리 '안'인지 검사하는 곳을 찾아. 검사 전에 symlink 랑 '..' 를 해석해, 원시 문자열을 비교해? 원시-문자열 containment 검사 하나를 먼저 canonicalize 하게 고쳐.
Hint
독립적인 두 신호: (1) 네 추론 어디든 '다른 쪽이 이미 검증함' 은 심층 방어 구멍이야 — 네 쪽에서 재검사해. (2) symlink/'..' 해석 전에 도는 containment 검사(hasPrefix, startsWith, 경로 문자열 비교)는 우회 가능해; canonicalize 하고, 그다음 비교.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.