npm 생태계 중심에 있는 거대한 버그

한정(Han Jung)
18 min readJul 16, 2023

--

원문: 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.jsonnameversion 필드는 검증되지 않았기 때문에 실제 매니페스트의 값과 다를 수 있습니다.)

예제

  1. npmjs.com에서 인증 토큰을 생성합니다.(예: https://www.npmjs.com/settings/<your-username>/tokens/new - 편의성을 위해 "Automation"을 선택합니다.)
  2. 새 프로젝트를 시작합니다.(예: mkdir test && cd test/ && npm init -y)
  3. 헬퍼 라이브러리를 설치합니다.(예: npm install ssri libnpmpack npm-registry-fetch)
  4. “실제” 패키지 및 내용물 역할을 할 하위 디렉터리를 생성합니다.(예: mkdir pkg && cd pkg/ && npm init -y)
  5. 해당 패키지 내용을 수정합니다..
  6. 프로젝트 루트에 다음과 같은 내용으로 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. 원하는 대로 매니페스트 키를 수정합니다.(예: 위에서는 scriptsdependencies를 제거했습니다.)

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-pkghttps://registry.npmjs.com/darcyclarke-manifest-pkg/ 참조)과 다른 매니페스트와 함께 게시되었습니다.

버그, 버그, 버그

이 불일치를 재현하는 더 쉬운 방법을 원한다면 지금 당장 npm CLI를 사용하면 됩니다. 프로젝트에서 binding.gyp 파일을 볼 때 npm publish 중에 실제로 매니페스트를 변경하기 때문입니다. 이는 제가 합류하기 이전(즉, 6.x 이하 버전)부터 클라이언트에 존재했던 것으로 보이는 동작으로, 많은 버그 및 소비자의 혼란을 야기하는 원인입니다.

  1. npm init -y
  2. touch binding.gyp
  3. npm publish
  4. "node-gyp rebuild" script.install 항목이 매니페스트에 자동으로 추가 되었지만 실제 tarball의 package.json에는 추가되지 않은 것을 확인할 수 있습니다. (예. https://registry.npmjs.com/darcyclarke-bindinghttps://unpkg.com/darcyclarke-binding@1.0.0/package.json)

이런 불일치의 실제 사례 및 피해자로는 node-canvas가 있습니다.

영향

이 버그가 실제로 소비자, 실제 사용자에게 영향을 미치는 방식은 여러 가지가 있습니다.

  • 캐시 포이즌(즉, 저장된 패키지가 레지스트리/URI에 있는 패키지의 이름+버전 사양과 일치하지 않을 수 있음)
  • 알 수 없는, 목록에 없는 종속성 설치(보안 및 감사 도구 속이기)
  • 알 수 없는, 목록에 없는 스크립트 실행(보안 및 감사 도구 속이기)
  • 다운그레이드 공격 가능성 존재(프로젝트에 저장된 버전 사양이 지정되지 않은 취약한 버전의 패키지에 대한 것일 경우)

영향을 받는다고 알려진 타사 조직 및 단체

업데이트: 이전에 소켓 시큐리티는 매니페스트 혼동 문제에 대해 수용 가능하다고 밝힌 바 있습니다. 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

매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다

재현 단계

  1. 잘못된 종속성 설치: npx npm@6 install darcyclarke-manifest-pkg@2.1.13
  2. 매니페스트에 라이프 사이클 스크립트가 없고, 레지스트리에 패키지에 설치 스크립트가 있다고 등록되지 않았는데도 라이프 사이클 스크립트가 실행되고 있는지 확인합니다 (예: hasInstallScriptundefined 또는 false)(참조: https://registry.npmjs.org/darcyclarke-manifest-pkg/2.1.13 / 코드/패키지 참조: https://github.com/npm/minify-registry-metadata/blob/main/lib/index.js).
  3. node_modules/darcyclarke-manifest-pkgpackage.json은 tarball 항목을 반영합니다.

매니페스트에 없는 의존을 설치할 수 있으며 그 반대의 경우도 가능합니다

패키지 tarball은 전역 스토어에 캐싱 되므로 --prefer-offline 설정을 --no-package-lock과 함께 사용하면 다시 시스템 전체에서 동일한 패키지를 install할 때 tarball에 숨겨져 있는 의존이 설치될 수 있습니다.

재현 단계

  1. npx npm@6 install darcyclarke-manifest-pkg@2.1.13 설치
  2. 다시 어딘가에서 설치 다시 하기.. npx npm@6 install --prefer-offline --no-package-lock

npm@9

매니페스트에 없는 의존을 설치할 수 있으며 그 반대의 경우도 가능합니다

npm@6와 유사하게, npm@9--offline 설정을 사용할 때 패키지의 캐시된 tarball package.json 내부에 참조된 의존을 쉽게 설치합니다.

“참고: --offline이 캐시에서 가져올 수도 있고 아닐 수도 있어 간헐적인 결과를 초래합니다. 특정 경쟁 상태(Race condition)가 있는 것처럼 보입니다."

재현 단계

  1. 잘못된 종속성을 설치해 캐시 되도록 합니다.
  2. 네트워크를 사용할 수 없게 한 뒤 --offline 설정을 사용해 설치를 실행합니다. (예: npm install --offline --no-package-lock)
  3. 매니페스트에 참조되지 않은 종속성이 설치되는지 확인합니다.

yarn@1

매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다

npm@6npm@9와 마찬가지로, yarn@1은 tarball 내부에 있지만 매니페스트에서 참조되지 않는 스크립트를 실행할 수 있으며, 그 반대의 경우도 가능합니다.

tarball의 version을 사용해 다운그레이드 공격 벡터에 노출될 가능성이 존재합니다

알려진 것처럼 tarball은 매니페스트와 다른 version을 정의할 수 있습니다. 이 경우, yarn@1은 쉽게 업그레이드, 다운그레이드할 수 있으며 잘못된 버전을 사용하는 프로젝트의 package.json에 저장합니다.(후속 설치 시 잠재적으로 사용자들은 다운그레이드 공격에 노출될 수 있음)

pnpm@7

매니페스트에 없는 설치 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다

재현 단계

다른 것과 마찬가지로 pnpm은 tarball 내부에 있지만 매니페스트에서 참조되지 않는 스크립트를 실행할 수 있으며 그 반대의 경우도 가능합니다.

CWE 분류 및 분해

이 취약점에 대한 CWE 분류는 잠재적으로 다양할 수 있습니다. 최소한 이 문제를 “기능”으로 간주할 수 있다면, 이 것을 “서버 측 보안의 클라이언트 측 적용”(즉, CWE-602)으로 간주하여야 합니다. 하지만, 적용할 수 있는 최소 범위인지 의심이 듭니다. 아래에 다양한 문제를 해당 CWE 분류와 함께 분해해 봤습니다.(사례마다 코드 참조가 제공됩니다.)

깃헙은 이 상황에 대해 무엇을 하고 있을까요?

제가 알기로는 2022년 11월 4일 또는 그즈음에 이 문제를 처음 알게 되었으며, 독립적인 조사를 수행한 결과 이 문제의 잠재적 영향 및 위험이 원래 알려진 것보다 훨씬 더 크다고 생각해 3월 9일 조사 결과를 담은 HackerOne 보고서를 제출했습니다. 깃헙은 3월 21일에 해당 티켓을 종료하고 “내부적으로” 이 문제를 처리하고 있다고 말했습니다. 제가 아는 한 깃헙은 이 문제에 대해 큰 진전을 이루지 않았고 이 문제를 공개하지 않았습니다. 대신, 6개월 동안 npm에서 제품의 입지를 사실상 매각했으며 후속 조치나 개선 작업에 대한 통찰을 전혀 제공하지 않았습니다.

해결책은 어떤 모습일까요?

깃헙은 부정할 수 없는 어려운 상황에 처해 있습니다. npmjs.com이 10년 넘게 이런 식으로 작동해 왔다는 사실은 현재 상태가 거의 체계화되어 있고 독특한 방식으로 누군가를 깨트릴 가능성이 있음을 의미합니다. 앞에서 언급했듯이 npm CLI 자체는 이 동작에 의존하고 있으며, 오늘날 야생에는 이 동작이 악의적이지 않은 다른 용도로 사용될 가능성이 있습니다.

  • 수행해야 할 작업으로는..
  • 남용을 결정하는 데 도움이 될 레지스트리에서 영향을 받는 항목의 범위를 결정하기 위해 수행해야 하는 추가 조사가 있습니다.
  • 불일치하는 수가 미미한 경우(인플라이트 매니페스트 변이가 얼마나 널리 퍼져있는지를 고려할 때 의심스럽지만), tarball의 package.json을 기반으로 불일치가 있는 매니페스트를 재생성하는 것이 합리적일 것이라 생각합니다.
  • 매니페스트에서 권한 및 알려진 키를 적용, 검증하기 시작한 것은 모든 연구 및 발견과 비동기적으로 발생할 수 있습니다.
  • 공개 npm 레지스트리 API 및 각각의 요청 및 응답 객체는 가능한 한 가장 빨리 문서화되어야 합니다.

당신은 무엇을 할 수 있을까요?

npm 레지스트리 매니페스트 데이터를 사용하는 것으로 알려진 툴 제작자 또는 관리자에게 연락해 적절한 패키지의 내용을 메타데이터로 사용하도록 하세요.(즉, nameversion제외한 모든 것). 일관성을 엄격하게 적용하고 검증하는 레지스트리 프록시를 사용하세요.

--

--