이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JDK 21 업그레이드 작업에서 SonarQube Insecure Random 룰 S2245가 비밀번호 reset 토큰 생성 코드에서 떴다. “Using pseudorandom number generators (PRNGs) is security-sensitive”라는 메시지였는데, 처음엔 “그냥 난수인데 뭐가 문제야”라는 반응이었다. Security Hotspot이라 빌드를 막는 건 아니었지만, 금융권 보안 감사 대상 항목이라 무시할 수가 없었다.
문제는 두 군데였다. 서버 사이드 Java에서 비밀번호 reset 링크 토큰을 Math.random()으로 만들고 있었고, 사내 어드민 SPA(Vue.js)에서도 임시 ID 생성에 Math.random()을 쓰고 있었다. 둘 다 SonarQube Insecure Random으로 잡혔다. 패턴은 달랐지만 결론은 같았다.
세 번의 시도 끝에 통과했다. Weak Cryptography(S4790)를 잡을 때도 비슷한 흐름이었는데, 이쪽은 난수 생성기 자체가 문제라는 게 본질이라 접근이 달랐다.

레거시에서 발견된 패턴 — Java + JavaScript
건수를 보니 Java 쪽 12건, JavaScript 쪽 4건이었다. SonarQube Insecure Random 유형을 나눠보면 크게 세 가지였다.
패턴 1. Java Math.random() — 비밀번호 reset 토큰
가장 흔하게 나온 패턴이다. 비밀번호 reset 요청이 오면 6자리 숫자 토큰을 만들어서 이메일로 보내는 코드였다.
// PasswordResetService.java — 취약 코드
public class PasswordResetService {
public String generateResetToken() {
// S2245 여기서 잡힘
int token = (int)(Math.random() * 1_000_000);
return String.format("%06d", token);
}
public String generateResetLink() {
// S2245 여기서도 잡힘
long raw = (long)(Math.random() * Long.MAX_VALUE);
return Long.toHexString(raw);
}
}
Math.random()은 내부적으로 java.util.Random과 동일한 선형 합동 생성기(LCG)를 쓴다. seed만 알면 다음 값을 예측할 수 있다. 금액 이체나 본인 인증에 쓰이는 reset 링크를 이런 방식으로 만들면 공격자가 brute-force나 timing attack으로 토큰을 맞힐 가능성이 생긴다.
패턴 2. Java new Random() — 세션 ID 패딩
// SessionHelper.java — 취약 코드
public class SessionHelper {
private static final Random RAND = new Random(); // S2245 잡힘
public static String buildSessionSuffix() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 8; i++) {
sb.append((char)('a' + RAND.nextInt(26)));
}
return sb.toString();
}
}
new Random()도 마찬가지다. 기본 생성자가 System.nanoTime() 기반 seed를 쓰는데, 이건 예측 가능성이 있다. SonarQube는 java.util.Random 인스턴스 생성 자체를 S2245로 잡는다.
패턴 3. JavaScript Math.random() — 임시 ID 생성
// adminStore.js (Vue.js SPA) — 취약 코드
function generateTempId() {
// S2245 잡힘
return 'tmp_' + Math.random().toString(36).substring(2, 11);
}
function generateUploadKey() {
// S2245 잡힘
return Date.now() + '_' + Math.floor(Math.random() * 99999);
}
JavaScript의 Math.random()도 PRNG다. 브라우저마다 구현이 다르지만 대부분 xorshift128+ 알고리즘 계열이다. 예측 불가능성을 보장하지 않기 때문에 보안 목적으로 쓰면 안 된다. SonarQube Insecure Random은 언어에 관계없이 같은 원칙으로 잡는다.
시도 1. seed 개선 — 실패
처음에 든 생각은 “seed를 예측하기 어렵게 만들면 되지 않나”였다. System.currentTimeMillis()나 현재 시각의 나노초를 seed로 넣으면 어떻게 될지 해봤다.
// 시도 1 — seed 개선 (실패)
public class PasswordResetService {
public String generateResetToken() {
// seed를 예측 어렵게 만들어봤다
Random rand = new Random(System.currentTimeMillis() ^ Thread.currentThread().getId());
long raw = rand.nextLong() & Long.MAX_VALUE;
return Long.toHexString(raw);
}
}
결과는 그대로 S2245 유지였다. 당연한 얘기인데, seed를 아무리 바꿔도 java.util.Random 자체가 PRNG라는 사실은 안 바뀐다. SonarQube는 seed 값을 보는 게 아니라 어떤 클래스를 쓰는지를 본다. new Random(seed)이든 Math.random()이든 SonarQube Insecure Random 판정에서 동일하게 잡힌다.
솔직히 이 시도에 30분 날렸다. “seed를 더 복잡하게” 방향으로 계속 파다가 결국 SonarQube 룰 설명을 다시 읽었다. CWE-338은 “Use of Cryptographically Weak Pseudo-Random Number Generator”다. 생성기 자체를 바꿔야 하는 문제였다.
시도 2. new Random(SecureRandom seed) — 부분 통과
그다음 시도는 SecureRandom에서 seed를 뽑아 Random에 주입하는 방식이었다. 어딘가 블로그에서 이 패턴을 본 것 같았다.
// 시도 2 — SecureRandom seed 주입 (부분 통과)
import java.security.SecureRandom;
import java.util.Random;
public class PasswordResetService {
private static final SecureRandom SECURE = new SecureRandom();
public String generateResetToken() {
// SecureRandom에서 seed를 가져와 Random에 주입
long seed = SECURE.nextLong();
Random rand = new Random(seed);
long raw = rand.nextLong() & Long.MAX_VALUE;
return Long.toHexString(raw);
}
}
이건 부분 통과였다. new Random(seed) 라인에서는 S2245가 사라졌는데, 문제는 실제로 안전하지 않다는 거였다. SecureRandom으로 seed를 줬어도 이후 값을 뽑는 건 java.util.Random이 한다. seed가 노출되는 순간 이후 모든 값이 예측 가능해진다. PRNG 본질이 그대로다.
더 큰 문제는 코드 리뷰 때 지적됐다. “형식적으로 룰은 통과됐지만 실질적으로는 여전히 PRNG를 쓰는 거잖아요”라는 말이 나왔고, 다시 접근해야 했다. Insecure Cookie(S2092/S3330) 때도 비슷하게 겉만 고치고 본질을 놓쳤던 경험이 있어서 이번엔 방향을 바꿨다.
시도 3. SecureRandom 단독 사용 / crypto API — 최종
결론부터 말하면 java.util.Random을 완전히 제거하고 SecureRandom만 쓰는 게 답이다. JavaScript 쪽은 Web Crypto API(crypto.randomUUID(), crypto.getRandomValues())로 대체한다.
Java — SecureRandom 전환
SecureRandom에는 두 가지 방식이 있다. 차이를 알고 쓰는 게 중요하다.
new SecureRandom()— OS 제공 CSPRNG를 기반으로 초기화. 일반적인 용도에서 충분히 안전하다. 첫 호출 시 blocking 없이 초기화된다.SecureRandom.getInstanceStrong()— JDK 8+에서 사용 가능. 플랫폼이 제공하는 가장 강력한 알고리즘을 선택한다. Linux에서는/dev/random을 쓰기 때문에 엔트로피가 부족하면 blocking될 수 있다. 서버 환경에서 대용량 토큰을 생성하면 성능 이슈가 생길 수 있다.
비밀번호 reset 토큰처럼 보안이 중요한 곳은 SecureRandom.getInstanceStrong()을 쓰고, 일반 세션 패딩처럼 빈번하게 호출되는 곳은 new SecureRandom()을 인스턴스 재사용 방식으로 쓰는 게 현실적이다.
// PasswordResetService.java — 최종 해결
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HexFormat;
public class PasswordResetService {
// SecureRandom은 thread-safe — static 재사용 OK
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* 비밀번호 reset 토큰 — 32바이트(256비트) CSPRNG
* getInstanceStrong()은 blocking 가능성이 있어 인스턴스 재사용 방식으로 처리
*/
public String generateResetToken() {
byte[] tokenBytes = new byte[32];
SECURE_RANDOM.nextBytes(tokenBytes);
return HexFormat.of().formatHex(tokenBytes); // 64자 hex 문자열
}
/**
* 6자리 숫자 OTP — 인증 코드처럼 짧아야 하는 경우
*/
public String generateOtp() {
// nextInt(bound)도 SecureRandom에서 쓰면 S2245 통과
int otp = SECURE_RANDOM.nextInt(1_000_000);
return String.format("%06d", otp);
}
}
// SessionHelper.java — 최종 해결
import java.security.SecureRandom;
public class SessionHelper {
// new SecureRandom() — getInstanceStrong()보다 빠름, 일반 세션용으로 충분
private static final SecureRandom SECURE = new SecureRandom();
public static String buildSessionSuffix() {
byte[] bytes = new byte[6];
SECURE.nextBytes(bytes);
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
// a-z 26자 중 균등 분포로 변환
sb.append((char)('a' + (Math.abs(b) % 26)));
}
return sb.toString();
}
}
핵심은 SecureRandom을 매번 new로 생성하지 않는 것이다. SecureRandom은 초기화 비용이 크다. static final로 인스턴스를 하나 만들어두고 재사용한다. SecureRandom은 thread-safe로 설계돼 있기 때문에 동시 호출에도 문제없다.
Java — getInstanceStrong() 사용 시나리오
꼭 getInstanceStrong()이 필요한 경우라면 blocking 문제를 우회하는 방법이 있다.
// getInstanceStrong() 사용 — 초기화 시 한 번만 호출
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
public class CryptoUtil {
private static final SecureRandom STRONG_RANDOM;
static {
try {
// 애플리케이션 시작 시 한 번 초기화 — 이후 nextBytes()는 빠름
STRONG_RANDOM = SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
// JDK 11+에서는 사실상 발생 안 함 (NativePRNGBlocking 등 항상 존재)
throw new IllegalStateException("강력 SecureRandom 초기화 실패", e);
}
}
public static String strongToken(int byteLength) {
byte[] buf = new byte[byteLength];
STRONG_RANDOM.nextBytes(buf);
return HexFormat.of().formatHex(buf);
}
}
Linux 프로덕션 환경에서 엔트로피 고갈 우려가 있다면 JVM 옵션을 조정할 수 있다. -Djava.security.egd=file:/dev/./urandom을 JVM 시작 인자로 넣으면 /dev/urandom(non-blocking)을 기본으로 쓴다. 보안성은 /dev/random과 실질적으로 동등하다는 게 Linux 커널 문서의 설명이다.
JavaScript — Web Crypto API로 대체
브라우저와 Node.js 양쪽에서 Math.random() 대신 쓸 수 있는 안전한 API가 있다.
// adminStore.js — 최종 해결 (브라우저 / Node.js 18+)
// 방법 1: crypto.randomUUID() — UUID v4 형식의 128비트 랜덤값
// 브라우저 및 Node.js 14.17+ 지원
function generateTempId() {
return 'tmp_' + crypto.randomUUID().replace(/-/g, '').substring(0, 12);
}
// 방법 2: crypto.getRandomValues() — 임의 길이 바이트 배열
function generateUploadKey() {
const arr = new Uint8Array(8);
crypto.getRandomValues(arr);
// Uint8Array를 hex 문자열로 변환
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
// 방법 3: Node.js crypto 모듈 — 서버 사이드 (Node.js 전용)
// import { randomBytes, randomUUID } from 'crypto'; (ESM)
// const { randomBytes } = require('crypto'); (CJS)
function generateServerToken() {
const { randomBytes } = require('crypto');
return randomBytes(32).toString('hex'); // 64자 hex 문자열
}
crypto.randomUUID()는 RFC 4122 UUID v4를 반환한다. 내부적으로 CSPRNG를 쓴다. 브라우저에서는 window.crypto.randomUUID(), Node.js 14.17+에서는 globalThis.crypto.randomUUID()로 접근한다. 별도 임포트가 필요 없다.
Node.js 서버 사이드에서 토큰을 직접 만들어야 한다면 crypto.randomBytes(32).toString("hex")가 가장 명확하다. 32바이트(256비트)면 보안 토큰으로 충분하다. 같은 모듈에서 CSRF 토큰이나 nonce 생성도 같은 API로 처리하면 일관된다. DOM XSS(S5696) 대응 때 인라인 스크립트에 nonce를 박는 구조라면 그 nonce도 결국 이 randomBytes로 만든다.
최종 확인 — SonarQube Insecure Random 통과 기준
S2245는 Security Hotspot이라 “Reviewed” 상태로 바꿔야 빌드에서 제거된다. 코드를 바꾼 뒤 SonarQube에서 Hotspot을 “Safe” 또는 “Fixed”로 마킹해야 한다. 아래 패턴들이 SonarQube Insecure Random 통과 처리된다:
- Java:
SecureRandom인스턴스로 생성한 난수 (Math.random(), java.util.Random 사용 금지) - JavaScript/TypeScript:
crypto.randomUUID(),crypto.getRandomValues(),crypto.randomBytes()(Node.js) - 둘 다: 보안 목적이 아닌 일반 난수(예: 게임 주사위, UI 애니메이션 딜레이)는 Hotspot을 “Acknowledged”로 마킹 후 주석 처리도 가능
정리
SonarQube Insecure Random(S2245)의 본질은 PRNG(Pseudo-Random Number Generator)와 CSPRNG(Cryptographically Secure PRNG)의 차이다. Math.random()과 new Random()은 빠르지만 예측 가능하고, 보안 토큰·세션 ID·OTP처럼 추측이 불가능해야 하는 값에 쓰면 안 된다. seed를 예측하기 어렵게 만들거나, SecureRandom seed를 Random에 주입하는 방식은 SonarQube Insecure Random이 잡는 PRNG 본질을 바꾸지 못한다.
Java에서는 new SecureRandom()을 static final로 선언해 재사용하면 된다. getInstanceStrong()은 blocking 가능성이 있으니 초기화를 앱 시작 시 한 번으로 제한한다. JavaScript에서는 crypto.randomUUID()나 crypto.getRandomValues()로 교체하는 게 가장 빠르다. Node.js 백엔드라면 crypto.randomBytes(32).toString("hex")가 표준이다.
이전 편 #49 SonarQube Deserialization(S5135)에서 ObjectInputStream 대체 작업을 했다면 이번 SonarQube Insecure Random은 비교적 간단한 편이다. 다음 편은 SonarQube ReDoS(S5852) — 입력 검증 정규식에서 백트래킹이 걸려 API 응답이 수십 초 지연된 케이스다.
룰 본문은 SonarSource 공식 룰 페이지(S2245)에서 확인할 수 있다.