← 홈
취약점 제보

CVE-2026-57441 : mcpvault

#취약점 제보

안녕하세요 novice-22입니다. 이번에 제보한 CVE-2026-57441 에 대해 분석글을 작성했습니다.
제보한 취약점은
mcpvault 에서 발견한 접근 제어 우회 취약점입니다.
코드 분석부터 실제 재현(PoC)까지, 왜 이게 취약한지 까지 쉽게 풀어서 작성하였습니다.

📋 요약

  • 대상 : bitbonsai/mcpvault (영향 버전 0.11.3 이하) · 전송 stdio
  • CVE : CVE-2026-57441 | 심각도 Medium | CWE-178 · CWE-41
  • 수정 · 0.11.4 에서 해결
  • 한 줄 요약 : 경로에서 한 글자만 대문자로 바꾸면 막혀 있던 .git · .obsidian 폴더가 열리고, git 훅을 심어 임의 명령까지 실행된다
  • 영향 : macOS · Windows 한정, 자격증명 유출에서 시작해, 볼트가 git 저장소면 다음 커밋 때 사용자 계정 권한으로 명령이 실행된다.

1. 이 MCP는 무엇을 하는 서버인가

MCPVault는 Obsidian 볼트를 AI 어시스턴트에 연결해주는 MCP 서버다. Obsidian은 마크다운으로 개인 노트를 관리하는 앱이고, 그 노트 폴더를 볼트라고 부른다. 이 서버를 붙이면 Claude 같은 어시스턴트가 볼트 안의 노트를 직접 읽고 쓸 수 있다.

도구는 전부 파일 작업이다. 읽기, 쓰기, 부분 수정, 삭제, 이동에 폴더 목록과 검색, 태그 관리가 더해진다. 외부 프로그램을 실행하는 도구는 없다.

눈여겨볼 것은 이 서버가 내건 보안 약속이다. README는 노트에 "안전한 읽기·쓰기 접근(safe read/write access)" 을 제공한다고 소개하고, 이를 PathFilter로 구현한다. .obsidian(앱 설정), .git(저장소 메타데이터) 같은 폴더를 차단 목록에 올려두고, 경로를 받는 모든 도구가 이 검사를 거쳐 막히면 Access denied: ... This path is restricted를 돌려준다.

즉 이 서버는 "볼트 안이라도 민감한 폴더는 열어주지 않는다" 는 가정 위에 서 있다.

2. 취약점 발견

PathFilter는 두 겹으로 막는다. .obsidian, .git 같은 폴더를 걸러내는 차단 목록, 그리고 노트가 아닌 파일 형식을 걸러내는 확장자 허용 목록이다.

두 검사가 문자열을 비교하는 방식이 서로 다르다.

const regex = new RegExp(regexPattern);                    // ① 차단 목록
normalizedPath.toLowerCase().endsWith(ext.toLowerCase())   // ② 확장자

②는 양쪽을 소문자로 맞춰 .MD.md를 같은 것으로 본다. ①에는 그 처리가 없다. 정규식은 기본이 대소문자 구분이라, .git 은 막지만 .Git 은 다른 글자로 읽는다.

그런데 macOS와 Windows의 파일시스템은 대소문자를 구분하지 않는다. 검사한 것은 "글자"인데, 그 값이 실제로 쓰이는 곳은 "파일 이름"이다. 이 어긋남이 이번 취약점의 출발점이다.

💡 개념 - CWE-178이란?

"자원에 접근하거나 그 속성을 판단할 때 대소문자 차이를 제대로 반영하지 못해 결과가 일관되지 않게 되는 것."

검사하는 쪽은 대소문자를 구분하고 파일시스템은 구분하지 않는다. 리눅스는 구분하므로 이 벡터가 통하지 않는다.

💡 개념 - CWE-41이란?

"경로 동등성(path equivalence)을 통한 파일시스템 내용 노출." 파일과 디렉터리 이름에 특수문자를 써서 같은 대상에 여러 이름을 만들어내는 조작을 말한다.

다른 대상의 이름을 만드는 순회와 달리 접근 제어 우회가 목적이다. 하위 항목이 CWE-42 'filename.' 과 CWE-46 'filename ' 이다.

3. 취약점 분석

도구가 경로를 받으면 PathFilter로 들어간다. 그 안에서 거치는 경로는 아래와 같다.

isAllowed(path)                파일을 다루기 전에 경로를 검사하는 함수이다
  │  역슬래시를 슬래시로 정규화
  ├─ isIgnoredPath(normalized) ① 차단 목록을 확인하는 함수이다
  │    └─ simpleGlobMatch()       여기서 실제 비교를 하게 된다
  └─ isFile(normalized)        ② ①을 통과했을 때만 도달한다
       └─ 확장자 비교              파일로 판정됐을 때만 한다

isAllowedForListing(path)      목록과 이동 전에 경로를 검사하는 함수이다
  │  역슬래시를 슬래시로 정규화
  └─ isIgnoredPath(normalized) ① 만 검사한다. 확장자는 보지 않는다

두 진입점 모두 맨 앞에서 경로를 정규화한다. path.replace(/\\/g, '/') 로 Windows 역슬래시를 슬래시로 바꾸는 것이다.

이제 PathFilter가 어떻게 작동하는지를 설명할 것이다. 먼저 검사에 쓸 차단 목록과 확장자 허용 목록은 생성자에서 만들어진다. 아래의 코드가 그 둘이다.

// src/pathfilter.ts
constructor(config?: Partial<PathFilterConfig>) {
  this.ignoredPatterns = [
    '.obsidian', '.obsidian/**',
    '.git', '.git/**',
    'node_modules', 'node_modules/**',
    '.DS_Store', 'Thumbs.db',
    ...config?.ignoredPatterns || []
  ];

  this.allowedExtensions = [
    '.md', '.markdown', '.txt',
    '.base',    // Obsidian Bases (YAML)
    '.canvas',  // Obsidian Canvas (JSON)
    ...config?.allowedExtensions || []
  ];
}

이 목록을 쓰는 진입점은 두 개다. 먼저 isAllowed부터 본다.

// src/pathfilter.ts
isAllowed(path: string): boolean {
  // Normalize path separators
  const normalizedPath = path.replace(/\\/g, '/');

  if (this.isIgnoredPath(normalizedPath)) {          // ① 차단 목록
    return false;
  }

  // For files, check extension if allowedExtensions is configured
  if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
    const hasAllowedExtension = this.allowedExtensions.some(ext =>
      normalizedPath.toLowerCase().endsWith(ext.toLowerCase())   // ② 확장자
    );
    if (!hasAllowedExtension) {
      return false;
    }
  }

  return true;
}

순서가 중요하다. 먼저 ①에서 차단 목록에 걸리는지 검사한다. 그리고 통과해야 ② 확장자 검사로 간다.

아래의 코드는 isIgnoredPath 의 코드이다.

// src/pathfilter.ts:77
private isIgnoredPath(normalizedPath: string): boolean {

  // Check if path matches any ignored pattern
  for (const pattern of this.ignoredPatterns) {
    if (this.simpleGlobMatch(pattern, normalizedPath)) {
      return true;
    }
  }

  return false;
}

이 함수는 직접 비교하지 않는다. 생성자에서 만든 차단 목록을 하나씩 꺼내 simpleGlobMatch에 넘기고, 하나라도 걸리면 즉시 차단한다. 끝까지 안 걸리면 통과다.

isAllowedisAllowedForListing이 둘 다 isIgnoredPath 함수를 부르므로, 차단 목록 검사는 여기 한 곳으로 모인다. 읽기든 쓰기든 목록이든 결국 이 길을 지난다.

나머지 진입점인 isAllowedForListing은 네 줄이 전부다. 주석이 존재 이유를 그대로 적어두었다.

// src/pathfilter.ts:69
isAllowedForListing(path: string): boolean {
  // Normalize path separators
  const normalizedPath = path.replace(/\\/g, '/');

  // Listing includes non-note files, but still blocks restricted system paths
  return !this.isIgnoredPath(normalizedPath);
}

폴더 목록에는 노트가 아닌 파일도 보여야 하므로 확장자 검사를 걸지 않는다. 다만 차단 목록은 그대로 적용한다.

그리고 실제로 비교하는 곳이 simpleGlobMatch다.

// src/pathfilter.ts
private simpleGlobMatch(pattern: string, path: string): boolean {
  const normalizedPattern = pattern.replace(/\\/g, '/');

  let regexPattern = normalizedPattern
    .replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')  // Escape all regex special chars
    .replace(/\\\*\\\*/g, '.*')              // ** matches any number of directories
    .replace(/\\\*/g, '[^/]*')               // * matches anything except /
    .replace(/\\\?/g, '[^/]');               // ? matches single character except /

  regexPattern = '^' + regexPattern + '$';

  const regex = new RegExp(regexPattern);
  return regex.test(path);
}

패턴을 정규식으로 바꿔서 검사한다. 차단 목록에 .git.git/**이 나란히 올라가 있는 이유도 여기서 드러난다. 앞의 것은 ^\.git$이 되어 폴더 이름 그 자체만 보고, 그 안의 내용은 다음 줄에서 검사한다. .git/**이 어떻게 변하는지 따라가 보면 이렇다.

단계결과
원본 패턴.git/**
정규식 특수문자 이스케이프\.git/\*\*
\*\*.* 로 복원\.git/.*
앞뒤 고정^\.git/.*$

마지막 줄에서 이 정규식이 new RegExp() 으로 만들어져 regex.test(path) 로 경로와 맞춰진다.

여기까지가 차단 목록 검사가 거치는 전부다. 그런데 같은 파일 안의 두 검사가 비교하는 방식은 서로 다르다.

② 확장자 쪽은 .MD.md든 같은 것으로 본다. 코드는 아래와 같다.

normalizedPath.toLowerCase().endsWith(ext.toLowerCase())

반면 차단 목록 쪽은 new RegExp()i 플래그가 없어서 대소문자를 구분한다. .git은 막지만 .Git은 다른 글자로 읽는다.

그러면 .Git/config는 차단 목록을 빠져나가도 ② 확장자 검사가 남는다. config.md.txt도 아니다. 그런데 isAllowed의 조건을 다시 보면 확장자 검사는 isFile()이 참일 때만 실행된다.

// src/pathfilter.ts:89
private isFile(path: string): boolean {
  // A path is a file if it has a file extension at the end
  // Paths ending with '/' are always directories
  if (path.endsWith('/')) {
    return false;
  }

  // Get the last component of the path
  const lastSlashIndex = path.lastIndexOf('/');
  const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);

  const lastDotIndex = lastComponent.lastIndexOf('.');
  if (lastDotIndex === -1 || lastDotIndex === 0) {
    // No dot, or dot at the start (like .gitignore) - treat as no extension
    return false;
  }

  const extension = lastComponent.substring(lastDotIndex + 1);
  return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
}

경로의 마지막 조각을 떼어내 점이 있는지 본다. .Git/config의 마지막 조각은 config이고 점이 없다. 그래서 false를 돌려주고, 확장자 검사 블록은 실행조차 되지 않는다.

확장자가 없는 파일은 확장자 검사를 아예 받지 않는다는 뜻이다. .git 안의 config, HEAD, index가 전부 여기 해당한다. 그 파일들을 지키는 것은 차단 목록 하나뿐이고, 그것이 뚫리면 남는 방어가 없다.

경로 하나가 지나온 길을 정리하면 이렇다.

.Git/config
  ① 차단 목록   대소문자가 달라 어느 패턴에도 안 걸린다   →  통과
  ② 확장자      isFile() 이 false 라 검사하지 않는다      →  통과
                                                 최종 return true

왜 이런 결함이 생겼나

  • ① 왜 취약한가 : 이름을 글자로 비교했지만, 그 판정이 적용되는 대상은 파일시스템이 해석한 이름이다. .git.Git은 비교에서는 다른 값이고 디스크에서는 같은 폴더다. 두 이름 공간이 다르다는 것을 코드가 모른다.
  • ② 왜 이렇게 짰나 : 대소문자를 맞춰야 한다는 인식 자체는 있었다. 확장자 검사는 양쪽에 toLowerCase()를 걸어둔다. 다만 그 검사는 isAllowed 본문에 있어 눈앞에 보이고, 차단 목록 비교는 두 단계 아래 simpleGlobMatch에 있다. 그 함수는 glob 문법 전반을 처리하는 범용 도구로 쓰여 있어서, 자기가 보안 판단에 쓰인다는 신호가 코드 안에 없다. isFile도 같다. 주석이 밝히듯 1. Project 같은 폴더 이름을 파일로 오인하지 않으려고 만든 분류 도구인데, 그것이 확장자 검사의 관문이 됐다.
  • ③ 신뢰경계가 어디서 깨지나 : 1번에서 본 "볼트 안이라도 민감한 폴더는 열어주지 않는다"는 약속이 무너진다. 필터는 자기가 받은 문자열에 대해서만 답하고, 파일을 여는 것은 운영체제다. 그 사이에서 이름이 한 번 더 해석된다는 것을 확인하는 코드가 없다.

by-design 반박

의도된 설계로 보기는 어렵다.

첫째, 차단 목록에 .obsidian.git이 명시적으로 올라가 있고, 막힐 때 돌려주는 메시지는 Access denied: ... This path is restricted다. 제한하려는 의도가 분명하다.

둘째, 같은 파일 안에 이미 올바른 방식이 있다. 확장자 검사는 비교 전에 양쪽을 소문자로 맞춘다.

normalizedPath.toLowerCase().endsWith(ext.toLowerCase())

대소문자를 맞춰야 비교가 성립한다는 것을 알고 있었다는 뜻이다. 이 방식을 몰라서 못 쓴 것이 아니다. 다만 두 단계 아래의 범용 함수에서만 그 처리가 빠졌을 뿐이다.

4. PoC

이제 실제로 재현해 본다. 취약 버전 0.11.0을 설치하고 서버를 stdio로 띄운 뒤, 에이전트가 보내는 것과 똑같은 MCP 도구 호출을 직접 보낸다. 환경은 Windows다. 이 결함은 대소문자를 구분하지 않는 파일시스템에서만 성립하기 때문이다.

⓪ 환경 확인

설치된 버전이 0.11.0인지, 볼트가 어디이고 그 안에 무엇이 있는지를 먼저 찍었다. 볼트에는 소문자 .git 하나뿐이고 .Git 이라는 폴더는 없다.

마지막 두 줄은 설치된 패키지에서 그대로 읽어온 것으로, 2번에서 지목한 두 비교가 배포된 코드에 실제로 그대로 있다는 확인이다.

① 가드 동작 확인

.git/config.obsidian/app.json 을 그대로 요청했다. 둘 다 Access denied 로 거부된다. 평범한 노트는 정상적으로 읽힌다.

뒤에 벌어지는 일이 "가드가 없어서"가 아니라 "가드를 통과한 뒤에" 생긴 것임을 분명히 하기 위해서이다.

② 대소문자 우회

같은 요청에서 g 한 글자만 대문자로 바꿨다. 도구는 정상 응답을 돌려주고, 그 안에 .git/config 의 내용이 그대로 들어 있다. GitHub 토큰이 박힌 원격 저장소 주소다.

디스크에 있는 폴더는 소문자 .git 하나뿐이므로, 열린 것은 ①에서 거부당한 .git/config 파일이다.

③ 읽기만이 아니다

②에서 쓴 한 글자 바꾸기는 다른 도구에도 그대로 통한다. 읽기, 목록, 쓰기가 모두 같은 PathFilter 를 거치기 때문이다.

list_directory 는 차단 폴더 안의 파일 이름을 그대로 돌려준다. write_note.git\hooks\pre-commit 에 파일을 심는다. 실행 전에는 없었고(absent) 실행 후에는 생겼다(PRESENT).

요청은 .Git 으로 보냈는데 파일은 소문자 .git 안에 떨어졌다. 같은 폴더라는 뜻이다.

git은 커밋할 때 이 파일을 실행한다. 훅은 셸 스크립트이므로 그 시점부터는 볼트 경계가 적용되지 않고, 거기 적은 명령이 사용자 계정 권한으로 그대로 실행된다. 실제로 심어둔 훅에서 홈 디렉터리의 SSH 개인키 목록을 읽어낼 수 있었다.

④ 통하지 않는 것

Windows는 파일 이름 끝의 점과 공백을 무시한다. 그래서 .git./config 도 같은 폴더를 가리킬 것처럼 보이지만 실제로는 열리지 않는다. 돌아오는 것은 거부 메시지가 아니라 "파일 없음"이다. 필터는 통과했다는 뜻이다.

같은 경로를 cmd.exe 로 열면 열리고 Node로 열면 실패한다. Node가 경로를 Windows 정규화를 건너뛰는 형태로 넘기기 때문이다. ②에서 본 대소문자 우회는 이와 무관하게 그대로 성립한다.

정리하면 이렇다. 소문자로 적은 경로는 막힌다. 그런데 한 글자를 대문자로 바꾸면 같은 파일이 읽히고, 목록이 보이고, 쓰기까지 된다.

5. 영향

공격 경로

이 서버는 stdio 전용이라 네트워크에 열려 있지 않다. 공격자가 도구를 직접 부를 방법은 없고 에이전트를 거쳐야 한다. 에이전트는 자율적으로 도구를 부르므로, 공격자가 노릴 자리는 서버가 아니라 에이전트가 읽는 콘텐츠다. 조작된 경로를 그 안에 심어두고 도구 인자로 넘어가도록 유도한다. 간접 프롬프트 인젝션이다.

Obsidian 볼트는 여러 기기에서 동기화되고 웹 페이지를 그대로 담아두는 경우가 많다. 공유받은 노트나 저장해둔 클리핑이 그 자리가 된다.

성립 조건이 하나 더 있다. 파일시스템이 대소문자를 구분하지 않아야 한다. macOS와 Windows가 여기 해당하고 리눅스에서는 통하지 않는다. Obsidian 데스크톱 사용자 대부분이 앞의 둘이다.

무엇을 할 수 있나

세 가지가 한 번에 열린다.

  • 읽기 · .git/config 에 박힌 원격 저장소 토큰, .git/HEAD, .obsidian 안의 앱 설정
  • 목록 · 차단 폴더 안에 무엇이 있는지 그대로 열람
  • 쓰기 · .git/hooks/pre-commit 에 파일을 심기

마지막이 성격을 바꿔 놓는다. 훅은 셸 스크립트라, 사용자가 다음에 커밋하는 순간 훅에 적힌 명령이 실행된다. 그 시점부터는 볼트 경계가 없다. 4번 PoC에서 심어둔 훅으로 홈 디렉터리의 SSH 개인키 목록을 읽어냈다.

다만 권한이 올라가지는 않는다. 얻는 것은 서버를 띄운 계정의 권한 그대로다. 그러나 그 계정 아래에는 SSH 키, 클라우드 자격증명, 다른 저장소의 토큰이 함께 있다. 이 결함이 권한을 올려주지는 않아도, 거기서 집어간 자격증명이 다른 곳으로 넘어가는 발판이 된다.

쓰기를 통한 명령 실행에는 조건이 붙는다. 볼트가 git 저장소여야 하고, 사용자가 그 뒤에 커밋해야 한다. 노트를 버전 관리하는 사용자에게만 해당한다. 읽기와 목록은 그런 조건 없이 성립한다.

심각도

공식 권고문(GHSA-j99q-93c9-h869)은 Medium 으로 분류했고 CVSS 벡터는 부여하지 않았다. 제보 시점에는 훅 심기를 "가능해 보인다"까지만 적었기 때문에 보수적으로 제안했던 것이다.

이번에 실행까지 확인했으므로 여기서는 이렇게 평가한다. 7.8 (High) 이다.

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
항목이유
AVL (로컬)stdio 전용이라 네트워크로 닿지 않는다
ACL (낮음)경로 한 글자를 대문자로 바꾸면 된다
PRN (권한 불필요)도구 호출 자체에 별도 인증이 없다
UIR (사용자 개입)에이전트가 조작된 인자로 도구를 부르도록 유도돼야 한다
SU (범위 불변)권한 경계를 넘지 않는다
C / I / AH읽기는 조건 없이 성립한다. 쓰기를 통한 명령 실행은 볼트가 git 저장소이고 사용자가 커밋할 때 성립하며, 그때는 계정 권한 전체가 넘어간다

6. 패치 비교 분석

이 결함은 커밋 b9ea91a1(2026-06-17)에서 수정돼 0.11.4로 배포됐다. 무엇이 달라졌고, 그 수정으로 정말 막히는지 직접 확인해 본다.

무엇이 달라졌나

-    const regex = new RegExp(regexPattern);
+    // Case-insensitive: on case-insensitive filesystems (macOS, Windows) the OS
+    // resolves ".Git" to ".git", so the deny-list must match regardless of case.
+    const regex = new RegExp(regexPattern, 'i');

정규식 생성자는 두 번째 인자로 플래그를 받는다. 거기에 i 를 주면 대소문자를 무시하고 비교한다. 이 한 글자로 ^\.git/.*$.Git/config 도, .GIT/config 도, 어떤 조합이든 잡게 된다. 2번에서 지목한 바로 그 자리다.

남는 것은 후행 점과 공백이다. 이쪽은 플래그로 해결되지 않아서, 비교하기 전에 경로를 다듬는 함수가 새로 생겼다.

private canonicalizeForMatch(normalizedPath: string): string {
  return normalizedPath
    .split('/')
    .filter(seg => seg !== '' && seg !== '.')
    .map(seg => seg.replace(/[. ]+$/, ''))   // 조각 끝의 점과 공백 제거
    .join('/');
}

경로를 슬래시로 쪼개서 조각마다 손을 본다. 빈 조각과 . 을 버려 //./ 같은 표기를 없애고, 각 조각 끝의 점과 공백을 떼어낸 다음 다시 붙인다.

그러면 .git./config.git/config 가 되고 .git /config 도 마찬가지다. Windows 파일시스템이 실제로 하는 일을 코드가 먼저 해두는 것이다.

그리고 isIgnoredPath 는 이제 원본과 이 정규화본을 둘 다 검사한다.

-      if (this.simpleGlobMatch(pattern, normalizedPath)) {
+      if (
+        this.simpleGlobMatch(pattern, normalizedPath) ||
+        this.simpleGlobMatch(pattern, canonicalPath)
+      ) {

|| 로 묶여 있어서 둘 중 하나만 걸려도 차단한다. 정규화본 하나만 보지 않는 이유는, 다듬는 과정에서 이름이 바뀌기 때문이다. 원래 형태로 걸리던 것을 놓치지 않도록 양쪽을 모두 확인한다.

차단 목록도, 확장자 목록도, isFile 도 그대로다. 바뀐 것은 비교하기 전에 이름을 어떤 형태로 맞추느냐 하나다.

이튿날 별도 커밋(8795716b)에서 차단 이름을 경로 깊이와 무관하게 막는 검사가 더 붙었다. 중첩된 .git 이 걸러지지 않던 다른 문제를 고친 것으로, 이번 결함과는 별개다.

왜 이게 근본 해결인가

패치에 그 이유가 주석으로 함께 들어갔다.

On Windows the filesystem strips trailing dots and spaces from each path segment, so ".git." and ".git " both resolve to ".git". Fold those away (and collapse "./" / duplicate separators) before matching the deny-list, otherwise the restriction can be bypassed with an equivalent name.

이 결함의 뿌리는 이름 공간이 둘이라는 데 있었다. 필터가 비교하는 이름과 파일시스템이 실제로 여는 이름이 달랐다. 이 수정은 비교 전에 파일시스템이 해석하는 형태로 먼저 맞춘다. 두 이름 공간을 하나로 합치는 쪽이다.

목록에 변형을 하나씩 추가하는 방식이었다면 빠뜨린 표기 하나로 다시 뚫린다. .Git 을 막아도 .GIT 이 남고, 그것을 막아도 .gIt 이 남는다. 정규식 i 플래그는 그 경우의 수를 통째로 없앤다.

원본과 정규화본을 둘 다 검사하는 것에도 의도가 있다. 정규화 때문에 원래 막히던 이름이 새어 나가는 일을 막는다.

우회가 가능한가

4번에서 통했던 것을 최신 0.12.5에 그대로 넣어 봤다.

호출0.11.00.12.5
read_note(".Git/config")토큰 유출Access denied
read_note(".GIT/config")토큰 유출Access denied
read_note(".gIt/config")토큰 유출Access denied
read_note(".Obsidian/app.json")필터 통과Access denied
list_directory(".Git")내부 열람빈 목록
write_note(".Git/hooks/pre-commit")훅 심김Access denied

전부 Access denied 로 돌아온다. 대소문자를 어떻게 섞든 i 플래그가 똑같이 잡기 때문이다.

회귀 확인에서 그치지 않고 새 코드를 상대로도 시도해 봤다.

  • 대문자 조합을 바꿔가며 (.Git, .GIT, .gIt) → 전부 차단. 표기를 늘리는 방향으로는 더 나올 것이 없다
  • 후행 점·공백을 대소문자와 섞어서 (.Git./config, .Git /config) → 전부 차단. canonicalizeForMatch 가 세그먼트 끝을 먼저 떼어내므로 정규화본에서 걸린다

부작용은 없나

막는 데만 집중해서 기능이 죽는 경우가 있는데, 이 패치는 그렇지 않다.

  • 소문자로 적은 .git/config 도 여전히 Access denied 로 거부된다
  • 평범한 노트는 그대로 읽힌다 (note.md )

7. 교훈

이 취약점으로 CWE-178과 CWE-41을 알게 됐고, Windows와 macOS의 파일시스템이 대소문자를 구분하지 않는다는 것도 알게 됐다.

그래서 경로를 검사할 때는 실제로 파일을 여는 쪽과 기준을 맞춰야 한다. 정규식으로 검사한다면 i 플래그를 빠뜨리지 말아야 하고, 그러지 않으면 파일시스템은 대소문자를 무시하는데 검사만 구분해서 그 틈으로 뚫린다.