Skip to content
khiopost
Go back

SonarQube SQL Injection 해결 — 레거시 JDK 21에서 만난 S3649 시도 3가지

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

📑 목차

  1. 레거시에서 발견된 취약 패턴
  2. 시도 1. 작은따옴표 escape
  3. 시도 2. 정규식 화이트리스트
  4. 시도 3. PreparedStatement 부분 적용
  5. 최종 해결 — #{} + enum 화이트리스트
  6. 정리

금융권 SI 현장에서 JDK 8 / 11짜리 레거시를 JDK 21로 올리는 일을 맡았다. 로직은 일절 건드리지 않는다는 조건이었는데, 빌드 통과시키고 나니 SonarQube SQL Injection 룰 S3649만 87건이 떴다. 한 줄도 안 고쳤는데 말이다.

이유는 단순했다. JDK 업그레이드와 함께 사내 SonarQube Quality Profile도 최신으로 갈렸고, 그동안 비활성화돼 있던 보안 룰들이 한꺼번에 켜졌다. 그중 제일 먼저 손볼 수밖에 없었던 게 SQL Injection이다. 이 글은 SonarQube SQL Injection 룰 S3649를 실제로 통과시키기까지 시도했던 3가지 방법과 최종 해결 코드를 정리한 기록이다.

SonarQube SQL Injection — IDE 정적 분석 경고 화면

레거시에서 발견된 취약 패턴

87건을 분류해 보니 두 패턴이 거의 전부였다.

패턴 1. MyBatis ${}

<!-- AccountMapper.xml -->
<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = '${acctNo}'
   ORDER BY ${sortCol} ${sortDir}
</select>

패턴 2. JDBC Statement + 문자열 연결

String sql =
    "SELECT CUST_ID, CUST_NM FROM CUSTOMER " +
    "WHERE CUST_NM LIKE '%" + name + "%'";
ResultSet rs = stmt.executeQuery(sql);

${}는 MyBatis가 값을 그대로 SQL에 박아넣는 방식이고, Statement + 문자열 연결도 똑같이 위험하다. 외부 입력이 그대로 SQL이 되니 SonarQube SQL Injection 룰 입장에서는 잡지 않을 이유가 없다.

시도 1. 작은따옴표 escape — 실패

제일 먼저 떠올린 게 입력값에서 '''로 바꿔주는 sanitize였다.

String safeName = name == null ? "" : name.replace("'", "''");
String sql = "... WHERE CUST_NM LIKE '%" + safeName + "%'";

S3649는 그대로 떠 있다. SonarQube SQL Injection 분석기는 입력값이 SQL 문자열에 직접 합쳐지는 데이터 플로우 자체를 추적한다. 중간에 replaceAll이 끼어들어도 알려진 sanitizer가 아니면 인정해주지 않는다. 게다가 숫자 컨텍스트(WHERE ID = ${id})에서는 작은따옴표 escape 자체가 의미가 없다.

시도 2. 정규식 화이트리스트 — 부분 실패

다음 시도는 입력값 패턴 검증이다.

private static final Pattern ACCT = Pattern.compile("^[0-9-]{10,20}$");

public List<TxVO> findTx(String acctNo, String sortCol) {
    if (!ACCT.matcher(acctNo).matches()) {
        throw new IllegalArgumentException("invalid account");
    }
    return mapper.findTxByAcct(acctNo, sortCol);
}

값 자체를 검증하니 일부 케이스는 사라졌다. 그러나 동적 정렬 컬럼(ORDER BY ${sortCol})은 여전히 잡힌다. SonarQube는 ${}로 들어가는 흐름이 있으면 의심을 거두지 않는다. 룰이 보는 건 결국 PreparedStatement 또는 그에 준하는 바인딩이다.

시도 3. PreparedStatement 부분 적용 — 부분 통과

WHERE 절만 바인딩하고 동적 정렬은 그대로 둔 케이스다.

<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = #{acctNo}
   ORDER BY ${sortCol} ${sortDir}
</select>

WHERE는 통과했다. 그런데 ORDER BY 줄에서 또 S3649가 뜬다. ${}로 컬럼명을 받으면 SonarQube는 무조건 잡는다. 동적 식별자라는 사실을 코드에서 증명해줘야 한다.

최종 해결 — #{} + enum 화이트리스트

세 단계 조합으로 87건을 전부 해소했다. 핵심은 외부 입력이 절대 SQL 문자열로 직접 들어가지 않게 하는 것이다.

1) 모든 단순 값은 #{} 또는 PreparedStatement

<select id="findTxByAcct" resultType="TxVO">
  SELECT TX_DT, AMT, REMARK
    FROM ACCT_TX
   WHERE ACCT_NO = #{acctNo}
   ORDER BY ${sortColumn} ${sortDirection}
</select>

JDBC 쪽도 동일하게 처리한다.

String sql =
    "SELECT CUST_ID, CUST_NM FROM CUSTOMER " +
    "WHERE CUST_NM LIKE CONCAT('%', ?, '%')";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    ps.setString(1, name);
    ResultSet rs = ps.executeQuery();
}

2) 동적 컬럼/정렬은 enum으로 매핑

식별자는 외부 입력을 그대로 받지 않고 enum으로 한 번 가둔다.

public enum TxSortColumn {
    TX_DT("TX_DT"),
    AMT("AMT"),
    REMARK("REMARK");

    private final String column;
    TxSortColumn(String column) { this.column = column; }
    public String column() { return column; }
}

public enum SortDir {
    ASC, DESC;
    public static SortDir from(String s) {
        return "DESC".equalsIgnoreCase(s) ? DESC : ASC;
    }
}
// Service
public List<TxVO> findTx(String acctNo, String sortColParam, String sortDirParam) {
    TxSortColumn col = TxSortColumn.valueOf(sortColParam); // 미허용 값이면 IllegalArgumentException
    SortDir dir = SortDir.from(sortDirParam);
    return mapper.findTxByAcct(acctNo, col.column(), dir.name());
}

Mapper에 들어가는 sortColumn, sortDirection은 enum에서 꺼낸 값이라 외부 입력이 닿지 않는다. SonarQube는 데이터 플로우를 보기 때문에 enum 상수에서 출발한 값이면 ${}여도 통과시킨다.

3) LIKE 검색은 CONCAT 안에서 #{}

<select id="searchByName" resultType="CustVO">
  SELECT CUST_ID, CUST_NM
    FROM CUSTOMER
   WHERE CUST_NM LIKE CONCAT('%', #{name}, '%')
</select>

레거시에서 '%${name}%' 패턴을 자주 보는데, CONCAT 안에 #{}를 넣으면 바인딩 변수로 처리되면서도 LIKE 검색이 정상 동작한다.

자주 놓치는 추가 케이스

IN 절 동적 파라미터

레거시에서 IN 절 처리를 문자열로 합쳐 넣는 패턴이 많다. S3649가 자주 잡히는 또 다른 지점이다.

<!-- 잘못된 패턴 -->
<select id="findByIds">
  SELECT * FROM ACCOUNT WHERE ACCT_ID IN (${ids})
</select>

이 경우 MyBatis foreach로 대체한다.

<select id="findByIds" parameterType="list">
  SELECT * FROM ACCOUNT
   WHERE ACCT_ID IN
  <foreach collection="list" item="id" open="(" separator="," close=")">
    #{id}
  </foreach>
</select>

컬렉션 파라미터를 #{}로 바인딩하니 S3649가 사라진다. 빈 컬렉션이 오면 IN 절이 빈 괄호가 되어 SQL 에러가 나는 문제는 <if test="list != null and list.size() > 0">로 감싸서 처리한다.

동적 테이블명 패턴

분기별·월별 테이블을 나눈 파티셔닝 구조에서 테이블명 자체를 외부 파라미터로 받는 경우가 있다.

<!-- 위험한 패턴 -->
SELECT * FROM ${tableName} WHERE ...

테이블명은 바인딩 변수로 쓸 수 없으므로 enum으로 가두는 게 유일한 방법이다.

public enum PartitionTable {
    TX_2024Q1("ACCT_TX_2024Q1"),
    TX_2024Q2("ACCT_TX_2024Q2"),
    TX_2024Q3("ACCT_TX_2024Q3"),
    TX_2024Q4("ACCT_TX_2024Q4");

    private final String tableName;
    PartitionTable(String t) { this.tableName = t; }
    public String tableName() { return tableName; }
}

서비스에서 파라미터를 enum으로 먼저 변환하고, enum의 tableName() 결과를 Mapper에 넘긴다. SonarQube는 enum 상수에서 출발한 값을 외부 입력으로 보지 않아 통과한다.

JPA Native Query 주의

레거시를 JPA로 부분 전환하는 과정에서 native query를 쓸 때도 같은 함정이 있다.

// 위험
@Query(value = "SELECT * FROM CUSTOMER WHERE NAME LIKE '%" + name + "%'", nativeQuery = true)

// 안전
@Query(value = "SELECT * FROM CUSTOMER WHERE NAME LIKE CONCAT('%', :name, '%')", nativeQuery = true)
List<Customer> searchByName(@Param("name") String name);

Spring Data JPA의 named parameter(:name)는 PreparedStatement 바인딩으로 처리된다. 문자열 연산으로 쿼리를 조립하면 S3649가 잡힌다.

S3649 False Positive 처리

암호화된 컬럼명처럼 컬럼명 자체가 코드 상수인데 SonarQube가 외부 입력으로 오해하는 경우가 있다. 데이터 플로우 추적이 클래스 경계를 넘을 때 발생하는 사례다. 먼저 enum으로 가두는 리팩터링이 가능한지 검토하고, 정말 불가능한 경우에만 // NOSONAR를 달되 왜 안전한지 주석으로 명시한다.

// 암호화 컬럼명은 코드 상수에서 출발 — 외부 입력 없음
mapper.findByEncColumn(EncColumns.CUST_NM.column()); // NOSONAR

정리

SonarQube SQL Injection 대응의 본질은 결국 "값"과 "식별자"를 구분하는 거였다. 값은 #{} 또는 PreparedStatement, 식별자(컬럼명, 정렬 방향)는 enum 매핑. 어설픈 escape나 정규식만 추가하는 건 시간 낭비고, S3649는 데이터 플로우를 보기 때문에 sanitize 한 줄 추가한다고 사라지지 않는다.

다음 편에서는 같은 입력 검증 계열인 SonarQube Command Injection (S2076) 케이스를 다룬다. 외부 시스템 연동 배치에서 자주 터지는 룰이다.

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


Share this post on:

Previous Post
SonarQube Command Injection 잡는법 — 레거시 배치에서 만난 S2076 시도 3가지
Next Post
Jump Desktop으로 iPad에서 Mac 원격 접속 3가지 방법 비교