Hacker News

Why does mathmain need an encrypted loader?

mathmain에 암호화된 로더가 필요한 이유는 무엇인가

mathmain과 유사 패키지 2개에 암호화된 원격 접근 implant가 숨겨져 있었습니다. 특정 3×3 행렬의 LU 분해 결과가 암호 해독 비밀번호가 되며, 해독된 코드는 Slack과 블록체인을 C2 채널로 사용해 원격 코드를 실행합니다.

AI 요약

SafeDep는 mathjs를 복제한 npm 패키지 mathmain에서 암호화된 원격 접근 implant를 발견했습니다. 악성 코드는 일반 JavaScript처럼 보이지 않고 base64 데이터로 저장되어 있습니다. 패키지를 import하는 것만으로는 실행되지 않으며, lusolve()에 특정 입력을 전달해야 로더가 동작합니다.

로더가 실행되는 경로

2026년 9월 17일 분석에서 SafeDep는 CommonJS 빌드의 lusolve() 끝부분에 추가된 호출을 찾았습니다. Solver가 이미 결과를 계산한 뒤 하부 삼각행렬 L과 결과값 xremoveSolveValidation()에 넘깁니다. 반환값은 변수 q에 저장하지만 이후 사용하지 않습니다. 이 함수는 lib/cjs/utils/is.jsisGraph()로 이어집니다.

isGraph()는 입력값을 JSON.stringify()한 문자열을 비밀번호로 사용합니다. 먼저 암호화된 파일 이름을 복호화한 뒤, 파일 경로와 비밀번호를 event()에 전달합니다. event()가 반환한 경로를 require()로 불러오면서 복호화된 코드가 Node.js 프로세스와 같은 권한으로 실행됩니다.

복호화에는 scrypt로 만든 256비트 키와 AES-GCM이 사용됩니다. 암호문은 16바이트 salt, 12바이트 initialization vector, 16바이트 authentication tag, ciphertext 순서로 구성되며 전체가 base64 문자열로 저장됩니다. 인증에 실패하면 파일을 쓰지 않고 원래 경로를 반환합니다. 따라서 임의의 입력값으로는 payload가 기록되지 않습니다.

lib/cjs/utils/ 아래에는 정상적인 JavaScript 대신 암호화된 데이터가 들어 있는 파일이 3개 있습니다. graph.js의 ciphertext는 20,918바이트, fraction.js는 9,084바이트, bignumber/type.js는 1,179,416바이트입니다. 화면에 보이는 로더에서는 뒤의 두 파일을 직접 참조하지 않지만, 복호화된 graph.js가 후속 단계로 불러옵니다.

비밀번호를 찾은 방법

SafeDep는 먼저 가능한 비밀번호 16,922개를 암호화된 파일 이름에 대입했습니다. 이후 모든 암호화 blob을 대상으로 533개 후보를 추가 검사했습니다. 행렬 테스트에 등장하는 숫자 배열과 일반적인 비밀번호도 포함했지만 인증을 통과한 값은 없었습니다.

JFrog가 먼저 트리거 입력을 찾아낸 뒤 SafeDep가 실제 mathmain 파일에서 재현했습니다. 트리거는 다음 3×3 Pascal matrix입니다.

A = [[1,1,1],[1,2,3],[1,3,6]]

이 행렬을 lusolve()에 전달하면 LU decomposition의 하부 삼각행렬 L이 다음처럼 나옵니다.

L = [[1,0,0],[1,1,0],[1,0.5,1]]

로더는 이 L을 JSON 문자열로 바꿔 비밀번호로 사용합니다. 같은 L을 계수 행렬로 직접 전달하거나 lusolve()의 객체 형식으로 넘겨도 같은 비밀번호가 만들어집니다. 이 값으로 파일 이름은 graph.js로 복호화되며, mathmain과 math-universe에 포함된 세 payload도 해독됩니다. mathmain의 payload는 math-universe의 payload와 바이트 단위로 일치합니다.

npm 패키지와 배포 상태

동일한 로더가 mathmain, mathsbase, math-universe 세 패키지에서 발견됐습니다. 다섯 버전에 같은 로더 파일, 트리거 코드, 대형 암호화 파일이 들어 있었습니다. 다만 [email protected]의 암호화된 graph.js는 다른 복사본과 달랐습니다.

npm의 기본 릴리스만 확인하면 악성 버전을 놓칠 수 있었습니다. 9월 17일 npm이 기본 버전으로 제공한 mathmain에는 로더가 없었지만, 1.0.1에는 로더가 있었습니다. 9월 18일 mathmain의 npm 다운로드 수는 605,157건으로 집계됐습니다. 다만 npm 전체에서 9월 17일 다운로드가 0건으로 보고된 문제도 있어, 해당 수치만으로 실제 설치 시스템이나 payload 실행 여부를 판단할 수는 없습니다.

mathsbase와 math-universe가 연결한 GitHub 저장소의 검토 커밋에는 로더가 없었습니다. 공개 소스의 solver에는 removeSolveValidation() 호출이 없지만 npm 배포본에는 포함되어 있습니다. SafeDep는 공개 GitHub 코드, lockfile, 의존성 서비스에서 트리거를 호출하는 프로그램을 찾지 못했습니다. 비공개 프로젝트와 검색되지 않는 코드는 조사 범위에 포함되지 않습니다.

복호화된 implant

첫 단계인 graph.jsosfs로 호스트 정보를 읽고, Node.js crypto 모듈의 generateKeyPairSyncdiffieHellman을 사용해 X25519 키 쌍을 만듭니다. child_process로 분리된 Node.js 프로세스를 시작하고 관리하며, ethers 번들을 이용해 Base Sepolia test network의 스마트 컨트랙트를 읽습니다. 통신에는 Slack의 chat.postMessage와 Telegram API가 사용됩니다.

bignumber/type.jsethers 5.7.2 복사본입니다. JsonRpcProvidersecp256k1 심볼이 포함되어 있으며 스마트 컨트랙트 접근에 사용됩니다. fraction.js는 Slack agent입니다. 부모 프로세스에서 CHAT_PASSWORD, 부모 PID, 트리거 비밀번호를 인자로 받습니다. X25519로 만든 공유 비밀이 CHAT_PASSWORD가 되며, 트리거 비밀번호로 Slack bot token과 channel ID를 복호화합니다.

Slack agent는 10초마다 conversations.history를 호출합니다. 허용된 발신자 식별자를 확인한 뒤 메시지를 JSON packet으로 복호화합니다. start packet이 전송을 열고, chunk packet이 파일 내용을 전달하며, end packet이 조각을 합칩니다. 완성된 결과는 lib/cjs/utils/subwatcher에 기록되고 Node.js로 실행됩니다. 블록체인 경로도 원격 데이터를 복호화해 같은 방식으로 subwatcher를 기록하고 실행합니다.

SafeDep는 implant 자체는 복구했지만 이 채널을 통해 운영자가 나중에 전달한 코드는 확보하지 못했습니다. 따라서 피해 시스템에서 실제로 어떤 작업이 실행됐는지는 확인하지 못했습니다. 로더를 호출하는 공격자 코드 역시 공개 자료에서 발견하지 못했습니다.

탐지에 사용할 지표

분석 대상에는 lib/cjs/utils/event.jslib/cjs/utils/is.js가 공통으로 포함됩니다. 두 파일의 SHA-256은 각각 ab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd, 5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae입니다. 복호화된 Deployment A의 graph.js, fraction.js, bignumber/type.js 해시는 각각 1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb, 6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3, 6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25입니다.

C2 지표로는 Base Sepolia 스마트 컨트랙트 0xac0bfC4C48A679b667732128278EACBA1c191894, Ethereum Sepolia 컨트랙트 0xE390863Dac96a7118C71227C2b099B50cF602D31, base-sepolia.infura[.]io, base-sepolia.g.alchemy[.]com, eth-sepolia.g.alchemy[.]com 엔드포인트가 제시됐습니다. 두 배포본은 같은 Alchemy 프로젝트 키도 공유합니다. 다만 패키지나 해시가 시스템에서 발견됐다는 사실만으로 실제 실행을 증명하지는 못합니다. 트리거 입력이 먼저 전달되어야 로더가 payload를 기록합니다.

Hacker News 반응

  • @j2kun — 도대체 왜 공격 트리거로 그 특정 3×3 행렬을 사용하나요? 특정 종류의 수치해석을 하는 사람을 찾는 건가요?
    • @zarzavat — 아마 합법적이고 인기 있는 패키지에 하위 의존성으로 들어가 공격을 준비하려는 것 같습니다. 합법 패키지의 코드는 전혀 의심을 사지 않을 수 있습니다.
    • @krackers — 이제 공격 대상이 무엇이었는지 궁금합니다. 이 특정 행렬에 LU decomposition을 적용하는 유명한 프로그램이나 문제 종류가 있나요?
    • @gruez — 꼭 이 특정 행렬일 필요는 없습니다. 인터넷에 노출된 서비스라면 사용자가 입력을 공급할 수 있습니다. 그러면 백도어로 바뀝니다.
    • @tranceylc — 실제로는 활성화되기 전까지 퍼질 시간을 확보하려는 방식이라고 생각합니다. 그 뒤 전체 패키지 의존성 체인에 영향을 주는 일을 하려는 것일 수 있습니다.
  • @coder-pm — 이 행렬은 조건이 아니라 키입니다. JSON.stringify로 만든 데이터가 scrypt에 비밀번호로 들어가고 AES-256-GCM 키를 만듭니다. 다른 모든 입력은 복호화되지 않으므로 패키지에 정확한 입력을 알지 못하면 payload를 얻을 수 없습니다.
  • @fshafique — FBI나 다른 법 집행 기관이 이런 백도어를 실제로 수사하나요? 이것이 범죄인지, 범죄 모의인지, 아니면 백도어를 사용한 행위만 범죄인지 궁금합니다. 경고 없이 아직 npm에 남아 있는 것도 보입니다. npm 패키지 페이지는 여기입니다: https://www.npmjs.com/package/mathmain. 반면 패키지 GitHub 저장소와 작성자 계정은 사라졌습니다.
    • @altairprime — 이 패키지는 악성 코드를 포함하고 있었고 npm 보안 팀이 레지스트리에서 제거했습니다.
  • @hiddenvulkcan — 이 문제를 풀어낸 사람을 실제로 봤습니다. 또는 Claude나 중국산 도구를 사용했을 수도 있습니다. 두 번째 단계는 완전히 망가져 있었다고 합니다. 더 이상한 점입니다. https://research.veryserious.systems/lusolve-and-you-shall-r...
  • @WorldMaker — 이 사례는 CommonJS module format을 이제 그만 사용해야 한다는 점을 다시 보여주는 것 같습니다. ESM에서도 await import()로 비슷한 수법을 쓸 수 있지만, CommonJS 의존성 전체에서 dynamic require()를 grep하기는 어렵습니다. 정적 import 키워드는 분석 도구를 만들거나 사용하기 쉽지만 CommonJS에는 그런 구분이 없습니다. 저는 이제 npm보다 JSR를 먼저 확인합니다. ESM을 훨씬 더 신뢰하기 때문입니다.
    • @bastawhiz — 부분적으로만 맞습니다. dynamic import는 syntax이지만 숨기는 데 큰 장벽은 아닙니다. i = x => import(x)처럼 작성하면 import를 감출 수 있습니다. await globalThis[computedValueEqualToI]처럼 보이는 코드가 실제로 import를 수행할 수도 있습니다. 무언가 import된다는 사실은 알 수 있지만, 많은 작업 없이는 무엇을 import하는지 알기 어렵습니다. require()를 쓸 때와 거의 같은 노력입니다.
  • @fn-mote — 비밀번호를 푸는 작업을 JFrog가 했고 그 결과 나머지 분석이 가능해졌다는 사실을 글을 꽤 읽어야 알 수 있습니다. https://research.jfrog.com/post/equation-of-compromise/
  • @firesteelrain — 이제 npm을 어떻게 고쳐야 하나요? 레지스트리에 문제가 너무 많습니다.

원문: SafeDep / 번역·요약: Trawling