이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
SonarQube SSRF(S5144) 작업을 마치고 나서 마지막 하나가 남았다. SonarQube LDAP Injection 룰 S2078이었다. 사내 인사 시스템에는 직원 검색 기능이 있었다. 사번이나 이름을 입력하면 LDAP 디렉터리에서 조직·직급 정보를 가져오는 기능인데, 그 검색 필터 문자열을 문자열 연산으로 직접 합쳐 쓰고 있었다.
SonarQube LDAP Injection 룰은 “LDAP queries should not be vulnerable to injection attacks”라고 뜬다. CWE-90에 해당하는 취약점이다. 사용자 입력이 LDAP 필터나 DN 문자열에 그대로 들어가면 잡는다. 공격자가 LDAP 메타문자(*()\\나 NUL)를 입력에 끼워 넣어 필터 논리를 바꾸거나 전체 디렉터리를 긁어갈 수 있다.
4가지 시도 끝에 최종 해결까지 걸린 과정을 기록한다.

사내 LDAP 검색 코드 패턴
문제가 된 코드는 사번(uid)과 이름(cn)으로 직원을 검색하는 EmployeeDirectoryService였다. JNDI DirContext.search()를 직접 쓰고 있었다.
// EmployeeDirectoryService.java — 취약 패턴
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.NamingEnumeration;
public class EmployeeDirectoryService {
private static final String LDAP_BASE = "ou=employees,dc=finco,dc=internal";
public List<EmployeeInfo> searchByUid(String uid) throws NamingException {
DirContext ctx = getLdapContext();
SearchControls ctls = new SearchControls();
ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
ctls.setReturningAttributes(new String[]{"cn", "employeeNumber", "title", "department"});
// S2078 여기서 잡힘
String filter = "(&(objectClass=person)(uid=" + uid + "))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
return mapResults(result);
}
public List<EmployeeInfo> searchByName(String name) throws NamingException {
DirContext ctx = getLdapContext();
SearchControls ctls = new SearchControls();
ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
// S2078 여기서도 잡힘
String filter = "(&(objectClass=person)(cn=*" + name + "*))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
return mapResults(result);
}
}
문제는 명확하다. "(&(uid=" + uid + "))" 패턴에서 uid가 사용자 입력 그대로 들어간다. 공격자가 *))(|(uid=*를 입력하면 필터가 (&(uid=*))(|(uid=*))로 바뀌어 전 직원을 조회할 수 있다. SonarQube LDAP Injection 룰은 DirContext.search() 세 번째 인자에 사용자 입력이 직접 흘러 들어오는 데이터 플로우를 추적해서 잡는다.
시도 1. 별표(*) 제거 — 실패
처음에 “가장 흔한 와일드카드 문자 *만 제거하면 되지 않을까”라는 생각으로 시작했다.
// 시도 1 — 별표만 제거
public List<EmployeeInfo> searchByUid(String uid) throws NamingException {
String sanitized = uid.replace("*", ""); // 별표 제거
String filter = "(&(objectClass=person)(uid=" + sanitized + "))";
// ...
}
S2078은 그대로 떴다. 당연한 결과였다. LDAP 메타문자는 * 하나가 아니다. (, ), \\, NUL(\00)도 메타문자다. *만 막으면 )(uid=admin 같은 괄호 주입이 여전히 가능하다.
SonarQube의 데이터 플로우 분석은 입력이 DirContext.search()에 들어가는지를 본다. replace("*", "") 같은 부분적 치환 후에도 입력이 여전히 필터 문자열 합치기에 쓰이면 S2078은 사라지지 않는다. SonarQube가 인식하는 안전한 패턴이 아니기 때문이다.
시도 2. 정규식 화이트리스트 — 부분 통과
다음 시도는 허용할 문자만 정의하는 화이트리스트 방식이었다. 사번은 숫자만, 이름은 영문·숫자·한글만 허용하도록 했다.
// 시도 2 — 정규식 화이트리스트
private static final Pattern UID_PATTERN = Pattern.compile("^[0-9]{5,10}$");
private static final Pattern CN_PATTERN = Pattern.compile("^[가-힣a-zA-Z\\s]{1,30}$");
public List<EmployeeInfo> searchByUid(String uid) throws NamingException {
if (!UID_PATTERN.matcher(uid).matches()) {
throw new IllegalArgumentException("사번 형식 오류");
}
String filter = "(&(objectClass=person)(uid=" + uid + "))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
// ...
}
public List<EmployeeInfo> searchByName(String name) throws NamingException {
if (!CN_PATTERN.matcher(name).matches()) {
throw new IllegalArgumentException("이름 형식 오류");
}
String filter = "(&(objectClass=person)(cn=*" + name + "*))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
// ...
}
searchByUid는 S2078이 사라졌다. 숫자만 허용하는 정규식 뒤에 필터를 합쳐도 SonarQube가 이 케이스를 화이트리스트로 인정했다. 그런데 searchByName은 여전히 S2078이 남아 있었다.
문제는 두 가지였다. 첫째, CN_PATTERN에 한글 범위(가-힣)가 들어가 있어도 SonarQube는 이 패턴이 LDAP 메타문자를 완전히 차단한다고 확신하지 못했다. 한글 코드 포인트 범위가 일부 특수문자 영역과 겹칠 수 있는지 정적 분석기가 판단하기 어렵기 때문이다. 둘째, 이름 검색에서 cn=*" + name + "*" 패턴 자체가 와일드카드를 쓰고 있어서 입력이 그대로 들어간다는 데이터 플로우는 변하지 않았다.
사번 검색만 쓰는 케이스라면 정규식 화이트리스트로 해결이 됐겠지만, 이름 검색까지 포함하면 부분 해결에 그쳤다.
시도 3. Spring LDAP filterEncode — 통과
Spring LDAP의 LdapEncoder.filterEncode()를 쓰는 방법이었다. 이 메서드는 *, (, ), \\, NUL을 각각 \2a, \28, \29, \5c, \00으로 이스케이프한다. RFC 4515에 정의된 LDAP 필터 인코딩 방식이다.
// 시도 3 — Spring LDAP filterEncode
import org.springframework.ldap.support.LdapEncoder;
public List<EmployeeInfo> searchByUid(String uid) throws NamingException {
String safeUid = LdapEncoder.filterEncode(uid);
String filter = "(&(objectClass=person)(uid=" + safeUid + "))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
return mapResults(result);
}
public List<EmployeeInfo> searchByName(String name) throws NamingException {
String safeName = LdapEncoder.filterEncode(name);
String filter = "(&(objectClass=person)(cn=*" + safeName + "*))";
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
return mapResults(result);
}
두 메서드 모두 S2078이 사라졌다. SonarQube는 LdapEncoder.filterEncode()를 LDAP 필터 입력을 안전하게 만드는 화이트리스트 sanitizer로 인식한다. 데이터 플로우 관점에서 입력이 인코딩 단계를 거쳤기 때문에 오염(taint)이 정화된 걸로 처리한다.
기능적으로도 맞다. 입력에 *가 있어도 \2a로 변환되어 와일드카드가 아닌 리터럴 별표로 처리된다. 이름에 특수문자를 입력한 사용자가 있어도 검색이 깨지지 않고 결과가 0건으로 나온다.
의존성은 Spring LDAP Core다. 이미 Spring 프로젝트라면 대부분 이미 있다.
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.ldap</groupId>
<artifactId>spring-ldap-core</artifactId>
<version>3.2.4</version>
</dependency>
최종 — EqualsFilter / AndFilter 빌더 패턴
filterEncode로 S2078은 통과했지만, 코드 리뷰에서 한 가지 지적이 나왔다. 여전히 문자열 합치기로 필터를 조립하고 있어서 향후 유지보수 중에 누군가 safeUid 대신 uid를 실수로 넣으면 다시 취약해진다는 점이었다.
Spring LDAP는 EqualsFilter, AndFilter, LikeFilter 같은 필터 빌더 클래스를 제공한다. 문자열 합치기 없이 필터를 객체로 조립하면 인코딩이 자동으로 처리되어 실수 가능성이 없다.
// 최종 — Spring LDAP 필터 빌더 패턴
import org.springframework.ldap.filter.AndFilter;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.LikeFilter;
import org.springframework.ldap.support.LdapNameBuilder;
import javax.naming.Name;
@Service
public class EmployeeDirectoryService {
private static final String LDAP_BASE = "ou=employees,dc=finco,dc=internal";
@Autowired
private LdapTemplate ldapTemplate; // Spring LDAP LdapTemplate 사용
// 사번(uid) 검색 — EqualsFilter
public List<EmployeeInfo> searchByUid(String uid) {
AndFilter filter = new AndFilter();
filter.and(new EqualsFilter("objectClass", "person"));
filter.and(new EqualsFilter("uid", uid)); // uid는 자동 이스케이프
return ldapTemplate.search(
LDAP_BASE,
filter.encode(),
new EmployeeInfoContextMapper()
);
}
// 이름(cn) 부분 검색 — LikeFilter (와일드카드 포함, 입력만 이스케이프)
public List<EmployeeInfo> searchByName(String name) {
AndFilter filter = new AndFilter();
filter.and(new EqualsFilter("objectClass", "person"));
filter.and(new LikeFilter("cn", "*" + name + "*")); // name은 자동 이스케이프
return ldapTemplate.search(
LDAP_BASE,
filter.encode(),
new EmployeeInfoContextMapper()
);
}
// DN 기반 직접 조회 — LdapNameBuilder로 DN 이스케이프
public EmployeeInfo findByDn(String uid, String ou) {
Name dn = LdapNameBuilder.newInstance(LDAP_BASE)
.add("ou", ou) // ou 값 자동 DN 이스케이프
.add("uid", uid) // uid 값 자동 DN 이스케이프
.build();
return ldapTemplate.lookup(dn, new EmployeeInfoContextMapper());
}
}
EqualsFilter, LikeFilter는 내부에서 LdapEncoder.filterEncode()를 호출해서 값을 인코딩한다. 필터 문자열을 직접 만질 일이 없으니 입력 주입 경로 자체가 없어진다. SonarQube LDAP Injection 룰 S2078은 통과하고, 코드 리뷰에서도 지적이 없었다.
DN 조립에는 LdapNameBuilder를 써야 한다. LDAP DN에도 ,, =, +, <, >, #, ;, \\, " 같은 메타문자가 있다. 사용자 입력으로 DN을 만들 때 문자열 합치기를 쓰면 DN Injection이 발생할 수 있다. LdapNameBuilder.newInstance().add(attr, value).build() 패턴을 쓰면 value가 자동으로 이스케이프된다.
JNDI DirContext를 직접 써야 하는 환경(레거시 설정이 LdapTemplate을 지원 안 하는 경우)이라면 시도 3의 filterEncode 방식으로 충분하다. 그 경우에도 DN 조립은 LdapNameBuilder로 하는 게 맞다.
// JNDI DirContext 환경 — filterEncode + LdapNameBuilder 조합
import org.springframework.ldap.support.LdapEncoder;
import org.springframework.ldap.support.LdapNameBuilder;
public List<EmployeeInfo> searchByUid(String uid) throws NamingException {
DirContext ctx = getLdapContext();
SearchControls ctls = new SearchControls();
ctls.setSearchScope(SearchControls.SUBTREE_SCOPE);
ctls.setReturningAttributes(new String[]{"cn", "employeeNumber", "title"});
// 필터 값 이스케이프
String safeUid = LdapEncoder.filterEncode(uid);
String filter = "(&(objectClass=person)(uid=" + safeUid + "))";
// DN 이스케이프 (DN 기반 조회 시)
// Name dn = LdapNameBuilder.newInstance(LDAP_BASE).add("uid", uid).build();
NamingEnumeration<?> result = ctx.search(LDAP_BASE, filter, ctls);
return mapResults(result);
}
정리
SonarQube LDAP Injection(S2078)의 핵심은 필터 문자열 합치기 자체를 없애는 것이다. filterEncode로도 통과는 되지만, Spring LDAP EqualsFilter·AndFilter·LikeFilter 빌더 패턴을 쓰면 코드 구조적으로 주입 경로가 사라진다. DN 조립은 LdapNameBuilder를 쓴다.
| 시도 | 방법 | 결과 |
|---|---|---|
| 시도 1 | replace("*", "") 별표 제거 |
실패 — 괄호·역슬래시 우회 |
| 시도 2 | 정규식 화이트리스트 | 부분 통과 — 숫자 입력만, 한글 이름 검색 미해결 |
| 시도 3 | LdapEncoder.filterEncode() |
통과 — S2078 해소 |
| 최종 | EqualsFilter / AndFilter 빌더 |
통과 + 구조적 안전 (권장) |
이걸로 SonarQube 보안 시리즈 14편이 끝났다. S3649 SQL Injection에서 시작해서 S2076 Command Injection, S5131 XSS Reflected, S2083 Path Traversal, S2755 XXE까지 Injection/Vulnerability 5편을 먼저 정리했다.
그다음은 Security Hotspot 9편이었다. S2068 Hardcoded Credentials, S4502 CSRF, S4790 Weak Cryptography, S5146 Open Redirect, S2092/S3330 Insecure Cookie, S5131 Stored XSS, S5696 DOM XSS, S1523 eval Injection, S5122 CORS.
마지막 4편은 Security Hotspot과 Vulnerability 경계에 걸쳐 있는 것들이었다. S5135 Deserialization, S2245 Insecure Random, S5852 ReDoS, S5144 SSRF. 그리고 이번 S2078 LDAP Injection으로 마무리다.
14편을 쓰면서 공통으로 느낀 건, SonarQube가 잡는 패턴 대부분이 “사용자 입력이 어딘가 민감한 컨텍스트에 그대로 흘러 들어가는 것”이라는 점이다. SQL이든 LDAP이든 파일 경로든 SSRF든, 데이터 플로우 중간에 인코딩이나 검증이 끼어들지 않으면 오염된 데이터가 실행 컨텍스트까지 도달한다. 해결의 구조는 비슷했다 — sanitizer를 끼우거나, 입력을 아예 파라미터로 분리하거나, 빌더 패턴으로 조립 자체를 없애거나.
룰 본문은 SonarSource 공식 룰 페이지(S2078)에서 확인할 수 있다.