npm 생태계 중심에 있는 거대한 버그
원문: https://blog.vlt.sh/blog/the-massive-hole-in-the-npm-ecosystem
역자 주: tarball은 npm에서 사용되는 압축 파일 형식입니다. tarball은 일반적으로
.tgz
확장을 가지며 패키지 파일과 폴더를 묶어 압축합니다.참고: 저는 2019년 7월부터 2022년 12월까지 npm CLI 팀의 스태프 엔지니어 관리자였습니다. 2020년 깃헙이 npm 주식회사 인수 당시 근무했고, 여러 이유로 12월 깃헙에서 떠났습니다.
tldr;
- npm 패키지의 매니페스트는 tarball과 독립적으로 배포됩니다.
- 매니페스트는 tarball의 내용에 대해 완전히 검증되지 않았습니다.
- 생태계에는 매니페스트와 tarball의 내용이 일관성 있다는 가정이 널리 퍼져있습니다.
- 공개 레지스트리를 사용하는 모든 도구 또는 인사이트는 악용될 수 있으며 부정확할 가능성이 있습니다.
- 악의적인 공격자는 탐지되지 않는 직접적 또는 전이적 의존에 멀웨어 및 스크립트를 숨길 수 있습니다.
새로운 공급망 공격이라는 측면에서 볼 때, 이는 큰 문제이며 지금부터 저는 이를 **”매니페스트 혼란”**이라고 부르겠습니다.
배경 지식
오늘날의 노드 생태계는 전 세계 수천만 명의 개발자가 310만 개 이상의 패키지를 만들고 한 달에 2,080억 번 다운로드가 되고 있습니다. 하지만 지금의 생태계가 되기 전 여러분이 믿고 사용하고 다운로드하는 소프트웨어 뭉치에 기여하는 사람은 매우 적었습니다. 커뮤니티가 작을수록 더 많은 신뢰를 얻을 수 있으며, npm 레지스트리가 개발되는 동안에도 대부분이 오픈 소스로 제공되어 자유롭게 기여하고 코드를 검사할 수 있었습니다. 그러나 시간이 지나면서 생태계가 성장함에 따라 소프트웨어 뭉치를 사용하는 조직의 정책과 관행도 변화했습니다.
처음부터 npm 프로젝트는 레지스트리의 클라이언트와 서버측 모두에 많은 신뢰를 두었습니다. 지금 돌이켜보면 데이터의 유효성 검사를 처리하는 데 클라이언트에 지나치게 의존하는 관행은 분명 문제가 많지만, 그 전략 덕분에 자바스크립트 도구 생태계가 유기적으로 성장하고 데이터의 형태로써 참여할 수 있었습니다.
무엇이 문제인가요?
공개 npm 레지스트리는 실제 패키지 tarball의 내용으로 매니페스트 정보의 유효성을 검사하지 않고, npm 호환 클라이언트를 사용해 유효성 검사 및 일관성을 해석하고 시행합니다. 실제로 제가 이 문제를 조사한 결과 서버에서 유효성 검사를 수행한 적이 없는 것으로 보입니다. (따라서 이를 “기능”이라 부를 수 있습니다.)
현재 registry.npmjs.com
에서는 사용자가 해당 패키지 URI(예: https://registry.npmjs.com/-/<package-name>
)에 PUT
요청을 보내면 패키지를 게시할 수 있습니다. 이 엔드포인트는 다음과 같은 요청 body
를 받습니다.(참고: 거의 10년 반이 지난 지금도 여전히 이 기능과 다른 모든 레지스트리 API는 끔찍하게 문서화되지 않은 상태입니다.)
{
_id: <pkg>,
name: <pkg>,
'dist-tags': { ... },
versions: {
'<version>': {
_id: '<pkg>@<version>`,
name: '<pkg>',
version: '<version>',
dist: {
integrity: '<tarball-sha512-hash>',
shasum: '<tarball-sha1-hash>',
tarball: ''
}
...
}
},
_attachments: {
0: {
content_type: 'application/octet-stream',
data: '<tarball-base64-string>',
length: '<tarball-length>'
}
}
}
현재 당면한 문제는 version
메타데이터(일명 "매니페스트" 데이터)가 패키지의 package.json
을 포함하는 첨부된 tarball과 독립적으로 제출된다는 것입니다. 이 두 가지 정보는 서로에 의해 검증되지 않으며 dependencies
, scripts
, license
등과 같은 데이터에 대해 어떤 것이 "정확한 진실의 출처"가 되어야 하는지 의문을 불러일으킵니다. 제가 알기로는 tarball이 서명 및 오프라인에서 저장 및 확인할 수 있는 무결성 값을 가진 유일한 아티팩트입니다. (따라서 tarball이 잠재적으로 올바른 출처가 될 수 있지만, 놀랍게도 package.json
의 name
및 version
필드는 검증되지 않았기 때문에 실제 매니페스트의 값과 다를 수 있습니다.)
예제
- npmjs.com에서 인증 토큰을 생성합니다.(예:
https://www.npmjs.com/settings/<your-username>/tokens/new
- 편의성을 위해 "Automation"을 선택합니다.) - 새 프로젝트를 시작합니다.(예:
mkdir test && cd test/ && npm init -y
) - 헬퍼 라이브러리를 설치합니다.(예:
npm install ssri libnpmpack npm-registry-fetch
) - “실제” 패키지 및 내용물 역할을 할 하위 디렉터리를 생성합니다.(예:
mkdir pkg && cd pkg/ && npm init -y
) - 해당 패키지 내용을 수정합니다..
- 프로젝트 루트에 다음과 같은 내용으로
publish.js
파일을 생성합니다.
;(async () => {
// 라이브러리
const ssri = require('ssri')
const pack = require('libnpmpack')
const fetch = require('npm-registry-fetch')
// tarball 압축 및 ingetrity 생성
const tarball = await pack('./pkg/')
const integrity = ssri.fromData(tarball, {
algorithms: [...new Set(['sha1', 'sha512'])],
})
// 직접 작성한 매니페스트
const name = '<pkg name>'
const version = '<pkg version>'
const manifest = {
_id: name,
name: name,
'dist-tags': {
latest: version,
},
versions: {
[version]: {
_id: `${name}@${version}`,
name,
version,
dist: {
integrity: integrity.sha512[0].toString(),
shasum: integrity.sha1[0].hexDigest(),
tarball: '',
},
scripts: {},
dependencies: {},
},
},
_attachments: {
0: {
content_type: 'application/octet-stream',
data: tarball.toString('base64'),
length: tarball.length,
},
},
}
// PUT을 통해 배포
fetch(name, {
'//registry.npmjs.org/:_authToken': '<auth token>',
method: 'PUT',
body: manifest,
})
})()
7. 원하는 대로 매니페스트
키를 수정합니다.(예: 위에서는 scripts
와 dependencies
를 제거했습니다.)
8.프로그램을 실행합니다.(예: run publish.js
)
9. https://registry.npmjs.com/<pkg>/
와 https://www.npmjs.com/package/<pkg>/v/<version>?activeTab=explore
로 이동해 불일치를 확인하세요.
위 예제에서 패키지는 해당 package.json
(https://www.npmjs.com/darcyclarke-manifest-pkg 및 https://registry.npmjs.com/darcyclarke-manifest-pkg/ 참조)과 다른 매니페스트와 함께 게시되었습니다.
버그, 버그, 버그
이 불일치를 재현하는 더 쉬운 방법을 원한다면 지금 당장 npm
CLI를 사용하면 됩니다. 프로젝트에서 binding.gyp
파일을 볼 때 npm publish
중에 실제로 매니페스트를 변경하기 때문입니다. 이는 제가 합류하기 이전(즉, 6.x
이하 버전)부터 클라이언트에 존재했던 것으로 보이는 동작으로, 많은 버그 및 소비자의 혼란을 야기하는 원인입니다.
npm init -y
touch binding.gyp
npm publish
"node-gyp rebuild" script.install
항목이 매니페스트에 자동으로 추가 되었지만 실제 tarball의package.json
에는 추가되지 않은 것을 확인할 수 있습니다. (예. https://registry.npmjs.com/darcyclarke-binding 및 https://unpkg.com/darcyclarke-binding@1.0.0/package.json)
이런 불일치의 실제 사례 및 피해자로는 node-canvas
가 있습니다.
- https://www.npmjs.com/package/node-canvas/v/2.9.0?activeTab=explore
- https://registry.npmjs.com/node-canvas/2.9.0
- npm/cli#5234
영향
이 버그가 실제로 소비자, 실제 사용자에게 영향을 미치는 방식은 여러 가지가 있습니다.
- 캐시 포이즌(즉, 저장된 패키지가 레지스트리/URI에 있는 패키지의 이름+버전 사양과 일치하지 않을 수 있음)
- 알 수 없는, 목록에 없는 종속성 설치(보안 및 감사 도구 속이기)
- 알 수 없는, 목록에 없는 스크립트 실행(보안 및 감사 도구 속이기)
- 다운그레이드 공격 가능성 존재(프로젝트에 저장된 버전 사양이 지정되지 않은 취약한 버전의 패키지에 대한 것일 경우)
영향을 받는다고 알려진 타사 조직 및 단체
- Snyk: https://security.snyk.io/package/npm/darcyclarke-manifest-pkg
- CNPMJS/Chinese Mirror: https://npmmirror.com/package/darcyclarke-manifest-pkg
- Cloudflare Mirror: https://registry.npmjs.cf/darcyclarke-manifest-pkg/2.1.15
- Skypack: https://cdn.skypack.dev/-/darcyclarke-manifest-pkg@v2.1.15
- UNPKG: https://unpkg.com/darcyclarke-manifest-pkg@2.1.15/package.json
- JSPM: https://ga.jspm.io/npm:darcyclarke-manifest-pkg@2.1.15/package.json
- Yarn: https://yarnpkg.com/package/darcyclarke-manifest-pkg
업데이트: 이전에 소켓 시큐리티는 매니페스트 혼동 문제에 대해 수용 가능하다고 밝힌 바 있습니다. 2022년 9월 5일부터 Socket은 tarball 내부의
package.json
파일을 진실의 출처로 사용하며 패키지(예: dependencies, licenses, scripts)에 대한 정확한 정보를 표시해야 합니다. 이 글이 게시되었을 때darcyclarke0-manifest-pkg
패키지 페이지는 오래된 데이터 참조를 잘못 사용하고 있었으며, Socket 팀에 의해 신속하게 해결되었습니다. 특히, Socket팀은 이 분야에서 이 문제를 적절하게 처리한 최초의 팀일 가능성이 높습니다.
이 문제는 아래에 자세히 설명된 다양한 방식으로 알려진 모든 주요 자바스크립트 패키지 매니저에도 영향을 미칩니다. jFrog의 Artifacory와 같은 타사 레지스트리 구현도 이 API 설계 및 문제를 재현하고 있는 것으로 보이며, 이는 해당 비공개 레지스트리 인스턴스의 모든 클라이언트에서 동일한 문제 및 불일치를 확인할 수 있음을 의미합니다.
특히, 다양한 패키지 매니저 및 도구는 패키지의 매니페스트 또는 tarball의 package.json
을 사용 및 참조하는 시나리오가 다릅니다.(거의 대부분 설치 시 캐시 및 성능 향상을 위한 매커니즘으로 사용됩니다)
여기서 중요한 점은 현재 생태계는 매니페스트에 항상 tarball의 package.json
내용이 포함되어 있다는 잘못된 가정을 하고 있다는 점입니다.(이는 레지스트리 API 문서가 상당히 부족하고 docs.npmjs.com에서 레지스트리가 package.json
의 내용을 메타데이터로 저장한다는 사실에 대한 다양한 언급이 없기 때문입니다. 또한, 클라이언트가 일관성을 보장할 책임이 있다는 언급은 어디에도 없습니다.)
npm@6
매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다
재현 단계
- 잘못된 종속성 설치:
npx npm@6 install darcyclarke-manifest-pkg@2.1.13
- 매니페스트에 라이프 사이클 스크립트가 없고, 레지스트리에 패키지에 설치 스크립트가 있다고 등록되지 않았는데도 라이프 사이클 스크립트가 실행되고 있는지 확인합니다 (예:
hasInstallScript
가undefined
또는false
)(참조: https://registry.npmjs.org/darcyclarke-manifest-pkg/2.1.13 / 코드/패키지 참조: https://github.com/npm/minify-registry-metadata/blob/main/lib/index.js). node_modules/darcyclarke-manifest-pkg
의package.json
은 tarball 항목을 반영합니다.
매니페스트에 없는 의존을 설치할 수 있으며 그 반대의 경우도 가능합니다
패키지 tarball은 전역 스토어에 캐싱 되므로 --prefer-offline
설정을 --no-package-lock
과 함께 사용하면 다시 시스템 전체에서 동일한 패키지를 install
할 때 tarball에 숨겨져 있는 의존이 설치될 수 있습니다.
재현 단계
npx npm@6 install darcyclarke-manifest-pkg@2.1.13
설치- 다시 어딘가에서 설치 다시 하기..
npx npm@6 install --prefer-offline --no-package-lock
npm@9
매니페스트에 없는 의존을 설치할 수 있으며 그 반대의 경우도 가능합니다
npm@6
와 유사하게, npm@9
은 --offline
설정을 사용할 때 패키지의 캐시된 tarball package.json
내부에 참조된 의존을 쉽게 설치합니다.
“참고:
--offline
이 캐시에서 가져올 수도 있고 아닐 수도 있어 간헐적인 결과를 초래합니다. 특정 경쟁 상태(Race condition)가 있는 것처럼 보입니다."
재현 단계
- 잘못된 종속성을 설치해 캐시 되도록 합니다.
- 네트워크를 사용할 수 없게 한 뒤
--offline
설정을 사용해 설치를 실행합니다. (예:npm install --offline --no-package-lock
) - 매니페스트에 참조되지 않은 종속성이 설치되는지 확인합니다.
yarn@1
매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다
npm@6
및 npm@9
와 마찬가지로, yarn@1
은 tarball 내부에 있지만 매니페스트에서 참조되지 않는 스크립트를 실행할 수 있으며, 그 반대의 경우도 가능합니다.
tarball의 version
을 사용해 다운그레이드 공격 벡터에 노출될 가능성이 존재합니다
알려진 것처럼 tarball은 매니페스트와 다른 version
을 정의할 수 있습니다. 이 경우, yarn@1
은 쉽게 업그레이드, 다운그레이드할 수 있으며 잘못된 버전을 사용하는 프로젝트의 package.json
에 저장합니다.(후속 설치 시 잠재적으로 사용자들은 다운그레이드 공격에 노출될 수 있음)
pnpm@7
매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다
재현 단계
다른 것과 마찬가지로 pnpm
은 tarball 내부에 있지만 매니페스트에서 참조되지 않는 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다.
CWE 분류 및 분해
이 취약점에 대한 CWE 분류는 잠재적으로 다양할 수 있습니다. 최소한 이 문제를 “기능”으로 간주할 수 있다면, 이 것을 “서버 측 보안의 클라이언트 측 적용”(즉, CWE-602
)으로 간주하여야 합니다. 하지만, 적용할 수 있는 최소 범위인지 의심이 듭니다. 아래에 다양한 문제를 해당 CWE 분류와 함께 분해해 봤습니다.(사례마다 코드 참조가 제공됩니다.)
- CWE-602: Client-Side Enforcement of Server-Side Security
️️️✔️ 서버 측에서 수행해야 하는 작업을 클라이언트(일명npm
CLI)에 크게 의존해 온 이력이 있습니다.
✔️ 코드 참조. https://github.com/npm/cli/blob/latest/workspaces/libnpmpublish/lib/publish.js#L63 - CWE-94: Improper Control of Generation of Code (‘Code Injection’)
✔️️ 이는 모든 소비자(npm
과 같은 패키지 매니저를 포함)와 관련이 있으며, 아래에서 언급된 바와 같이 다양한 문제가 있습니다. - CWE-295: Improper Certificate Generation
✔️ tarball의 내용(name
,version
,dependencies
,license
,scripts
등을 포함해서)이 연관된 레지스트리 인덱스와 다르더라도 서명되고 무결성 값이 부여됩니다. - CWE-325: Missing Cryptographic Step
✔️ 매니페스트 데이터가 서명되지 않았으므로 오프라인에서 캐시하거나 확인할 수 없습니다.
✔️tarball의package.json
과 패키지 매니페스트가 겹치는 키의 데이터 하위 집합에 대한 해시 및 검증이 누락되었습니다. - CWE-656: Reliance on Security Through Obscurity
✔️ 레지스트리 API와 관련된 문서가 완전히 부족했기 때문에 이 문제를 쉽게 파악할 수 없었습니다.
깃헙은 이 상황에 대해 무엇을 하고 있을까요?
제가 알기로는 2022년 11월 4일 또는 그즈음에 이 문제를 처음 알게 되었으며, 독립적인 조사를 수행한 결과 이 문제의 잠재적 영향 및 위험이 원래 알려진 것보다 훨씬 더 크다고 생각해 3월 9일 조사 결과를 담은 HackerOne 보고서를 제출했습니다. 깃헙은 3월 21일에 해당 티켓을 종료하고 “내부적으로” 이 문제를 처리하고 있다고 말했습니다. 제가 아는 한 깃헙은 이 문제에 대해 큰 진전을 이루지 않았고 이 문제를 공개하지 않았습니다. 대신, 6개월 동안 npm에서 제품의 입지를 사실상 매각했으며 후속 조치나 개선 작업에 대한 통찰을 전혀 제공하지 않았습니다.
해결책은 어떤 모습일까요?
깃헙은 부정할 수 없는 어려운 상황에 처해 있습니다. npmjs.com
이 10년 넘게 이런 식으로 작동해 왔다는 사실은 현재 상태가 거의 체계화되어 있고 독특한 방식으로 누군가를 깨트릴 가능성이 있음을 의미합니다. 앞에서 언급했듯이 npm CLI 자체는 이 동작에 의존하고 있으며, 오늘날 야생에는 이 동작이 악의적이지 않은 다른 용도로 사용될 가능성이 있습니다.
- 수행해야 할 작업으로는..
- 남용을 결정하는 데 도움이 될 레지스트리에서 영향을 받는 항목의 범위를 결정하기 위해 수행해야 하는 추가 조사가 있습니다.
- 불일치하는 수가 미미한 경우(인플라이트 매니페스트 변이가 얼마나 널리 퍼져있는지를 고려할 때 의심스럽지만), tarball의
package.json
을 기반으로 불일치가 있는 매니페스트를 재생성하는 것이 합리적일 것이라 생각합니다. - 매니페스트에서 권한 및 알려진 키를 적용, 검증하기 시작한 것은 모든 연구 및 발견과 비동기적으로 발생할 수 있습니다.
- 공개 npm 레지스트리 API 및 각각의 요청 및 응답 객체는 가능한 한 가장 빨리 문서화되어야 합니다.
당신은 무엇을 할 수 있을까요?
npm 레지스트리 매니페스트 데이터를 사용하는 것으로 알려진 툴 제작자 또는 관리자에게 연락해 적절한 패키지의 내용을 메타데이터로 사용하도록 하세요.(즉, name
과 version
을 제외한 모든 것). 일관성을 엄격하게 적용하고 검증하는 레지스트리 프록시를 사용하세요.