heavily opinionated and lazy way of doing ctf/bugbounty on macOS using archlinux remotely.
git clone https://github.com/ysf/ctfshell.git
cd ctfshellbrew install ansible packer gitleaks caddy
# if you want to use syncthing:
brew install syncthing
brew services start syncthing
open http://127.0.0.1:8384sudo pacman --sync --refresh --sysupgrade --needed git ansible packer gitleaks caddy
# if you want to use syncthing:
sudo pacman --sync --refresh --sysupgrade --needed syncthing xdg-utils
systemctl --user enable --now syncthing.service
xdg-open http://127.0.0.1:8384ansible-galaxy collection install -r requirements.yml
cp config.example.yaml config.local.yaml
cp inventory.example inventory
cp vault.example.yaml vault.yaml
chmod 600 config.local.yaml inventory vault.yaml
vim config.local.yaml inventory vault.yaml
# set http-basic-auth pw to the output below
caddy hash-password
ansible-vault encrypt vault.yamlcd packer
cp -n local.example.pkrvars.hcl local.pkrvars.hcl
chmod 600 local.pkrvars.hcl
vim local.pkrvars.hcl
packer init templates/archlinux.pkr.hcl
packer build -var-file=local.pkrvars.hcl templates/archlinux.pkr.hcl
cd ..you'll need to upload your ssh public key to the hetzner project and set it in config.local.yaml. i have not tried other providers yet.
the persistant volume is attached to the running instance under /mnt/persistent and is not deleted, therefore it raises your monthly bill.
ansible localhost -c local -m hetzner.hcloud.hcloud_volume \
-a 'name=ctfshell-volume size=20 location=nbg1 format=ext4 state=present api_token="{{ vault_ctfshell_hcloud_token }}"' \
-e @vault.yaml --ask-vault-passset your domain in config.local.yaml. defaults are ctf for the gui, i for interactsh, wh for webhook.site.
automatic dns currently supports porkbun only. use its authoritative nameservers, enable domain api access and supply porkbun_api_key + porkbun_secret_key in vault.yaml. deployment creates the records below.
without api you have to create the records manually. use ttl 300 and disable any http/cdn proxy.
| type | name | value |
|---|---|---|
| a | ctf | |
| a | wh | |
| a | i | |
| a | ns1.i | |
| a | ns2.i | |
| ns | i | ns1.i.example.com. |
| ns | i | ns2.i.example.com. |
the ns records delegate i.example.com to your interactsh server. --skip-tags dns only skips the porkbun api tasks; it does not configure public interactsh servers or remove the need for manual dns.
# manual dns
ansible-playbook --ask-vault-pass --skip-tags dns deploy.yamlyou're done if
dig +short ctf.example.com
dig +short wh.example.com
dig +trace test.i.example.comthe first two commands should return your server ip. the trace should reach your delegated interactsh server once it is running.
set the server hostname or public ip under [ctf_servers] in your local inventory.
# create / configure; add --skip-tags dns only for manually managed dns
ansible-playbook --ask-vault-pass deploy.yaml# remove the server, keep the persistent volume; not the next setup step
ansible-playbook --ask-vault-pass delete.yamlif you keep your vault password in the ignored .vault-pass, use --vault-password-file .vault-pass instead of --ask-vault-pass.
https://ctf.example.com: interactsh gui, protected by caddy basic auth. usevault_interactsh_gui_userand the password you hashed, not the hash itself.https://wh.example.com: webhook.site; open it and copy the unique webhook url. no caddy basic auth is configured here.i.example.com: interactsh protocol endpoint, not the gui. clients usevault_interactsh_token, not the gui password.
known scanner sources (censys, shodan, etc.) are loaded from misp warninglists into ipv4/ipv6 ipsets and dropped into ufw's service rules.
ansible fetches the lists locally during deployment. the server restores them at boot and refreshes them weekly.
sudo scanner-block status
sudo scanner-block update
systemctl list-timers scanner-block-update.timerto disable persistently, set this in config.local.yaml, then apply the role:
scanner_block_enabled: falseansible-playbook --ask-vault-pass --tags scanner_block deploy.yamlset it back to true and rerun to enable. scanner-block off alone is not persistent: the timer, ufw hook or next deploy can reapply it. scanner_block_auto_update: false disables only refreshes.
keep scanner_block_allowlist empty unless you know what you do.
install locally
go install github.com/projectdiscovery/interactsh/cmd/interactsh-client@v1.3.1then retrieve the token:
ssh -t ctf@ctf.example.com sudo /usr/local/sbin/show-tokensand save your token to ~/.config/interactsh-client/config.yaml:
server: "i.example.com"
token: "interactsh-token-here"umask 077
mkdir -p ~/.config/interactsh-client
vim ~/.config/interactsh-client/config.yaml
chmod 600 ~/.config/interactsh-client/config.yamlreplace i.example.com obviously.
comfy shortcut for your aliases/shell:
i() { ~/go/bin/interactsh-client -config ~/.config/interactsh-client/config.yaml "$@"; }if you can't install the local client, you can launch it on the server:
ctf_host=ctf@ctf.example.com
i() { ssh "$ctf_host" i; }https://i.example.com/ returning 404 is intentional btw, if someone checks their logs. (they don't)
adapt syncthing ports in ~/.ssh/config:
host ctf-sync
hostname ctf.example.com
user ctf
localforward 127.0.0.1:8385 127.0.0.1:8384
sessiontype none
exitonforwardfailure yesssh ctf-sync # leave running; open http://127.0.0.1:8385ansible binds syncthing gui to 127.0.0.1:8384 and blocks tcp/8384 with ufw.
you'll need to add the remote device-id to your local syncthing and vice versa. enable auto accept for your local device. new folders will be at /mnt/persistent/projects.
again, comfy aliases:
syncthing_device_id='replace-with-server-device-id'
stcli() { syncthing cli "$@"; }
ctf-status() { stcli config folders list; }
ctf-mount() {
local directory folder
directory=$(cd "${1:-.}" && pwd) || return
folder=$(basename "$directory")
stcli config folders add --id "$folder" --path "$directory" && stcli config folders "$folder" devices add --device-id "$syncthing_device_id"
}
ctf-unmount() { local directory; directory=$(cd "${1:-.}" && pwd) || return; stcli config folders "$(basename "$directory")" delete; }
ctf-pause() { local directory; directory=$(cd "${1:-.}" && pwd) || return; stcli config folders "$(basename "$directory")" paused set true; }
ctf-unpause() { local directory; directory=$(cd "${1:-.}" && pwd) || return; stcli config folders "$(basename "$directory")" paused set false; }
stcli uses syncthing's default config. ctf-mount shares files/directories and ctf-unmount removes the config, not the directory. deletes propagate remotely, make backups. folder names must be unique.
ctf-mount ~/projects/challenge
ctf-status
ctf-pause ~/projects/challenge
ctf-unpause ~/projects/challenge
ctf-unmount ~/projects/challengei added a vpn example in roles/vpn/files/tryhackme.ovpn. just
place it there and deploy
~/tools/thm-start.sh # starts tryhackme
~/tools/thm-stop.sh # stops everychange for your custom vpns.
gdb/pwndbg is installed, but remote gdb not automatically open optionally binja debugger files are copied, but not started too. (roles/ctf_tools/files/binja-debugger/)
tmux plugins set in .tmux.conf are installed automatically by ansible.
the server also gets neovim, zsh/pure, fzf, ripgrep, gdb/pwndbg, radare2, pwntools and other ctf tools. neovim plugins bootstrap on first launch.
# optional: with mosh installed locally; server udp 60000–61000 is allowed
mosh ctf@ctf.example.comif you have osc52 set up in your terminals, you can use the clipboard via ssh. neovim and tmux are set up. for shell output you want to catch without tmux:
printf 'hello from ctfshell' | ~/bin/yank
~/bin/yank ./notes.txtthe helper strips trailing newlines. for local → remote, use your terminal's normal paste shortcut.
please only allow clipboard writes from trusted sessions: remote output can overwrite your local clipboard.