
권한이 낮은 Docmost 사용자가 피해자의 attachmentId를 일반 업로드 엔드포인트에 제공하여 동일한 작업 공간 내에서 다른 페이지의 저장된 첨부 파일을 덮어쓸 수 있습니다.
Docmost의 권한이 낮은 사용자가 피해자의 attachmentId를 일반 업로드 엔드포인트에 제공하고 동일한 워크스페이스 내 다른 페이지의 저장된 첨부 파일을 덮어쓸 수 있습니다.
저는 Docmost(오픈소스 협업 문서화 플랫폼)에서 High 심각도의 인가 결함을 식별하고, 책임감 있게 공개했으며, 재현했습니다.
Docmost의 공식 사이트는 이 플랫폼을 300만 회 이상 다운로드된 엔터프라이즈급 온프레미스 위키로 소개하며, Vilnius City, Bechtle, 호주 정부, 적십자, ETS Quebec 등 조직의 팀들이 신뢰한다고 밝히고 있습니다.
버그는 Docmost가 다이어그램 저장/업데이트 흐름에도 사용하는 일반 파일 업로드 경로에 존재했습니다.
저는 매우 구체적인 질문을 가지고 그 코드를 검토하고 있었습니다.
업로드 엔드포인트가 한 페이지에 대한 편집 접근을 증명하지만, 덮어쓰기 대상이 사용자가 제어하는 별도의 첨부 ID로 선택되면 어떻게 될까요?
이 경우, 그 질문은 실제 객체 바인딩 실패로 바로 이어졌습니다.
Docmost는 호출자가 다음을 보낼 수 있도록 허용했습니다.
pageIdattachmentId서버는 덮어쓰기 일관성 검사를 수행했지만, 가드가 잘못된 부울 로직을 사용했습니다.
즉, 요청이 인가를 통과하고 피해자 첨부 파일을 어쨌든 덮어쓸 수 있었습니다.
이 이슈는 CVE-2026-34213이 되었습니다.
Docmost: docmost/docmost
Advisory: GHSA-89fp-2hch-j9gp
CVE: CVE-2026-34213
패치 버전: v0.71.0
---
공격자가 제어하는 편집 접근 권한이 있는 pageId -> 공격자가 제어하는 피해자 attachmentId -> 결함이 있는 덮어쓰기 가드가 페이지 간 덮어쓰기를 유효한 것으로 처리 -> 피해자 attachmentId로부터 스토리지 경로 재구성 -> 공격자 바이트가 피해자 파일을 대체 -> 피해자 페이지가 계속 수정된 첨부 파일을 제공
Docmost는 업로드된 페이지 첨부 파일을 데이터베이스 레코드와 스토리지의 백업 파일로 저장합니다.
일반 업로드의 경우 서버는 새 첨부 ID를 생성하고 새 파일을 씁니다.
그러나 다이어그램 저장/업데이트 흐름의 경우 클라이언트는 의도적으로 기존 attachmentId를 재사용하여 매번 새 첨부 레코드를 생성하는 대신 동일한 다이어그램 파일을 제자리에서 업데이트할 수 있도록 합니다.
그 동작 자체는 합법적입니다.
문제는 이것이 고위험 경로를 생성한다는 점입니다.
엔드포인트가 이 두 가지 책임을 혼합할 때마다 구현은 이들을 정확하게 바인딩해야 합니다.
Docmost는 그렇게 하지 못했습니다.
생성/업데이트 혼합 엔드포인트는 인가 버그가 흔한 곳입니다.
그 이유는 간단합니다.
이것이 바로 여기의 패턴입니다.
POST /api/files/upload는 호출자가 pageId가 지정한 페이지를 편집할 수 있는지 검증했습니다.
그러나 attachmentId도 제공되면 서버는 덮어쓰기 경로로 전환하고 별도로 기존 첨부 레코드를 선택했습니다.
이로 인해 중요한 보안 질문이 생겼습니다.
덮어쓰기 경로가 선택된 첨부 파일이 실제로 인가된 페이지에 속한다는 것을 증명합니까?
취약한 버전에서의 대답은 '아니오'였습니다.
근본 원인은 사용자 제어 키를 통한 인가 우회와 덮어쓰기 가드의 부울 로직 버그가 결합된 것이었습니다.
취약한 흐름은 다음과 같습니다.
AttachmentController.uploadFile()이 멀티파트 폼 데이터에서 pageId를 읽었습니다.validateCanEdit(page, user)를 호출했습니다.attachmentId를 별도로 허용했습니다.AttachmentService.uploadFile()이 공격자가 제공한 ID로 기존 첨부 파일을 로드했습니다.&&를 사용했지만, 불일치가 있으면 거부해야 했습니다.취약한 가드는 다음과 같았습니다.
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
해당 조건은 다음과 같은 경우에만 요청을 거부했습니다.
모두 동시에.
이는 덮어쓰기 가드가 해야 할 일과 반대입니다.
실제 공격 상황에서 공격자는 의도적으로 동일한 워크스페이스 내에 머물렀습니다.
따라서:
existingAttachment.workspaceId !== workspaceId는 false였습니다.해당 피연산자가 false가 되자, 첨부 파일이 다른 페이지에 속하더라도 전체 && 조건이 false로 평가되었습니다.
따라서 서버는 페이지 간 덮어쓰기를 유효한 것으로 처리했습니다.
그것이 버그의 전반부였습니다.
후반부는 실제 영향을 실현한 부분입니다.
검사 후 서비스는 공격자가 제공한 attachmentId와 파일 이름을 사용하여 대상 스토리지 경로를 재구성했습니다.
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
그런 다음 업데이트 경로에서 Docmost는 다음과 같은 변경 가능한 메타데이터만 업데이트했습니다.
fileSizeupdatedAt소유권을 공격자 페이지로 다시 바인딩하지 않았습니다.
따라서 피해자 페이지는 계속 동일한 첨부 레코드와 동일한 첨부 ID를 가리켰습니다. 기본 파일 바이트만 변경되었습니다.
이것이 단순한 불일치가 아닌 이유입니다.
지속적인 무단 덮어쓰기 원시(primitive)였습니다.
이것은 미용상의 버그도 아니고 파일 이름 충돌 문제도 아닙니다.
공격자에게는 경쟁 조건이 필요하지 않았습니다. 공격자가 무작위 경로를 추측할 필요도 없었습니다. 공격자가 피해자 페이지에 쓰기 접근 권한이 필요하지도 않았습니다.
필요한 것은 다음과 같습니다.
그로부터 공격자는 피해자 페이지가 계속 해당 첨부 파일을 참조하고 아무 일도 없었던 것처럼 제공하는 동안 다른 페이지의 첨부 파일에 대한 저장된 파일 바이트를 대체할 수 있었습니다.
이는 직접적인 무결성 실패입니다.
실질적으로 공격자는 다음을 수행할 수 있었습니다.
중요한 점은 이것입니다.
서버는 공격자가 선택한 덮어쓰기 대상을 허용했으며, 실제로 확인된 페이지에 바인딩하지 않았습니다.
이는 나쁜 부울 위생이 아니라 접근 제어 실패입니다.
익스플로잇은 특히 다이어그램 첨부 파일에 대해 실용적이었습니다.
Docmost의 클라이언트는 다이어그램 저장을 위해 의도적으로 attachmentId를 재사용하고 결정적 파일 이름을 사용합니다.
diagram.excalidraw.svgdiagram.drawio.svg이는 공격자의 요구 사항을 낮추기 때문에 중요합니다.
일반 첨부 파일의 경우 공격자는 다음이 모두 필요합니다.
다이어그램의 경우 파일 이름은 이미 예측 가능합니다.
따라서 공격자가 피해자 페이지 내용을 읽을 수 있다면, 종종 필요한 유일한 누락 조각을 복구할 수 있습니다.
attachmentId제 검증 설정에서 저는 정확히 그 경로를 사용했습니다.
그것으로 충분했습니다.
익스플로잇은 동일한 워크스페이스 내에서 페이지 경계와 공간 경계를 넘어, 결함이 있는 워크스페이스 검사를 여전히 만족시키면서 실행되었습니다.
저는 docmost/docmost:0.70.3, Postgres, Redis로 구축된 일회용 랩을 사용하여 Docmost v0.70.3 에 대해 이슈를 라이브로 검증했습니다.
PoC 흐름은 다음과 같습니다.
attachmentId를 기록합니다.POST /api/files/upload를 다음 항목과 함께 보냅니다.
pageId = 공격자 페이지 IDattachmentId = 피해자 첨부 IDfile = 피해자 파일 이름을 사용하는 공격자 제어 대체 파일최소 요청 형태는 다음과 같습니다.
POST /api/files/upload
Content-Type: multipart/form-data
pageId=<attackerPageId>
attachmentId=<victimAttachmentId>
[email protected];filename=diagram.excalidraw.svg
관찰된 라이브 결과는 다음과 같습니다.
019d18ae-b176-751c-8525-b5f3cede131d019d18ae-b15b-70e9-ac67-64948e87cc5e019d18ae-b12f-75ec-8c1c-5aff3ba6be9c200 OK686a0a0ede90ece1cbb975bb29304a6c3a90373a9c3ab2496345cf7ca59cc8fa
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
Attacker replacement from another page
이는 이론적인 소스 검토가 아닌 완전한 종단간 덮어쓰기 증명입니다.
트라이지 중 저는 두 가지 스타일의 증명을 사용했습니다.
독립형 harness는 부울 로직 실패를 격리하는 데 유용했습니다.
라이브 HTTP PoC는 더 강력한 증거였습니다. 전체 보안 스토리를 증명했기 때문입니다.
attachmentId가 허용됨이 구분은 접근 제어 버그에서 중요합니다.
"조건이 잘못되었다"는 것만으로는 충분하지 않습니다.
"조건이 잘못되었고, 애플리케이션이 종단간 지속적인 무단 덮어쓰기로 구동될 수 있다"는 것이 완전한 사례입니다.
수정은 v0.71.0에서 제공되었으며, 덮어쓰기 가드를 &&에서 ||로 변경했습니다.
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
해당 패치는 보고된 버그에 대해 최소화되고 직접적이며 정확합니다.
올바른 규칙을 복원합니다.
덮어쓰기는 기존 첨부 파일이 인가된 페이지/워크스페이스/유형 가정과 정확히 일치하는 경우에만 허용됩니다.
가드가 불일치가 있으면 거부하도록 변경되면:
이는 올바른 종류의 수정이었습니다.
인가된 페이지와 덮어쓰기 대상 간의 엄격한 바인딩일 뿐입니다.
여전히 더 넓은 공학적 교훈이 있습니다.
내부 업데이트 흐름도 제공하는 일반 업로드 엔드포인트는 고위험 API 표면으로 취급되어야 합니다.
즉각적인 버그가 수정되더라도 장기적으로 더 강력한 설계는 다음과 같습니다.
그러나 취약점 자체에 대해서는 게시된 패치가 핵심 이슈를 깔끔하게 종결했습니다.