본 게시물은 KISA의 "주요정보통신기반시설 기술적 취약점 분석·평가 방법 상세가이드"를 기반으로 작성된 글입니다.
KISA 한국인터넷진흥원
www.kisa.or.kr
1. LDAP(Lightweight Directory Access Protocol) 이란?
LDAP은 디렉터리 서비스에 접근하기 위한 네트워크 프로토콜이다.
여기서 "디렉터리 서비스"라는 워딩은 트리 형태로 정보가 저장되어 있다는 말과 같다고 생각하면 된다.
우리가 일반적으로 생각하는 RDB는 PK를 통해 정보를 찾는다면, 디렉터리 서비스는 "회사 > 개발팀 > 홍길동"과 같이 우리가 파일을 찾듯 정보에 접근하는 구조라고 생각하면 된다.
2. 왜 LDAP을 사용하는가?
여기서 왜 RDB나 다른 데이터 저장 및 접근 방식을 채택하지 않고 LDAP을 사용할까? 라는 의문이 생기게 된다.
일반적인 RDB는 굉장히 범용적으로 설계되어 있다. 복잡한 JOIN을 수행하고, Transaction 처리를 하며, 무결성을 보장하기 위한 여러 장치가 맞물려 돌아가게 된다. 하지만 사용자 인증 정보를 조회하는 것이 이러한 일들이 필요할까?
사용자 인증을 구현한다고 생각해보자.
인증 정보를 읽어와 코드(Application) 수준에서 이를 비교하는 로직으로 구현할 것이다.
이름, 부서, 직위, 직책, 권한 등과 같은 정보들은 가끔 수정될 뿐, 읽기 처리가 대부분일 것이다.
일반적으로 삽입, 수정, 삭제에 의해 무결성에 문제가 발생 되어 RDB는 이를 방지하기 위해 여러 장치를 가지고 있는 것인데, 조회가 대부분이고 관계성도 크게 필요없는 이 상황에서는 RDB 선택이 오버 엔지니어링이 되는 것이다. 추가적으로 단순함으로 인해 유지보수 편의성에 대한 이점도 챙길 수 있다.
또한, RDB와 다르게 LDAP은 트리구조로 이루어져있다.
RDB를 부모-자식 관계로 3 Depth 정도 구성되어 있다고 가정해보자, 이를 가져오려면 3개의 테이블을 JOIN해야 한다. JOIN은 기본적으로 Cartesian Product를 전제하기 때문에 depth 하나당 얼마나 무겁게 동작하는지 대략 짐작이 될 것이다.
반면 LDAP은 트리 형태로 단일 특정이 가능하다는 것이다. 여기서 발생하는 압도적인 읽기 성능 차이가 발생한다.
이러한 LDAP을 한번 잘 구축하게 되면 중앙에서 인증을 관리하면 매우 편하다.
회사에 시스템이 한개라는 보장이 없다. 메일, VPN, 사내 포탈, 사내 형상 관리 등 전부 같은 아이디/비밀번호로 사용하게 구성하면 각 시스템마다 DB 구축해서 만드는 것 보다 LDAP에게 물어보고 처리하는게 훨씬 깔끔한 구조가 된다는 말이다.
정리해보자
내가 생각하기에 LDAP을 사용하는 이유는 크게 3가지다.
- 인증 로직은 읽기 작업의 비율이 압도적인데 RDB는 오버 엔지니어링이다.
- 트리구조로 인해 depth에 따른 읽기 성능이 RDB보다 압도적이다.
- LDAP을 하나 잘 구축해놓으면 통합 인증 체계를 구성하기 용이하다.
Question
여기서 한 가지 의문이 든다.
최근 같은 이유로 떠오르는 NoSQL로 인증 체계를 만들어도 LDAP이 더 뛰어난 가치를 지닐까?
개인적인 생각으론 NoSQL이 압도적으로 확정성이 편하고 성능에 대한 차이가 굉장히 미비하지 않을까 싶다. 이전에 MongoDB 컨퍼런스에서 사용 사례들을 봤을때 생각보다 RDB에 비해 드라마틱한 성능개선이 가능함을 체감했다.
그럼에도 LDAP을 채택해야 할 이유가 있을까?
Answer
NoSQL로 인증 체계를 만든다는 것이 굉장히 많은 워크로드를 요구하는 작업이다.
데이터 비교만 가능해서 되는게 아니라 패스워드 해싱, 세션 관리, MFA, 토큰 발급/갱신/폐기 등등 제대로 사용하려면 구현해야 할 항목들이 많아진다. 더욱이 사내에서 사용하는 VPN, NAS, CI/CD 등 각 시스템마다 통합 가능한 체계를 직접 구축해야된다는 것이다.
이렇게 만들어진 체계가 보안적으로 안전하다고 확신할 수도 없다.
결론적으로, LDAP이 표준으로 자리 잡은 이유는 기술적 우수성보다 생태계 Lock-in이다. 대부분의 네트워크, 운영체제, 소프트웨어들이 LDAP을 기본 지원하도록 만들어졌다. 새롭게 만들어서 이 모든 것과 호환 가능하게 구현할 자신이 있는가?
기업 입장에서는 그 약간의 성능 향상을 위해 이러한 리스크들을 감수할 이유가 없다.
3. LDAP 구축 및 실습
3.1. Docker Container 생성
docker run -it --name ldap-server -p 389:389 ubuntu:22.04 /bin/bash

3.2. LDAP 설치
LDAP 설치
apt update
apt install -y slapd ldap-utils iproute2 vim
service slapd start
Administrator password: [YOUR-PASSWORD]
LDAP 설정
dpkg-reconfigure slapd
- Omit OpenLDAP server configuration? no
- DNS domain name: example.com
- Organization name: MyLab
- Administartor password: admin1234
- Confirm password: admin1234
- Do you want the database to be removed when slapd is purged? no
- Move old database? yes
설치 확인
slapcat

3.3. LDAP 데이터 삽입 및 테스트
OU(Organizational Unit) 생성
vim /tmp/base.ldif
--------------------------------------------------------------
dn: ou=People,dc=example,dc=com
objectClass: organizationalUnit
ou: People
--------------------------------------------------------------
LDIF(LDAP Data Interchange Format)은 LDQP에 데이터를 넣을 때 사용하는 텍스트 포멧이다. INSERT 문을 담은 .sql 파일이라고 생각하면 된다.
dn: ou=People,dc=example,dc=com은 이 데이터가 트리에서 어디에 위치할지 지정하는 것이다. 해당 경우에는 example.com 아래 People 이라는 디렉터리를 만든다고 이해하면 된다.
objectClass: organizationalUnit은 엔트리의 타입을 지정하는 것이다. LDAP의 모든 엔트리에 타입이 존재해야하고 타입에 따라 속성이 정해진다.
ou: People은 OU(Organizational Unit)의 이름을 People로 설정한다.
ldapadd -x -D "cn=admin,dc=example,dc=com" -w admin1234 -f /tmp/base.ldif

위 명령어는 /tmp/base.ldif 파일을 이용하여 LDAP에 새 엔트리를 추가하라는 명령어이다.
사용자 추가
vim /tmp/users.ldif
--------------------------------------------------------------
dn: uid=hong,ou=People,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: hong
sn: Hong
givenName: Gildong
cn: Hong Gildong
displayName: Hong Gildong
uidNumber: 10000
gidNumber: 10000
userPassword: pass1234
loginShell: /bin/bash
homeDirectory: /home/hong
mail: hong@example.com
dn: uid=kim,ou=People,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: shadowAccount
uid: kim
sn: Kim
givenName: Cheolsu
cn: Kim Cheolsu
displayName: Kim Cheolsu
uidNumber: 10001
gidNumber: 10001
userPassword: secure5678
loginShell: /bin/bash
homeDirectory: /home/kim
mail: kim@example.com
--------------------------------------------------------------
ldapadd -x -D "cn=admin,dc=example,dc=com" -w admin1234 -f /tmp/users.ldif

사용자 확인
ldapsearch -x -b "dc=example,dc=com" -D "cn=admin,dc=example,dc=com" -w admin1234
인증 테스트
# 성공
ldapwhoami -x -D "uid=hong,ou=People,dc=example,dc=com" -w pass1234
# 실패
ldapwhoami -x -D "uid=hong,ou=People,dc=example,dc=com" -w wrongpass
리스닝 확인
ss -tlnp | grep 389
LISTEN 0 2048 0.0.0.0:389 0.0.0.0:*
LISTEN 0 2048 [::]:389 [::]:*
만약 127.0.0.1:389로만 리스닝 되고 있다면 아래 명령어로 0.0.0.0:389로 리스닝 받을 수 있게 지정해주세요.
service slapd stop
slapd -h "ldap://0.0.0.0:389" -u openldap -g openldap
service slapd start
3.4. Python을 이용한 LDAP 인증 정보 호출
LDAP Python 라이브러리 설치
# windows
pip install ldap3==2.6
# mac & linux
pip install python-ldap
LDAP 인증 테스트 코드
import ldap3
LDAP_SERVER = "ldap://localhost:389"
PEOPLE_DN = "ou=People,dc=example,dc=com"
ADMIN_DN = "cn=admin,dc=example,dc=com"
ADMIN_PW = "admin1234"
def ldap_authenticate(uid: str, password: str):
server = ldap3.Server(LDAP_SERVER, port=389)
# 1) 관리자 계정 접속
admin_conn = ldap3.Connection(server, user=ADMIN_DN, password=ADMIN_PW)
if not admin_conn.bind():
print("Failed to bind to LDAP server")
# 2) uidd와 password로 사용자 검색
admin_conn.search(
search_base=PEOPLE_DN,
search_filter=f"(&(uid={uid})(userPassword={password}))",
search_scope=ldap3.SUBTREE,
attributes=["cn", "mail", "uid"]
)
if not admin_conn.entries:
admin_conn.unbind()
return None
entry = admin_conn.entries[0]
admin_conn.unbind()
return {
"uid": str(entry.uid),
"cn": str(entry.cn),
"mail": str(entry.mail),
}
if __name__ == "__main__":
while True:
uid = input("Enter UID: ")
password = input("Enter password: ")
print(f"""
[USER INFORMATION]
{ldap_authenticate(uid, password)}
""")

4. 취약점 분석 및 실습
취약점 포인트
# 2) uidd와 password로 사용자 검색
admin_conn.search(
search_base=PEOPLE_DN,
search_filter=f"(&(uid={uid})(userPassword={password}))",
search_scope=ldap3.SUBTREE,
attributes=["cn", "mail", "uid"]
)
모든 코드 인젝션 문제는 단순하게도 사용자의 입력을 신뢰하여 일어난다.
해당 코드의 search_filter에 들어가는 입력 값을 이용하여 사용자를 특정하고 있다. 이 filter의 값을 변경하여 질의 자체를 바꿀 수 있다는 점이 LDAP Injection 취약점의 포인트이다.
전체 와일드 카드 사용
(&(uid=*)(userPassword=*))
uid: *
password: *

해당 경우에는 모든 조건이 참이 되어 첫 번째 유저를 반환하게 된다.
Password 와일드 카드 사용
(&(uid=hong)(userPassword=*))
uid: hong
password: *

해당 경우는 uid=hong을 특정하고 비밀번호 검증을 무력화 하게된다.
부분 와일드 카드 사용
(&(uid=h*)(userPassword=*))
uid: h*
password: *

해당 경우에는 h로 시작하는 UID 중 첫 번째를 결과로 가진다. *를 이용해서 substring match를 사용할 수 있다는 의미이다.
반면
(&(uid=hong)(userPassword=p*))
uid: hong
password: p*

userPassword에 p*를 삽입하면 결과가 나오지 않는 것을 볼 수 있다. 그 이유는 userPassword는 기본적으로 equaility match만 허용하기 때문에 단일 와일드카드로 비교하지 않는 이상 참/거짓 유무를 확인할 수 없다.
RFC 4519
그 근거를 찾기위해 우선 userPassword 타입을 확인해봤다.
ldapsearch -x -D "cn=admin,dc=example,dc=com" -w admin1234 \
-b "cn=Subschema" -s base "(objectClass=subschema)" attributeTypes \
| grep -i "userpassword"

RFC 4519를 확인해보니 Section 2.41에 기재되어 있다.

여기서 userPassword에 EQUALITY octetStringMatch만 존재하는 것을 확인할 수 있다. 만약 우리가 원하는 Substring이 가능하려면 아래 사진과 같이 SUBSTR라는 항목이 존재해야 한다.

만약 LDAP Injection을 통해 Blind Injection 가능 여부를 판단할 때, 추측되는 attribute type이 SUBSTR 특성을 지니고 있는지 확인해보면 될 것 같다.
정리해보자!
- 와일드카드(*)를 이용하면 단일 조회의 경우 첫 번째 결과를 가져온다.
이는 LDAP의 절대적인 특성이 아니라 일반적으로 코드 수준에서 이와 같이 구현한다. ex) conn.entires[0] - 와일드카드(*)를 이용하여 substring match로 사용이 가능하다.
- userPassword 처럼 substring match가 아닌 equaility match만 가능한 attribute들이 존재한다. 이러한 경우 와일드 카드로 blind injection을 통해 값을 직접적으로 알아내는 것은 어렵다.
5. 대응방안
자 그럼 가장 중요한 대응방안에 대한 이야기이다.
5.1. 관리자 바인드로 이스케이핑된 UID 검색
safe_uid = ldap3.utils.conv.escape_filter_chars(uid)
admin_conn.search(
search_base=PEOPLE_DN,
search_filter=f"(uid={safe_uid})",
search_scope=ldap3.SUBTREE,
attributes=["cn", "mail", "uid", "dn"]
)
if not admin_conn.entries:
admin_conn.unbind()
return None
user_dn = admin_conn.entries[0].entry_dn
admin_conn.unbind()
uid의 값은 바로 신뢰하지 않고 이스케이프하여 할당하고, uid와 password를 동시 조건으로 사용하는 것이 아니라 분리하여 검증한다.
5.2. 사용자 DN으로 바인드 및 password 검증
user_conn = ldap3.Connection(server, user=user_dn, password=password)
if not user_conn.bind():
return None
user_conn.unbind()
return {"uid": uid, "dn": user_dn}
위에서 찾은 DN과 password를 이용하여 직접 바인드를 시도하여 로그인 성공 여부를 확인한다.
해당 조치 방안은 다음과 같은 효과를 갖는다.
- uid에 대한 값은 이스케이핑 처리되어 이스케이프 함수에 제로데이가 발견되지 않는 이상 신뢰 가능하다.
- 패스워드가 검색 필터에 들어가지 않으므로 패스워드 필드에 대한 인젝션 경로가 완전히 차단된다.
- LDAP 서버의 bind를 이용하여 해시 비교와 계정 정책등을 자체 처리할 수 있다. (이미 잘 구현되어 있는 기능을 가져다 쓰므로 인증 로직에 대한 휴먼 에러를 줄일 수 있다.)
- 평문 패스워드가 검색 필터 문자열로 노출되지 않는다.
LDAP 서버에 어떤 필터로 요청이 왔는지 로그를 남기고 있다고 가정해보자. 필터에 비밀번호 평문이 포함되면 LDAP 서버에 사용자 비밀번호 평문이라는 민감한 정보가 로그에 남게 된다.
반면, LDAP bind 요청은 credentials 필드에 담겨 전송되기 때문에 LDAP 서버에서 로그로 기록하지 않는다.
만약 세상이 녹록치 않아 이러한 대응방안을 적용할 수 없다면, 입력값에 대한 이스케이핑 처리라도 적용해야 한다.
safe_uid = ldap3.utils.conv.escape_filter_chars(uid)
safe_pw = ldap3.utils.conv.escape_filter_chars(password)
admin_conn.search(
search_base=PEOPLE_DN,
search_filter=f"(&(uid={safe_uid})(userPassword={safe_pw}))",
search_scope=ldap3.SUBTREE,
attributes=["cn", "mail", "uid"]
)
6. 트러블 슈팅
6.1. session terminated by server
해당 경우는 Docker 컨테이너 내부에서 LDAP 서비스가 꺼져있을 확률이 크다.
docker exec -it ldap-server /bin/sh
service slapd start
위와 같이 컨테이너에 service slapd start 명령어를 수행해주도록 하자
7. 후기
코드 인젝션에 옛날 고조선 시대에 사용했을 법한 스택들이 모여있어서 마주하는 것을 미루어왔다. 최근에 필요한 SSTI 정도만 공부했는데 이참에 최신화된 주통기를 보면서 하나씩 정리할 예정이다.
사실 입사 면접을 보는데 당당하게 코드인젝션에 대한 진단도 가능할거라 자만하고 답했는데, 이젠 잘 안나오는 옛날 코드 인젝션들도 진단 가능한거냐고 물어보셔서, 잘못 대답한 것 같다고 정정 했었다.
당당하게 할 수 있다고 대답한게 부끄러워서 지금이라도 공부한다...