본문 바로가기
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

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

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