메뉴
HN
Hacker News • 4일 전

npm 수학 라이브러리에 암호화 로더가 숨겨진 이유

IMP
8/10
핵심 요약

인기 수학 라이브러리 mathjs를 사칭한 npm 패키지 'mathmain'에서 원격 접근 백도어(임플란트)가 발견되었다. 악성 코드는 암호화되어 숨겨져 있으며, 특정 방정식을 풀 때 생성되는 행렬 데이터가 복호화 키로 사용되어야만 활성화된다. 유출된 페이로드는 공격자의 명령을 실행하며 공개 채팅 서비스와 블록체인 네트워크를 명령 채널로 활용한다.

번역된 본문

블로그로 돌아가기

npm 수학 라이브러리에 암호화 로더가 왜 필요한가?

보안 | SafeDep 팀 • 2026년 9월 18일 • 11분 읽기

우리는 인기 수학 라이브러리인 mathjs를 복제한 npm 패키지 mathmain 내부에 숨겨진 원격 접근 임플란트를 발견했습니다. 악성 코드는 암호화된 형태로 배포되며, 프로그램이 이 라이브러리로 특정 방정식을 풀 때까지 잠복 상태로 유지됩니다. 그 방정식이 곧 키(key)입니다. 키가 일치하면 패키지는 페이로드를 복호화하여 실행합니다. 이 페이로드는 공격자로부터 명령을 받아 호스트에서 실행하며, 공개 채팅 서비스와 블록체인 네트워크를 명령 채널로 사용합니다. 이 글에서는 로더를 어떻게 발견했는지, 어떻게 복호화했는지, 페이로드가 무엇을 하는지, 그리고 이를 탐지할 수 있는 지표들을 소개합니다.

2026년 9월 17일, 우리는 SafeDep 분석을 통해 mathmain을 조사하기 시작했습니다. 이 패키지는 이름만 다르고 코드가 난독화된 mathjs의 복제본처럼 보였습니다. 솔버(solver)에 추가된 호출 하나가 우리를 로더로 이끌었습니다.

솔버가 타입 체크를 호출

lusolve() 함수의 거의 끝부분에서 CommonJS 빌드에 추가된 호출을 발견했습니다. 솔버는 이미 결과를 계산한 상태였고, 그 후 하삼각행렬(lower triangular matrix)의 데이터를 removeSolveValidation()에 전달하고 있었습니다. 여기서 l은 하삼각행렬을, x는 결과를 담고 있습니다. 솔버는 x를 변경 없이 반환하며, 추가 호출의 반환값을 q에 할당하지만 q는 다시 사용되지 않습니다.

removeSolveValidation()을 따라가 보니 lib/cjs/utils/is.js의 isGraph(x)로 연결되었습니다. 이 파일에는 isMatrix, isNumber 같은 체크 함수들이 포함되어 있는데, 추가된 isGraph() 함수가 코드를 복호화하여 로드하는 역할을 합니다.

isGraph()는 입력을 JSON 문자열로 변환하고 그 문자열을 비밀번호로 사용합니다. 먼저 파일명을 복호화한 다음, 파일 경로와 비밀번호를 event()에 전달하고, 반환된 경로를 require()로 로드합니다. 가독성을 위해 로더 코드 조각에서는 문자열을 복원하고 지역 변수명을 변경했습니다. 글 끝부분의 해시값이 원본 파일을 식별합니다.

행렬 데이터가 비밀번호가 된다

lib/cjs/utils/event.js에서 복호화 함수들을 발견했습니다. 이 함수들은 scrypt를 사용해 비밀번호를 256비트 키로 변환한 후, AES-GCM(Galois/Counter Mode)으로 데이터를 복호화합니다.

암호화된 데이터는 고정된 구조를 가집니다: 16바이트 salt, 12바이트 초기화 벡터(IV), 16바이트 인증 태그(tag), 그 뒤에 암호문이 이어집니다. 패키지는 이 전체 시퀀스를 base64 텍스트로 저장합니다.

솔버를 통한 호출의 경우 비밀번호는 JSON.stringify(L._data)입니다. 호출자는 lusolve()의 객체 형식으로 L을 전달할 수 있으므로, 올바른 비밀번호를 만들어내는 행렬 데이터를 넘겨야 합니다. 우리는 공개된 로더 코드 안에 저장된 비밀번호를 발견하지 못했습니다.

암호화된 파일명은 8바이트의 암호문입니다. 로더 옆에 있으며 파일명도 8바이트인 graph.js를 가리키는 것으로 추정되지만, 비밀번호를 복구하지 못해 확인하지는 못했습니다.

로더가 파일을 작성하고 실행

event() 헬퍼는 파일을 복호화하고, 결과를 디스크에 기록한 뒤 출력 경로를 반환합니다. 파일명에 enc_ 접두사가 없으면, 헬퍼는 암호화된 파일을 복호화된 코드로 덮어씁니다. 그런 다음 isGraph()의 require() 호출이 이를 로드하며, 그 코드는 Node.js 프로세스와 동일한 권한으로 실행됩니다.

세 개의 추가된 파일에는 일반적인 JavaScript 대신 base64 데이터가 담겨 있었습니다:

  • lib/cjs/utils/ 하위 graph.js — 암호문 20,918바이트
  • lib/cjs/utils/ 하위 fraction.js — 암호문 9,084바이트
  • lib/cjs/utils/bignumber/ 하위 type.js — 암호문 1,179,416바이트

공개된 로더에서는 마지막 두 파일에 대한 참조를 찾지 못했습니다. 복호화된 graph.js가 페이로드 섹션에서 보여지듯 이후 단계로 이들을 로드합니다. 매니페스트에서는 설치 훅(install hook)을 발견하지 못했습니다.

원문 보기
원문 보기 (영어)
Back to Blog Why Does an npm Math Library Need an Encrypted Loader? Security SafeDep Team • Sep 18, 2026 • 11 min read On this page 11 sections On this page ` rules instead of the old inline `prose-* / [&_…]` modifier string — that string compiled to ~40 complex selectors that Chrome iOS (WKWebView) re-matched on every scroll recalc, blanking the page. Base `prose prose-lg prose-invert` reset is kept. Running text is capped at a readable measure in that file; code blocks, tables, and embeds intentionally span the full column. --> We found a remote access implant hidden inside [email protected] , an npm package that copies the popular mathjs library. The malicious code ships encrypted. It stays dormant until a program solves a specific equation with the library. That equation is the key. When the key matches, the package decrypts a payload and runs it. The payload takes commands from the attacker and runs them on the host. It uses a public chat service and a blockchain network for its command channel. This post shows how we found the loader, how we decrypted it, what the payload does, and the indicators you can use to find it. We started with a SafeDep analysis of mathmain on September 17, 2026. The package looked like a copy of mathjs with a different name and obfuscated code. One added call in the solver led us to the loader. A solver calls a type check Near the end of lusolve() , we found an extra call in the CommonJS build. The solver had already calculated its result. It then passed data from the lower triangular matrix to removeSolveValidation() : Here, l holds the lower triangular matrix and x holds the result. The solver returns x unchanged. It assigns the extra call’s return value to q , but does not use q again. We followed removeSolveValidation() to isGraph(x) in lib/cjs/utils/is.js . This file contains checks such as isMatrix and isNumber . The added isGraph() function decrypts and loads code: isGraph() converts its input to a JSON string and uses that string as a password. It first decrypts a filename. It then passes the file path and password to event() , and loads the returned path with require() . We have made the loader snippets easier to read by restoring strings and renaming local variables. The hashes at the end of this post identify the original files. The matrix data becomes a password In lib/cjs/utils/event.js , we found the decryption functions. They use scrypt to turn the password into a key of 256 bits. They then decrypt the data with Advanced Encryption Standard in Galois/Counter Mode (AES-GCM): The encrypted data has a fixed layout: a salt of 16 bytes, an initialization vector of 12 bytes, and an authentication tag of 16 bytes. The ciphertext follows these fields. The package stores the whole sequence as base64 text. For calls through the solver, the password is JSON.stringify(L._data) . A caller can supply L through the object form of lusolve() . So the caller must pass matrix data that produces the correct password. We found no password stored in the visible loader. The encrypted filename has eight bytes of ciphertext. We suspect it names graph.js , a file beside the loader whose name also takes eight bytes. We could not confirm this because we did not recover the password. The loader writes and runs the file The event() helper decrypts the file, writes the result to disk, and returns the output path: If the filename has no enc_ prefix, the helper overwrites the encrypted file with the decrypted code. The require() call in isGraph() then loads it. That code would run with the same permissions as the Node.js process. Three added files contain base64 data instead of normal JavaScript: Path under lib/cjs/utils/ Size of ciphertext in bytes graph.js 20,918 fraction.js 9,084 bignumber/type.js 1,179,416 We found no reference to the last two files in the visible loader. The decrypted graph.js loads them as later stages, as the payload section shows. We found no install hooks in the manifest. Importing the package through the path we reviewed does not activate the loader either. The solver must first pass its validation and calculation steps to reach the added call. If the password is wrong, validEvent() fails its authentication check before the helper writes any file. The same loader appears in two more packages We searched the npm registry and found two more packages: mathsbase and math-universe . Across five versions, we found identical loader files, trigger code, solver changes, and two large encrypted files. The encrypted graph.js in [email protected] differs from the other copies. The shared files connect these releases. They do not tell us who added the loader or whether someone took over a publisher’s account. On September 17, npm served [email protected] as the default release. That version did not contain this loader. Checking only the default version would have missed the code in 1.0.1 . On September 19, we checked npm’s download counts for all three packages. These totals cover September 12–18, 2026, across all versions of each package. Package Reported downloads mathmain 605,157 mathsbase 1,923,059 math-universe 569,730 npm also reported zero downloads across the entire registry for September 17 . This makes the earlier zero for mathmain unreliable. The packages have no public dependents. They have almost no traffic on the jsDelivr content network. So the counts do not show real installs. We could not determine what produced the volume. These counts do not tell us how many systems installed the packages or whether the encrypted code ran. The loader is not in the GitHub source The mathsbase and math-universe packages each link to a public GitHub repository. We read both. Neither repository holds the loader: Repository Reviewed commit github[.]com/tinystar8/mathsbase 560d97e66140dbf817e04284a7a0c58757d1202e github[.]com/mathubio/math-universe da99dd46501c75ba6102a51ef60ebb922174da32 The public math-universe source ends its solver like this: The extra removeSolveValidation() call is not there. The npm build has it. The GitHub source does not. So someone added the loader when they published the package, not in the public code. We also looked for a program that calls the solver with the trigger. We searched GitHub code, lockfiles, and dependency services. We found none. Private projects, and code that search engines miss, stay out of reach. Our first attempts did not find the password We tested 16,922 possible passwords against the encrypted filename. Some came from matrices with zero diagonal entries, which our earlier search had left out. In a second search, we tested 533 possible passwords against all five distinct encrypted blobs, including the older graph.js . We tried common passwords and numeric arrays from the solver’s tests. Neither search found a password that passed the authentication check. Some passwords appeared in both searches. We checked the tools with test data and known passwords. The searches finished. None of our guesses worked. The password was not a common value or a test matrix. It was one specific matrix that we found later. Cracking the encryption The password is the JSON form of a matrix lower factor L . JFrog first reported the input that produces it. We then reproduced the result against the mathmain files. The recovered trigger is a 3 by 3 Pascal matrix. A caller passes A to lusolve() . The solver runs an LU decomposition of A . The lower triangular factor L becomes [[1, 0, 0], [1, 1, 0], [1, 0.5, 1]] . The loader turns L into a JSON string. That string is the password. We confirmed this against the real files. The password is the JSON form of L . The password decrypted the filename to graph.js . It also decrypted the three payload files in mathmain and in math-universe . The mathmain payload matches the math-universe payload byte for byte. [email protected] uses the same password with different encrypted data. So the same password unlocks the whole family. Payload analys