ORM Injection
■ 서론
ORM(Object-Relational Mapping)은 개발자가 SQL 쿼리를 직접 작성하지 않고도 객체와 메서드를 통해 데이터를 조회하거나 수정할 수 있게 해주는 기술이다. 이 때문에 ORM은 개발 편의성을 높여주는 도구로 널리 사용된다.
또한 ORM은 일반적으로 파라미터 바인딩 1을 통해 SQL 쿼리를 생성하므로, 많은 개발자들이 ORM을 사용하면 SQL Injection 문제에서도 안전하다고 생각한다.
하지만 ORM이 SQL Injection을 항상 막아주는 것은 아니다. ORM은 기본적인 조회, 생성, 수정, 삭제 기능 외에도 동적 조건 생성, 정렬, Raw Query, Expression API 등 다양한 기능을 제공한다. 이러한 기능을 부적절하게 사용할 경우, ORM 환경 특유의 Injection 취약점이 발생할 수 있다.
본 보고서에서는 ORM을 사용했음에도 SQL Injection이 발생하는 원인에 대해 상세히 살펴본다.
■ ORM
Step 1. ORM 이란?
ORM은 객체 지향 언어의 객체와 관계형 데이터베이스의 테이블 사이를 연결해 주는 기술이다. 애플리케이션은 객체를 중심으로 동작하지만, 데이터베이스는 테이블과 행을 중심으로 데이터를 저장한다. ORM은 이 차이를 중간에서 맞춰 주어, 개발자가 데이터베이스 작업을 객체 중심으로 다룰 수 있게 해준다.
그림 1. ORM 작동 원리
예를 들어 JDBC를 사용하면 개발자가 SQL 쿼리를 직접 작성하고, 실행 결과를 객체로 변환하는 과정까지 직접 처리해야 한다. JDBC에서도 PreparedStatement 2를 사용하면 사용자 입력값을 안전하게 전달할 수 있지만, SQL 작성, 파라미터 설정, 결과 매핑과 같은 반복 코드가 많아질 수 있다.
아래 코드는 사용자 입력값을 SQL 문자열에 직접 이어 붙이지 않고, PreparedStatement를 사용하는 방식이다.
String sql = "SELECT id, name, email FROM users WHERE name = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, userInput);
ResultSet rs = stmt.executeQuery();
User user = null;
if (rs.next()) {
user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setEmail(rs.getString("email"));
}
이후 stmt.setString()과 같은 메서드를 통해 ? 위치에 파라미터 바인딩을 한다. 파라미터 바인딩을 사용하면 사용자 입력값은 SQL 문법이 아니라 단순 데이터 값으로 처리된다. 따라서 입력값이 SQL 쿼리 구조를 변경하는 위험을 줄일 수 있다.
다만 JDBC에서는 개발자가 직접 조회 결과를 ResultSet에서 꺼내 User 객체에 넣어주는 작업을 거쳐야 한다. 컬럼이 많아지거나 여러 테이블을 함께 조회하는 경우에는 이러한 반복 코드가 늘어나고, 유지보수 부담도 커질 수 있다.
반면 Hibernate와 같은 ORM을 사용하면, 개발자는 ORM이 제공하는 API를 통해 데이터베이스를 객체 중심으로 다룰 수 있다. 아래 예시는 Hibernate의 Session 객체를 사용한 조회 코드이다.
Session session = sessionFactory.openSession();
User user = session
.createQuery("FROM User WHERE name = :name", User.class)
.setParameter("name", userInput)
.uniqueResult();
session.close();
위 코드에서도 사용자 입력값은 HQL 3 문자열에 직접 이어 붙지 않고, :name이라는 이름이 붙은 파라미터에 setParameter()를 통해 별도로 전달된다. ORM은 이 값을 쿼리 구조의 일부가 아니라 조건 비교에 사용할 데이터로 처리한다.
이처럼 ORM을 사용하면 조회 결과를 객체로 바로 받을 수 있어, JDBC에서처럼 ResultSet에서 각 컬럼 값을 꺼내 직접 객체에 넣는 반복 작업을 줄일 수 있다. 또한 데이터 접근 코드를 객체와 메서드 중심으로 작성할 수 있으므로, 개발자는 실제 기능 구현에 더 집중할 수 있다.
Step 2. 대표적인 ORM
각 환경의 대표적인 ORM들은 다음과 같다.
| 이름 | 정보 |
| Hibernate |
Java 환경에서 널리 사용되는 대표적인 ORM으로, 주로 Spring 기반 백엔드 개발에서 많이 사용된다. Java 객체와 데이터베이스 테이블을 연결해 주며, 기업용 웹 서비스나 업무 시스템 개발에서 널리 활용된다. |
| Django ORM |
Python 객체를 통해 데이터베이스를 다룰 수 있으며, SQL 쿼리를 직접 작성하지 않고도 CRUD 작업이 가능하다. |
| Entity Framework Core |
.NET 환경에서 널리 사용되는 대표적인 ORM으로, 주로 ASP.NET Core 기반 웹 애플리케이션에서 사용되며, C# 객체를 통해 데이터베이스 작업을 처리할 수 있도록 돕는다. |
| Sequelize |
Node.js 환경에서 널리 알려진 대표적인 ORM으로, Express·NestJS 같은 Node.js 백엔드에서 자주 사용되며, 여러 관계형 데이터베이스를 지원한다. |
| MikroORM |
Node.js 환경에서 사용되는 TypeScript 기반 ORM으로, NestJS 등과 잘 통합되며 객체와 데이터베이스 매핑을 지원한다. |
표 1. 대표적인 ORM
Step 3. ORM이 안전하다고 여겨지는 이유
많은 개발자들이 ORM을 사용하면 SQL Injection으로부터 비교적 안전하다고 생각하는 이유는, ORM이 일반적으로 파라미터 바인딩 방식을 사용하기 때문이다.
그림 2. 파라미터 바인딩 예시
사용자가 입력한 문자열이 그대로 SQL 쿼리에 삽입되면, 데이터베이스는 이를 단순한 값이 아니라 SQL 쿼리의 일부로 해석할 수 있다. 다음과 같이 OR '1'='1'과 같은 구문이 SQL 쿼리에 직접 들어가면, 원래 의도한 조건이 바뀌어 SQL Injection이 발생할 수 있다.
그림 3. 직접 문자열 연결 시 최종 실행 쿼리
반면 파라미터 바인딩 방식은 WHERE name = ? 와 같이 SQL 쿼리의 구조가 먼저 정해지고, 이후 사용자 입력은 ? 위치에 들어갈 값으로만 전달된다. 즉, 어떤 테이블을 조회할지, 어떤 컬럼을 비교할지, 어떤 조건식을 사용할지는 미리 결정되어 사용자가 admin' OR '1'='1'과 같은 문자열을 입력하더라도, 데이터베이스는 이를 새로운 SQL 조건식으로 해석하지 않는다. 이 때문에 ORM의 파라미터 바인딩은 일반적인 사용자 입력 처리에서 SQL Injection 위험을 줄일 수 있다.
그림 4. Hibernate ORM의 파라미터 바인딩으로 생성되는 최종 실행 쿼리
하지만 ORM을 사용하더라도 모든 입력이 항상 단순 데이터로 처리되는 것은 아니다. 정렬 기준, 컬럼명, 조건 연산자처럼 쿼리 구조에 직접 반영되는 값이 검증 없이 사용되면 Injection 취약점이 발생할 수 있다. 따라서 ORM 환경에서는 어떤 값이 데이터로 바인딩되는지, 어떤 값이 쿼리 구조에 반영되는지 구분해서 살펴볼 필요가 있다.
■ ORM Injection
Step 1. ORM Injection이란?
ORM Injection은 ORM을 사용하는 환경에서 발생하는 SQL Injection 계열 취약점이다. 앞서 살펴본 것처럼 ORM은 일반적인 값 입력에 대해서는 파라미터 바인딩을 통해 SQL Injection 위험을 줄인다.
그러나 ORM이 최종 SQL을 생성하는 과정에서 외부 입력이나 신뢰할 수 없는 데이터가 조건, 정렬, 필드, 연산자와 같은 쿼리 구조에 영향을 주면 ORM Injection이 발생할 수 있다. 이 경우, 개발자가 직접 SQL 문자열을 작성하지 않았음에도 SQL Injection 위협에 노출된다.
Step 2. 발생 원인
ORM은 대부분의 경우 파라미터 바인딩을 통해 사용자 입력을 안전하게 처리한다. 그러나 ORM이 제공하는 일부 기능은 입력값을 항상 단순 데이터로만 처리하지 않는다. 특정 기능의 설계나 구현에 문제가 있으면, 정상적인 사용 흐름 안에서도 입력값이나 데이터가 안전하게 처리되지 않고 SQL 쿼리 생성에 직접 영향을 줄 수 있다.
즉, ORM Injection은 단순히 개발자의 잘못된 ORM 사용으로만 발생하는 문제가 아니라, ORM 자체의 기능이나 구현 방식에 따라 발생할 수도 있다. 다음 장에서는 ORM에서 발생한 실제 취약점 사례를 통해 이를 구체적으로 살펴본다.
■ 사례 분석
Step 1. CVE-2026-30951
1. CVE 개요
CVE-2026-30951은 Node.js ORM인 Sequelize에서 JSON/JSONB 4 컬럼의 동적 필터링 처리 중 발생하는 SQL Injection 취약점이다.
Sequelize는 JSON 데이터를 다룰 때 :: 문법을 통해 JSONPath 5 기반 조회와 타입 캐스팅 6(CAST)을 함께 처리하는 기능을 제공한다. 그러나 취약 버전에서는 이 과정에서 사용자가 입력한 키(Key) 값에 포함된 CAST 타입 정보를 충분히 검증하지 않는 문제가 존재한다.
공격자는 이를 악용하여 키 값에 임의의 SQL 쿼리를 삽입함으로써, 의도하지 않은 쿼리를 실행시키고 전체 데이터 조회 등 권한을 초과하는 데이터 접근을 수행할 수 있다.
2. 영향받는 소프트웨어 버전
| S/W 구분 | 취약 버전 |
Sequelize |
version <= 6.37.7 |
표 2. CVE-2026-30951 영향 받는 소프트웨어 버전
3. 공격 시나리오
① 공격자는 Sequelize 기반 서비스에서 JSON 컬럼 검색 기능을 확인한다.
② 공격자는 검색 조건의 필드명에 SQL Injection 구문을 삽입해 요청을 보낸다.
③ 서버는 해당 필드명을 JSONPath와 CAST 타입 정보로 처리해 SQL WHERE 절을 생성한다.
④ 생성된 SQL 쿼리가 실행되어 대상 테이블의 전체 데이터를 조회한다.
4. 테스트 환경 구성 정보
| 이름 | 정보 |
피해자 |
Ubuntu 22.04 & Node.js Express 4.21.2 & Sequelize 6.37.7 |
공격자 |
Kali Linux |
표 3. CVE-2026-30951 테스트 환경 구성
5. 취약점 테스트
취약점 테스트를 위한 정보는 아래 EQSTLab GitHub Repository에서 확인할 수 있다.
• URL: https://github.com/EQSTLab/CVE-2026-30951
그림 5. 웹페이지 접근 시 화면
그림 6. 정상 검색 요청 및 응답
공격자는 취약한 웹페이지의 검색 기능을 이용하여 검색 요청을 변조한다. 변조된 요청은 아래와 같다.
그림 7. 변조한 검색 요청 및 응답
그 결과, SQL Injection이 발생하며 테이블 내 전체 사용자 정보가 조회되는 것을 확인할 수 있다.
그림 8. 결과 - 전체 사용자 조회 화면
6. 취약점 상세 분석
CVE-2026-30951의 핵심은 Sequelize가 JSON 컬럼 검색 조건의 필드명을 단순 문자열이 아니라 SQL 조건 생성을 위한 JSONPath 및 CAST 타입 정보로 해석한다는 점이다. 취약 버전에서는 이 중 CAST 타입 문자열에 대한 검증이 미흡하여, 공격자가 필드명에 삽입한 SQL 쿼리가 최종 WHERE 절에 반영될 수 있다.
1) JSON 검색 조건이 WHERE 조건으로 변환되는 구조
현재 검색 API는 사용자가 요청 본문에 포함한 filter 객체를 Sequelize의 where 조건으로 전달한다.
그림 9. 사용자 입력 filter가 Sequelize WHERE 조건으로 전달되는 지점
filter 객체는 사용자가 검색 기준을 전달하기 위한 JSON 객체이다. 아래와 같이 filter에 name 조건이 포함되면, 서버는 이를 바탕으로 metadata JSON 컬럼 내부의 name 값이 emma와 일치하는 레코드를 조회한다.
그림 10. emma 검색 시 요청 및 응답
여기서 name은 JSON 형식이 저장된 컬럼에서 어떤 항목을 찾을지 나타내고, emma는 그 항목과 비교할 값이다.
즉, filter 객체의 필드명은 단순한 이름이 아니라, JSON 컬럼 내부에서 조회할 위치를 정하는 입력으로 사용된다. Sequelize는 이 값을 바탕으로 JSONPath를 구성해 JSON 컬럼 안의 특정 항목에 접근한다.
따라서 공격자가 조작된 문자열을 필드명으로 전달하면, 이후 JSONPath 처리 및 SQL 쿼리 생성 과정에 영향을 줄 수 있다.
2) JSONPath와 CAST 타입 분리
Sequelize는 JSON 컬럼 값을 비교할 때 :: 문법을 통해 JSONPath 조회와 타입 캐스팅을 함께 처리할 수 있다. 다음과 같이 요청 본문이 전달되면 name은 JSONPath의 일부로, TEXT는 CAST 타입으로 해석된다.
그림 11. CAST 타입이 함께 포함된 검색 요청
이 요청은 metadata JSON 컬럼의 name 값을 추출한 뒤, 이를 TEXT 타입으로 변환하여 emma와 비교하는 조건을 생성한다.
생성되는 WHERE 조건의 핵심 구조는 다음과 같다.
|
WHERE CAST(json_extract("users"."metadata", '$.name') AS TEXT) = 'emma' |
그림 12. 요청 JSON의 필드명과 값이 WHERE 조건으로 변환되는 구조
여기서 CAST()는 SQL에서 값을 특정 데이터 타입으로 변환할 때 사용하는 함수이다.
CAST([값 또는 컬럼] AS [타입]) |
JSON 컬럼에서 추출한 값은 DBMS나 연산 방식에 따라 문자열, 숫자, JSON 값 등으로 다르게 취급될 수 있다. Sequelize는 JSON 값 비교 시 타입을 명확히 하기 위해 추출된 값을 TEXT와 같은 타입으로 변환한 뒤 비교할 수 있다.
문제는 CAST(... AS [타입])의 [타입] 위치가 SQL 문법의 일부라는 점이다. 따라서 이 위치에 정상적인 타입명 대신 SQL 쿼리가 삽입되면, 단순 검색값이 아니라 WHERE 절의 구조 자체가 변경될 수 있다.
3) Sequelize 내부의 JSON 조건 처리 흐름
Sequelize 내부의 _whereJSON()은 사용자 입력 객체를 순회하며 각 필드명을 JSONPath로 처리하고, 이를 _traverseJSON()으로 전달한다.
그림 13. _whereJSON()에서 _traverseJSON() 호출
| 구분 | 값 | 의미 |
JSONPath |
name |
metadata.name 값을 조회 |
구분자 |
:: |
경로와 CAST 타입을 구분 |
CAST 타입 |
text |
추출한 값을 문자열 타입으로 변환 |
표 4. JSONPath 및 CAST 구문 구성 요소
그림 14. traverseJSON() - JSONPath와 CAST 타입 분리 로직
이후 JSONPath는 jsonPathExtractionQuery()를 통해 JSON 값을 추출하는 SQL 쿼리로 변환되고, CAST 타입은 _castKey()를 통해 해당 표현식에 적용된다.
그림 15. traverseJSON() 2 - JSONPath 추출 결과에 CAST 타입을 적용하는 로직
이 동작 자체는 JSON 값 비교 시 타입을 맞추기 위한 정상 기능이다. 그러나 취약 버전에서는 :: 뒤에서 분리된 CAST 타입 문자열이 허용된 타입인지 충분히 검증되지 않아, SQL 쿼리 삽입 지점으로 악용될 수 있다.
4) CAST 타입 위치를 이용한 WHERE 절 변조
_castKey()는 JSONPath 추출 결과와 CAST 타입을 이용해 SQL의 CAST(... AS ...) 표현식을 생성한다.
그림 16. castKey() 함수
정상적인 CAST 조건에서는 :: 뒤에 TEXT, INTEGER, BOOLEAN과 같은 자료형이 들어간다. 하지만 공격자는 :: 뒤의 CAST 타입 위치에 SQL 쿼리를 삽입할 수 있다.
그림 17. CAST 타입 위치에 SQL 쿼리를 삽입한 변조 요청
이 경우 Sequelize는 name을 JSONPath로, text) or 1=1-- 부분을 CAST 타입 문자열로 해석한다. 그 결과 최종 WHERE 절의 핵심 구조는 다음과 같이 변조된다.
|
WHERE CAST(json_extract("users"."metadata", '$.name') AS text) OR 1=1-- ... |
실제 생성 SQL은 DBMS와 Sequelize 문법 차이에 따라 일부 차이가 있을 수 있으나, 핵심은 CAST 타입 위치에 삽입된 OR 1=1-- 구문이 WHERE 절의 논리 구조를 변경한다는 점이다.
OR 1=1 조건은 항상 참이며, -- 이후의 구문은 SQL 주석으로 처리된다. 따라서 원래는 특정 이름과 일치하는 사용자만 조회되어야 하지만, 변조된 WHERE 절에서는 조건이 항상 참이 되어 테이블 내 전체 사용자 정보가 반환될 수 있다.
결과적으로 CVE-2026-30951은 JSON 조건 필드명에 포함된 CAST 타입 문자열을 검증하지 않아, 공격자가 WHERE 절의 논리 구조를 조작할 수 있는 SQL Injection 취약점이다.
7. 대응 방안
1) 보안 패치 적용
2026년 3월, Sequelize 개발팀은 CVE-2026-30951 취약점에 대한 보안 패치를 공개했다. 해당 패치에서는 JSONPath 처리 과정에서 분리된 CAST 값이 SQL 쿼리로 재해석되지 않도록, _traverseJSON() 단계에 _validateCastType() 검증 로직을 추가하였다.
이 검증은 TEXT, INTEGER, BOOLEAN 등 사전에 정의된 CAST 타입만 허용하는 화이트리스트 방식으로 동작한다. 이를 통해 허용되지 않은 CAST 타입 문자열이 SQL 생성 과정에 포함되는 것을 차단하고, CAST 타입 위치를 이용한 SQL Injection을 방지할 수 있다.
그림 18. CAST 타입 검증 추가
그림 19. 허용된 CAST 타입 리스트
2) 애플리케이션 레벨에서 검색 필드 제한
보안 패치를 즉시 적용하기 어려운 경우에는, 애플리케이션 차원에서 사용자가 입력한 문자열을 JSONPath나 CAST 표현식에 그대로 반영하지 않도록 해야 한다. 또 검색 조건으로 사용할 수 있는 필드는 서버 내부에서 미리 정의하고, 사용자는 그중 하나를 선택하도록 제한해야 한다.
Step 2. CVE-2026-0603
1. CVE 개요
CVE-2026-0603은 Java 웹 애플리케이션에서 널리 사용되는 Hibernate ORM에서 발견된 ORM Injection 취약점이다. Hibernate는 개발자가 SQL을 직접 작성하지 않아도 Java 객체와 DB를 자동으로 연결해 주는 ORM 프레임워크이다.
본 ORM Injection 취약점은 Second-Order SQL Injection 유형에 해당한다. Second-Order SQL Injection은 악성 입력이 즉시 실행되지 않고 일단 저장된 뒤, 이후 다른 쿼리에서 다시 사용되는 시점에 실행되는 SQL Injection을 의미한다. 취약 버전의 Hibernate는 여러 테이블에 걸친 데이터를 한 번에 수정/삭제할 때, 대상을 식별하기 위한 기본키 값을 SQL 쿼리에 문자열로 이어 붙인다. 이때, 기본키에 악성 SQL 쿼리가 포함되어 있으면, 해당 값이 저장된 뒤 후속 수정/삭제 과정에서 다시 사용되면서 SQL Injection이 발생할 수 있었다.
즉, 공격자는 먼저 악성 SQL 쿼리가 포함된 값을 기본키로 저장해두고, 이후 자신의 데이터에 대한 수정/삭제 요청을 수행하는 것만으로 저장해둔 악성 SQL 쿼리를 실행시킬 수 있다. 이를 통해 공격자는 자신의 권한 범위를 넘어 다른 사용자의 데이터 삭제 및 변조, DB 내 민감한 정보 탈취 등의 피해를 유발할 수 있다.
2. 영향받는 소프트웨어 버전
| S/W 구분 | 취약 버전 |
Hibernate ORM |
5.2.8 <= version <= 5.6.15 |
표 5. CVE-2026-0603 영향 받는 소프트웨어 버전
3. 공격 시나리오
4. 테스트 환경 구성 정보
| 이름 | 정보 |
피해자 |
ubuntu:22.04 & Hibernate ORM 5.6.15 |
공격자 |
Kali Linux |
표 6. CVE-2026-0603 테스트 환경 구성
5. 취약점 테스트
취약점 테스트를 위한 정보는 아래 EQSTLab GitHub Repository에서 확인할 수 있다.
• URL: https://github.com/EQSTLab/CVE-2026-0603
취약한 Hibernate ORM을 사용하는 회원 목록 페이지를 확인한다. 사용자는 회원가입, 개인정보 수정, 탈퇴가 가능하다.
그림 20. 취약한 Hibernate ORM을 사용하는 회원 관리 페이지
회원가입 페이지에서 사용자 이름에 악성 SQL 쿼리를 삽입한다.
그림 21. 악성 SQL 쿼리를 삽입하여 회원가입
그림 22. 악성 SQL 쿼리를 삽입하여 회원가입 완료
정보 변경 버튼을 통해 회원 정보를 수정한다.
그림 23. 회원 정보 변경
회원 정보 수정 시 저장된 악성 SQL 쿼리에 의해, 같은 테이블에 존재하는 모든 사용자들의 개인정보가 변경된다.
그림 24. 모든 사용자의 정보가 변경된 모습
탈퇴 버튼을 누를 경우, 동일한 이유로 모든 사용자의 정보가 삭제되며 탈퇴 처리된다.
그림 25. 모든 사용자가 탈퇴된 모습
6. 취약점 상세 분석
1) 취약점 발생 조건
취약 버전의 Hibernate를 사용하더라도 다음 조건을 모두 충족하는 경우에만 취약점이 발생한다.
① InlineIdsOrClauseBulkIdStrategy 방식을 사용하도록 설정한 경우
원래 Hibernate는 수정 또는 삭제에 사용할 기본키 값을 임시 테이블에 저장한 뒤, 이를 참조하여 쿼리를 생성할 수 있다. 그러나 애플리케이션이 사용하는 DB 계정에 임시 테이블 생성 권한이 없거나, 운영 환경상 임시 테이블 방식을 쓰기 어려운 경우에는 대안이 필요하다. 이때 사용할 수 있는 방식이 InlineIdsOrClauseBulkIdStrategy이다. 이 설정을 사용하면 기본키 값을 임시 테이블에 저장하지 않고 SQL 조건문에 직접 넣어 처리하게 된다.
② 하나의 데이터가 여러 테이블에 나누어 저장되는 구조인 경우
다음은 회원 한 명의 정보가 기본 정보와 상세 정보로 분리되어 각각 다른 테이블에 저장되는 예시이다.
그림 26. 사용자 정보가 기본 정보 테이블과 상세 정보 테이블로 분리 저장된 구조
이러한 구조에서는 데이터의 수정/삭제 시 하나의 테이블만 처리하는 것이 아니라, 관련된 여러 테이블에서 같은 대상을 함께 처리해야 한다. 따라서 InlineIdsOrClauseBulkIdStrategy를 사용하도록 설정한 경우, Hibernate는 수정/삭제할 대상의 기본키 값을 먼저 조회한 뒤 이를 각 테이블에 대한 WHERE 절 조건문에 직접 넣어 쿼리를 만든다. 본 취약점은 이 과정에서 기본키 값이 조건문의 일부로 사용되면서 발생한다.
③ 기본키가 문자열 타입이고 사용자가 값을 직접 입력할 수 있는 경우
입력값이 기본키로 사용될 경우, 공격자가 SQL Injection 구문을 삽입하기 위한 공격 지점이 된다. 해당 조건을 만족하지 않는 경우, 취약 버전의 Hibernate를 사용하더라도 악성 SQL 쿼리가 삽입될 경로가 없어 취약점이 발생하지 않는다.
2) 취약 코드 분석
본 취약점은 Hibernate 내부의 아래와 같은 호출 흐름에서 발생한다.
그림 27. 기본키 값이 SQL 문자열로 변환되는 내부 호출 흐름
먼저 toStatement()는 수정/삭제 대상의 기본키 값을 이용해 WHERE 절 조건문을 문자열로 조립하는 메서드이다. 이 메서드는 각 대상에 대해 기본키 컬럼 = 기본키 값 형태의 조건을 만들고, 대상이 여럿이면 이를 OR로 이어 하나의 조건문으로 반환한다. 이후 이렇게 만들어진 문자열은 각 테이블의 수정/삭제 쿼리의 WHERE 절에 그대로 삽입된다.
그림 28. WHERE 절 조건문을 문자열로 조립하는 toStatement() 메서드
예를 들어 기본키 컬럼이 username이라면, toStatement()는 다음과 같은 형태의 조건문을 조립한다.
username = [변환된 기본키 값] |
이 과정에서 문자열 타입의 기본키 값은 quoteIdentifier()를 거쳐 objectToSQLString()으로 전달된다.
그림 29. objectToSQLString()에 문자열 값을 전달하는 quoteIdentifier() 메서드
objectToSQLString()은 전달받은 문자열 값을 SQL에 넣을 수 있는 문자열 형태로 바꾸는 메서드이다. 문제는 이 메서드가 값을 PreparedStatement의 파라미터처럼 안전하게 분리하지 않고, 단순히 앞뒤에 작은따옴표만 붙여 반환한다는 점이다.
그림 30. 값을 안전하게 바인딩하지 않고 문자열로 변환하는 objectToSQLString() 메서드
예를 들어 기본키 값이 alice라면, objectToSQLString()은 이를 다음과 같이 변환한다.
'alice' |
결과적으로 toStatement()가 만든 조건문은 다음과 같이 완성된다.
username = 'alice' |
하지만 공격자가 기본키 값으로 A' or '1'='1'과 같은 문자열을 미리 저장해두었다면, objectToSQLString()은 이 값도 별도의 바인딩이나 이스케이프 없이 다음과 같이 변환한다.
'A' or '1'='1' |
결국 toStatement()가 다음과 같은 조건문을 생성하게 된다.
username = 'A' or '1'='1' |
이 문자열이 그대로 WHERE 절에 삽입되면, 저장된 값이 단순 데이터가 아니라 SQL 쿼리의 일부로 해석된다. 그 결과 원래는 특정 행만 대상으로 해야 할 수정/삭제가 테이블 내 다른 데이터까지 확장될 수 있다.
7. 대응 방안
1) 지원되는 버전으로 업그레이드 또는 벤더 패치 적용
가장 우선적인 대응은 취약한 Hibernate 버전을 계속 사용하지 않는 것이다. 다만 Hibernate는 5.6 버전 라인에 대한 유지보수를 종료했으며, 이후 버전에서는 기능 및 구조 변화가 많아 환경에 따라 즉시 업데이트가 어려울 수 있다. 이러한 경우에는 아래와 같은 완화 방안을 적용하는 것이 바람직하다.
2) InlineIdsOrClauseBulkIdStrategy 사용 필요 여부 점검 및 불필요 시 설정 제거
해당 취약점은 InlineIdsOrClauseBulkIdStrategy를 사용하는 경우에 발생하므로, 현재 운영 환경에서 이 설정이 반드시 필요한지 먼저 점검해야 한다. 임시 테이블 기반 처리 방식 등, 기본키 값을 SQL 조건문에 직접 넣지 않는 다른 방식으로 동일한 수정/삭제 처리를 수행할 수 있다면 해당 설정을 제거하여 취약한 쿼리 생성 경로를 차단할 수 있다.
3) 기본키 입력값 필터링
업데이트가 어렵고 운영 환경상 InlineIdsOrClauseBulkIdStrategy를 계속 사용해야 한다면, 사용자가 입력하는 기본키 값은 허용된 문자만 입력할 수 있도록 화이트리스트 방식으로 검증해야 한다. 영문, 숫자 등 사전에 정한 문자만 허용하고 그 외 입력은 서버에서 차단하면, 악성 SQL 쿼리가 기본키 값으로 저장되는 것을 막을 수 있다. 이를 통해 해당 취약점이 Second-Order SQL Injection 형태로 악용되는 것을 방지할 수 있다.
Step 3. CVE-2026-34220
1. CVE 개요
CVE-2026-34220은 MikroORM에서 발생하는 ORM Injection 취약점이다. 취약한 버전의 MikroORM은 악의적으로 조작된 입력을 통해, 서버 내부에서만 사용되도록 설계된 기능이 외부 입력에 의해 사용될 수 있었다.
그 결과 조작된 입력이 일반 데이터가 아니라 쿼리 구조에 영향을 주는 값으로 처리되면서, 실행되는 SQL 쿼리의 구조가 변경될 수 있다. 이 취약점이 공격자에 의해 악용될 경우, 권한 없이 데이터베이스의 민감한 정보가 탈취되거나, 기존의 데이터가 훼손되는 피해가 발생할 수 있다.
2. 영향받는 소프트웨어 버전
| S/W 구분 | 취약 버전 |
MikroORM |
version <= 6.6.9, |
표 7. CVE-2026-34220 영향 받는 소프트웨어 버전
3. 공격 시나리오
4. 테스트 환경 구성 정보
| 이름 | 정보 |
피해자 |
Ubuntu 22.04 & Node.js Express 4.21.2 & MikroORM 6.6.9 |
공격자 |
Kali Linux |
표 8. CVE-2026-34220 테스트 환경 구성
5. 취약점 테스트
취약점 테스트를 위한 정보는 아래 EQSTLab GitHub Repository에서 확인할 수 있다.
• URL: https://github.com/EQSTLab/CVE-2026-34220
사내 게시판에서 게시글을 작성한다.
그림 31. 게시글 작성
작성한 게시물이 게시판에 등록된 것을 확인할 수 있다. 이제 작성된 게시글을 수정하는 과정에서 공격을 시도한다.
그림 32. 게시글 수정
게시글 수정 요청에 악성 페이로드를 삽입하여 salaries 테이블의 정보를 조회하고, 해당 내용으로 글이 수정되도록 유도한다.
그림 33. 게시글 수정 요청 변조
수정된 글이 게시판에 등록되고, 이를 통해 본래 확인할 수 없던 급여 정보를 확인할 수 있다.
그림 34. 급여 정보 유출
6. 취약점 상세 분석
1) 취약점 발생 조건
취약 버전의 MikroORM을 사용하더라도 다음 조건을 모두 충족하는 경우에만 취약점이 발생한다.
① 커스텀 타입 컬럼에 입력값이 전달되는 경우
커스텀 타입은 컬럼에 입력값이 DB에 저장되기 전에, 개발자가 정의한 방식에 따라 입력값을 변환하여 처리하는 타입이다. 따라서 이러한 컬럼에 전달된 입력값은 일반 문자열이나 숫자 컬럼처럼 바로 처리되지 않고, 개발자가 정의한 변환 로직을 거치게 된다.
MikroORM은 컬럼에 지정된 타입이 커스텀 타입인지 isMappedType()을 통해 판단한다.
그림 35. 커스텀 타입 판별 로직 1
isMappedType()은 전달된 값에 __mappedType 속성이 있는지 확인한다.
그림 36. 커스텀 타입 판별 로직 2
Type 클래스의 prototype에 __mappedType 속성이 존재하므로 Type을 상속한 클래스는 이 속성을 갖게 된다.
그림 37. 커스텀 타입 판별 로직 3
즉, 컬럼에 지정된 타입이 Type 클래스를 상속하였다면, MikroORM는 해당 컬럼을 커스텀 타입의 컬럼으로 취급한다.
② 외부 입력이 쿼리 구성 경로에 직접 전달되는 경우
MikroORM은 DB 동작을 위한 다양한 메서드를 제공하며, 그에 따라 값을 처리하는 방식이 다르다. 일부 메서드는 클라이언트의 입력값을 DB에 전달하기 전에 JSON 문자열로 변환하는데, 이 경우 { "__raw": true, "sql": "..." } 형태의 페이로드도 단순 문자열이 되어 SQL로 해석되지 않는다.
반면 아래 메서드들은 이 변환 과정을 거치지 않아 { __raw } 객체가 그대로 SQL 조립 단계까지 전달되며, 이 시점에서 MikroORM이 __raw 속성을 감지하고 해당 값을 SQL로 직접 삽입한다.
| 메서드 | 설명 |
nativeUpdate() |
조건에 맞는 레코드를 UPDATE하는 메서드로, 별도의 조회 과정 없이 SQL 쿼리를 실행한다. |
create() + flush() |
새로운 레코드를 DB에 저장하는 메서드로, insert()와 달리 ORM 내부에 레코드를 등록해 두고 flush() 호출 시점에 일괄적으로 INSERT한다. |
wrap(entity).assign(userInput) + flush() |
기존 레코드를 UPDATE하는 메서드로, nativeUpdate()와 달리 여러 필드를 한 번에 UPDATE할 수 있다. |
표 9. 입력값이 문자열로 변환되지 않는 메서드
2) 취약 코드 분석
커스텀 타입 컬럼에 전달된 값은 일반 컬럼 값처럼 바로 변환되지 않고 processCustomType()를 통해 별도로 처리된다. 이 함수는 입력값을 데이터베이스에 저장 가능한 일반값으로 변환하기 전에, 먼저 해당 값이 특수한 내부용 값인지 확인한다.
여기서 사용되는 것이 Raw Query Fragment이다. Raw Query Fragment는 ORM이 생성하는 SQL 쿼리에서 원래 데이터 값이 들어가야 하는 부분에 SQL 쿼리를 사용해야 할 때 서버 내부에서 사용하는 기능이다. 즉, 이 기능에 전달되는 값은 단순한 입력 데이터가 아니라 실행될 SQL 쿼리로 취급되므로, 본래 외부 입력이 이 기능을 직접 사용할 수 있어서는 안 된다.
processCustomType()는 먼저 입력값이 Raw Query Fragment인지 확인하고, Raw Query Fragment로 판단되면 타입 변환을 수행하지 않은 채 값을 그대로 반환한다.
그림 38. isRaw() 검사와 타입 변환 분기
문제는 이때 사용되는 isRaw()의 검증이 미흡하다는 점이다. 취약 버전의 isRaw()는 전달된 값이 내부에서 생성된 정상적인 Raw Query Fragment인지 확인하지 않고, 입력값 객체에 __raw 속성이 존재하는지를 기준으로 Raw Query Fragment인지 판단하였다.
그림 39. __raw 속성만으로 Raw Query Fragment 여부를 판별하는 로직
이로 인해 공격자가 __raw 속성을 포함한 값을 전달하면, MikroORM은 이를 일반 데이터가 아니라 Raw Query Fragment로 인식하게 된다. 그 결과, 공격자가 전달한 악성 구문이 최종 생성되는 SQL 쿼리에 반영된다.
예를 들어 공격자가 게시물 수정 기능에서 content 필드에 악성 객체를 전달하면, 원래는 게시물 본문 문자열이 저장되어야 할 위치에 일반 값이 아닌 SQL 쿼리가 들어가게 된다. 아래 그림은 요청 변조에 사용된 예시와, 그 결과 생성된 악의적인 UPDATE 문의 예시를 보여준다.
그림 40. 게시글 수정 요청 변조
그림 41. 공격자에 의해 생성된 악의적인 SQL 쿼리
7. 대응 방안
1) 보안 패치 적용
CVE-2026-34220은 MikroORM 6.6.10 및 7.0.6에서 패치되었다. 취약 버전에서는 __raw 속성 존재 여부만으로 전달된 값을 SQL 쿼리로 처리할 수 있는 값으로 판단했었다.
패치 후에는 이러한 판별 방식이 변경되었다. 기존처럼 __raw 속성만 확인하는 것이 아니라, MikroORM 내부에서 생성한 값인지 추가로 검증하도록 수정되었다.
그림 42. isRaw()의 SQL 쿼리 처리 대상 판별 방식 변경
RawQueryFragment.isRaw()는 단순히 __raw의 존재를 확인하는 대신, 해당 객체가 MikroORM이 내부적으로 부여한 rawSymbol이라는 Symbol 식별자를 갖고 있는지 확인한다. Symbol은 호출할 때마다 고유한 값이 생성되므로, 외부에서 이를 전달하여 기존과 같이 내부 구조를 모방하는 것이 불가능해졌다.
그림 43. RawQueryFragment 클래스의 내부 식별자 기반 검증 로직
그 결과 요청을 변조하여 { "__raw": true, "sql": "..." } 형태의 값을 포함해 전달하더라도, 이를 ORM 내부에서 생성된 Raw Query Fragment로 위장할 수 없다. 따라서 이러한 값은 더 이상 SQL 쿼리 처리 대상으로 인식되지 않으며, 공격자가 삽입한 sql 속성 내용이 최종 SQL 쿼리에 직접 반영되는 동작도 차단된다.
2) 입력 객체에서 허용된 필드만 추출하여 사용
보안 패치를 즉시 적용하기 어려운 경우, 입력 객체에서 실제로 필요한 필드만 별도로 추출하여 새 객체를 만든 뒤 전달하는 것이 안전하다. 이 방식은 사용자가 __raw, sql 등 불필요한 속성을 함께 전달하더라도 해당 값들이 data 객체에 포함되지 않도록 한다.
■ 마무리
ORM은 SQL 작성을 단순화하고 개발 생산성을 높여주는 유용한 기술이지만, 이를 사용한다고 해서 SQL Injection 문제가 자동으로 해결되는 것은 아니다. ORM은 일반적인 경우 비교적 안전하게 동작하지만, 처리하지 못하는 영역이 존재하고, 기능 자체의 구현 문제로 인해 Injection 취약점이 발생할 수 있다.
따라서 ORM을 보안 대책 그 자체로 받아들여서는 안 된다. 애플리케이션은 여전히 입력값을 제한하고, SQL 쿼리에 영향을 줄 수 있는 위치에는 허용된 값만 사용되도록 설계해야 한다. 또한 ORM 자체의 취약점 가능성을 고려해, 벤더의 보안 패치와 업데이트를 지속적으로 추적하고 반영해야 한다.
결국 ORM Injection에 대응하는 출발점은 “ORM을 사용하므로 안전하다”는 전제를 버리는 것이다. ORM도 내부적으로 SQL 쿼리를 생성하는 이상, 그 동작을 신뢰만 할 것이 아니라 검증과 관리의 대상으로 바라봐야 한다.
■ 참고 사이트
•NVD
◦ https://nvd.nist.gov/vuln/detail/CVE-2026-30951
◦ https://nvd.nist.gov/vuln/detail/CVE-2026-0603
◦ https://nvd.nist.gov/vuln/detail/CVE-2026-34220
•AWS
◦ https://aws.amazon.com/what-is/object-relational-mapping
•CVE-2026-30951
◦ https://github.com/sequelize/sequelize/commit/b1475280b82159c11b829b43568da370f598a9e4
•CVE-2026-0603
◦ https://github.com/hibernate/hibernate-orm
•CVE-2026-34220
◦ https://github.com/mikro-orm/mikro-orm/security/advisories/GHSA-gwhv-j974-6fxm
◦ https://github.com/mikro-orm/mikro-orm
1 파라미터 바인딩: 입력값이 SQL 쿼리의 구조를 바꾸지 않도록, SQL 쿼리와 입력값을 하나의 문자열로 합치지 않고 값이 들어갈 위치에 맞춰 따로 전달하는 방식.
2 PreparedStatement: ‘?’처럼 입력값이 들어갈 위치를 포함한 SQL 쿼리를 미리 준비해 두고, DB가 그 구조를 먼저 파악한 상태에서 이후 파라미터 바인딩으로 값을 넣어 실행할 수 있게 하는 객체.
3 HQL(Hibernate Query Language): Hibernate에서 엔티티 객체를 기준으로 작성하는 SQL 유사 쿼리 언어.
4 JSON/JSONB: JSON은 텍스트 기반 데이터 형식이며, JSONB는 JSON을 바이너리 형태로 저장해 검색과 연산에 유리하게 만든 형식.
5 JSONPath: JSON 데이터 내부의 특정 값이나 경로를 지정하기 위한 표현식.
6 타입 캐스팅: 값을 특정 데이터 타입으로 변환하는 작업.
7 Raw Query Fragment: 일반 값을 넣는 방식으로는 처리할 수 없는 내용을 SQL 쿼리에 반영하기 위해 사용하는 구문