Why Write Bash Scripts for CTF Instead of Ready-Made Frameworks
Three situations in which bash wins against any Python framework or specialized tool. More information in our material about creating ctf tasks.
There is nothing but shell on the target machine. Minimum containers, stripped Docker images, embedded devices - Python is not worth it, there is no place to put, sometimes there is no network out. Bash is everywhere where there is a terminal. In the terminology of MITRE ATT&CK it is a technique T1059.004 (Unix Shell) – the use of a full-time shell to execute commands on the target system. Atomic Red Team tests for T1059.004 include "Create and Execute Bash Shell Script" and "Harvest SUID executable files" - both run through sh on Linux: the first demonstrates the very fact of performing a bash script as a technique T1059.004, the second is a specific example of data collection for privesc through the search for SUID binary.
It is necessary to glue three utilities into one chain. Start nmap, pull open ports through grep, set the result in gobuster five lines on the bash. The same in Python with argparse, processing of exceptions and importing libraries — project on 50+ lines. On jeopardy-CTF with a limit of two hours per ten tasks this difference solves.
Understanding mechanics is more important than the speed of the instrument. When you write a port scanner on a clean bash, you understand how the TCP connection works. When you write a brutforser on curl, you see that the POST request is in the body. ffuf faster order, but ffuf - a black box. Writing scripts for CTF on bash forms an intuition that GUI tools do not give. After the hands have assembled your scanner, nmap ceases to be magic - it becomes clear that inside the same SYN/ACK.
Before the first script, three quick checks. Bash version: bash --version, need 4.x or higher (on macOS by default costs 3.2 due to licensing restrictions — update through brew install bash; on Windows use WSL2). Tools: nmap, curl, grep, awk – in Kali and Parrot everything is out of the box, on minimal distributions deliver through apt install nmap curl. Working Directory and Shebang: mkdir ~/ctf && cd ~/ctf, each script starts with #!/bin/bash, after creating the file — chmod +x script.sh. Without chmod get Permission denied Everyone goes through this trap.
Ports on clean Bash without nmap
No Russian-language CTF automation guide covers this technique, although it is critically useful in the situation from the introduction: scanning ports without a single external utility.
Bash (exactly bash, not sh and not dash) supports pseudo-devices /dev/tcp/HOST/PORT. When trying to open this path, the shell initiates a TCP connection. The port is open – the connection is established, the command is completed with code 0. Closed — timeout or failure, the return code is non-zero. This is the construction of a minimum scanner for the selection of bash ports.
#!/bin/bash
HOST="${1:?Использование: $0 <IP>
RANGE="${2:-1-1024}"
IFS='-' read -r START END <<< "$RANGE"
for ((port=START; port<=END; port++)); do
(echo >/dev/tcp/$HOST/$port) 2>/dev/null && \
echo "[+] $port open"
done
Construction ${1:?...} checks the presence of an argument - if the IP is not transmitted, the script will output a message and end. The second argument is the port range in the format 1-1024, default first 1024. Team IFS='-' read -r START END breaks the line by hyphen into two variables.
The key line — (echo >/dev/tcp/$HOST/$port) 2>/dev/null. Round brackets run the command into subshell so that the connection error does not kill the main script. Redirect 2>/dev/null suppresses messages "Connection". Operator && works only with a successful connection.
Speed: 1024 ports sequential scan takes 2-5 minutes depending on network timeouts. To speed up, add & at the end of the line with echo — bash will run checks in parallel in background processes, and wait after done wait for the completion of all. Careful: with the range 1-65535, this generates tens of thousands of processes. Limit parallelism with packs: insert [[ $(jobs -r | wc -l) -ge 50 ]] && wait -n inside the cycle. Consider that jobs -r in bash is updated inaccurately in scripting mode and the real number of processes may exceed the limit. A more reliable option is to transfer the list of ports through xargs -P50 -I{} bash -c '(echo >/dev/tcp/$HOST/{}) 2>/dev/null && echo "[+] {} open"' or use a FIFO-based semaphore. Test the real load before being used for combat purposes.
This approach is described in the Penetration Testing Lab EN source as a training TCP scanner. It doesn’t replace nmap — no version definition, no NSE scripts, no UDP. But when there is nothing on the target car, /dev/tcp - the only option. And he works.
When /dev/tcp is not working
Pseudodection /dev/tcp – feature bash, not POSIX standard. On systems with dash by default (Ubuntu, Debian) script must be run explicitly through bash script.sh, not sh script.sh. BusyBox ash also does not support /dev/tcp - and that's where the dances start. Alternative – nc -z -w1 $HOST $port 2>/dev/null && echo "open". Flag -z puts netcat into scanning mode without data transfer, -w1 sets a timeout per second. If there is no netcat, it remains to load the statically compiled nmap binary through curl or wget from your machine.
Parsing output: grep, awk and sed for CTF
Running nmap is half the case. The second half is to pull the desired data out of the output and put it in the next tool. Bash command output parsing is a skill that saves minutes on each CTF machine.
Base bundle: nmap -sV -sC -oN scan.txt TARGET saves the result in a text format suitable for grep. Next, we work with the file.
Extract open ports through the comma for substitution in other utilities: grep '/tcp.*open' scan.txt | cut -d'/' -f1 | tr '\n' ',' | sed 's/,$//'. Conveyor of four teams: grep finds strings with open TCP ports, cut cut the port number to the symbol /, tr replaces translations of lines into commas, sed removes the final comma. Result: 22,80,443,8080 – finished line for nmap -p 22,80,443,8080 on the second pass with deep scripts.
Find HTTP services for further web crawling: grep -E 'http|https' scan.txt | grep -oE '^[0-9]+' | head -5. The first grep looking for strings with the mention of HTTP services, the second extracts port numbers from the beginning of the line. The result is a list of ports that can be pitted gobuster or nikto.
Searching for flags on the file system is an absolute classic. One-line grep -rE 'flag\{|CTF\{|HTB\{|THM\{' / 2>/dev/null recursively looking for strings in standard flag formats. Redirect 2>/dev/null suppresses access errors to secure directories. Alternative: find / -name "*.txt" -exec grep -l "flag" {} \; 2>/dev/null – looking for files containing the word “flag”. MITRE ATT&CK classifies this as T1083 (File and Directory Discovery) and T1119 (Automated Collection).
For repetitive tasks, wrap parsing in a function:
extract_ports() {
grep '/tcp.*open' "$file" | \
cut -d'/' -f1 | tr '\n' ',' | sed 's/,$//'
}
# Использование: PORTS=$(extract_ports scan.txt)
# nmap -sV -p "$PORTS" $TARGET
The function accepts the file name, returns the port string. Save it in ~/.bash_ctf and add source ~/.bash_ctf in .bashrc – the function is available in each terminal without copying.
Awk bundle for structured output
awk more powerful grep for complicated parsing. Extract from nmap-output the table "port - service - version": awk '/^[0-9]+\/tcp/{print $1, $3, $4, $5}' scan.txt. The command processes the rows starting from the port number and outputs the desired columns. For tab-separated format: awk -F' +' '/^[0-9]+\/tcp/{OFS="\t"; print $1, $3, $4}' scan.txt.
sed will be useful for mass replacements and cutting sections: sed -n '/^PORT/,/^$/p' scan.txt – extract only a block with ports from the full output of nmap. The command prints lines from PORT to the first empty line.
The combination of grep, awk and sed for CTF is a universal parser of any text output. No need jq for JSON or xmlstarlet for XML - on the entry-level CTF text format nmap (-oN) covers the vast majority of tasks.
Mini Exploit: Brutforce Web Forms via Curl
Classical entry-level CTF scenario: A form of authorization with a numerical PIN or a short-style wordlist password. No rate limiting, no CSRF tokens. In OWASP terminology, it’s the intersection of Broken Authentication (API2:2023) and Unrestricted Resource Consumption (API4:2023) — the server does not limit the frequency of attempts. On the CTF, this is a deliberate weakness. MITRE ATT&CK classifies the overtaking of passwords as T1110.001 (Password Guessing).
Before writing a mini-exploit, bash is a mandatory manual exploration. Open the form in the browser, enter a deliberately incorrect password and look at the Developer Tools (Network tab). Fix four things: URL forms (e.g. http://target:8080/login), method (POST or GET), field names (user, pass) and the text of the answer in case of error (Access denied). Without these four values, the script shoots blind.
#!/bin/bash
TARGET="http://target:8080/login"
FAIL="Access denied"
while read -r pass; do
resp=$(curl -s -d "user=admin&pass=${pass}" "$TARGET")
echo "$resp" | grep -q "$FAIL" || \
{ echo "[+] Пароль: $pass"; exit 0; }
done < wordlist.txt
Construction while read -r pass Reading wordlist.txt string by line, placing each line in the variable pass. Flag -r prohibits bash from interpreting reverse slashes – without it, the password test\n123 will turn into testn123, and there will be no coincidence. Syntactic catch: < wordlist.txt standing after done, and not at the beginning of the cycle - it catches almost everyone the first time.
Team curl -s -d "user=admin&pass=${pass}" sends a POST request. Flag -s suppresses the progress bar. Line with -d wrapped in double quotes. In single quotes ${pass} not disclosed and flies to the server literally as text ${pass}. This mistake is consistently included in the top 3 of those I have mentored.
Checking grep -q "$FAIL" looking for a string of errors. Flag -q – quiet mode: does not output anything, only sets the return code. Operator || triggered when the error string is not found, so the answer is different from the standard failure. Figure brackets group the output and exit 0.
For numerical PIN replace while read on for pin in $(seq -w 0000 9999); do and set up $pin instead of $pass. Flag -w adds the leading zeros – without it seq Generates 1 instead of 0001, and the four-digit PIN won't match.
Restrictions of bash-brutfors
The script works when there are few options (up to 10 000-50 000) and the server does not limit requests. What is useless against: rate limiting (addition sleep 0.5 stretches the selection of 10 000 options from 40 seconds to 83 minutes), CSRF tokens (for each query you need a unique token - the script grows to 20+ lines and becomes fragile), JavaScript rendering shape. Take on real projects hydra for standard protocols (SSH, FTP, HTTP Basic Auth) or Python with requests.Session() for complex logic. Bash scripting for pentest through curl is a tool for training tasks and legacy applications without WAF.
Debugging bash scripts and typical errors
Write a script – 30% of the case. The other 70% understand why it doesn’t work. Three debugging tools, without which the Linux for beginners CTFs turns into endless bugging of errors.
set -x – tracing mode. Add after the shebang, and the bash will print each command before execution with the substituted variable values. See the difference between what you wrote and what bash is doing. For the shutdown — set +x. On the CTF, when the script silently works incorrectly, set -x Saves tens of minutes.
set -euo pipefail – protection against quiet mistakes. -e stops the script at the first fallen command (without it, the bash continues to execute – the intelligence script, where nmap returned the error, and the gobuster started without a target). -u swears at uninitialized variables instead of silently setting an empty line. -o pipefail monitors errors inside conveyors – without it false | echo "ok" will end successfully, although the first team fell.
Gaps in square brackets – everyone stumbles on this. Construction [ "$VAR" -ne 0 ] is the team challenge test, and the square bracket is its alias. Gaps around brackets and operator are mandatory. Recording ["$VAR"-ne 0] without gaps gives [: command not found. Recording [ "$VAR"-ne"0" ] perceived as one line instead of three arguments and also breaks.
Quotes around variables. if [ $VAR = "test" ] breaks if $VAR empty – bash will turn into if [ = "test" ], which is syntactically incorrect. Always wrap in double quotes: "$VAR". Inside [[ ]] bash processes empty variables correctly, but the habit of quotation marks will save when the script is transferred to other shells.
Wordlist encoding is my favorite pain. The file created on Windows contains \r\n instead of \n. Bash reads the line password\r – curl sends a password to the server with an invisible carriage return symbol. The password is “right” but does not match. Treatment: sed -i 's/\r$//' wordlist.txt before use. Or dos2unix wordlist.txt, if the utility is installed. I came across this problem three times before I started checking automatically – now in .bash_ctf The first line is a function clean_wordlist(), which does sed and sort -u on any input file.
From bash one-liners to reused tulkite
One-time scripts are the beginning. The next step is to collect a collection of features that work from CTF to CTF without edits.
Create a file ~/.bash_ctf with helper functions. extract_ports() parsit nmap output. flag_search() Recursively looking for flags: grep -rE 'flag\{|CTF\{|HTB\{|THM\{' / 2>/dev/null. Function serve() raises the HTTP server with one command: python3 -m http.server 8000. Add source ~/.bash_ctf in .bashrc – the entire set is available in each terminal. The difference between “I’m looking for a script that I wrote a month ago” and “I type recon 10.10.10.5 and I go to read the condition” is tangible. On the CTF with a time limit, this is decided.
tmux for parallel work. Run each scan in a separate panel: tmux new-session -d -s ctf for the establishment of the session, Ctrl+B % for vertical separation, Ctrl+B " for horizontal. In one panel - nmap, in the other - gobuster, in the third - notes. Automation of the routine tasks of the pentester is not only scripts, but also the organization of the workspace.
System data by one team. After receiving shell on the target machine - quick collection: id; uname -a; cat /etc/os-release; ip a; ss -tlnp. Five commands through a semicolon give a full picture: who you are, what OS, what network interfaces, which ports listen to. MITRE ATT&CK classifies this as T1082 (System Information Discovery). Wrap in Alias: alias sysinfo='id; uname -a; cat /etc/os-release; ip a; ss -tlnp' – on each new car you gain sysinfo instead of five teams.
SUID files to increase privileges. Finding binary with SUID-bit is one of the first steps of privesc: find / -perm -4000 -type f 2>/dev/null. Check the list with GTFOBins – if in the output bash, awk, find or other binary from the GTFOBins directory, it is a privilege increase vector. The Atomic Red Team "Harvest SUID executable files" test for T1059.004 does exactly this - looking for SUID binary through shell script.
Base64-decoding. Flags and hints on CTFs are often encoded in base64. One-line echo "dGVzdA==" | base64 -d decodes the string. For files: base64 -d encoded.txt > decoded.bin. For recursive search of lines similar to base64: grep -rEo '[A-Za-z0-9+/]{20,}={0,2}' /var/www/ 2>/dev/null | while read -r line; do echo "$line" | base64 -d 2>/dev/null; done – rough, but working approach for jeopardy tasks.
Each of these techniques is a building block. Bash scripts for CTFs do not require architecture and design patterns. They require a set of workpieces that are combined for the task in two minutes. File .bash_ctf of 10-15 functions, close the bulk of the routine on the entry- and mid-level CTF.
The position I defend in front of each new group: beginners should write their own bash wraps, even if ffuf or AutoRecon Do the same ten times faster. Not because the bash is better – because after writing your own port scanner through /dev/tcp a person understands that nmap inside makes a sequence of TCP SYN/ACK, not magic. After writing a brutforser on curl – understands that hydra inside sends exactly the same POST request, only in a thousand streams.
Three situations in which bash wins against any Python framework or specialized tool. More information in our material about creating ctf tasks.
There is nothing but shell on the target machine. Minimum containers, stripped Docker images, embedded devices - Python is not worth it, there is no place to put, sometimes there is no network out. Bash is everywhere where there is a terminal. In the terminology of MITRE ATT&CK it is a technique T1059.004 (Unix Shell) – the use of a full-time shell to execute commands on the target system. Atomic Red Team tests for T1059.004 include "Create and Execute Bash Shell Script" and "Harvest SUID executable files" - both run through sh on Linux: the first demonstrates the very fact of performing a bash script as a technique T1059.004, the second is a specific example of data collection for privesc through the search for SUID binary.
It is necessary to glue three utilities into one chain. Start nmap, pull open ports through grep, set the result in gobuster five lines on the bash. The same in Python with argparse, processing of exceptions and importing libraries — project on 50+ lines. On jeopardy-CTF with a limit of two hours per ten tasks this difference solves.
Understanding mechanics is more important than the speed of the instrument. When you write a port scanner on a clean bash, you understand how the TCP connection works. When you write a brutforser on curl, you see that the POST request is in the body. ffuf faster order, but ffuf - a black box. Writing scripts for CTF on bash forms an intuition that GUI tools do not give. After the hands have assembled your scanner, nmap ceases to be magic - it becomes clear that inside the same SYN/ACK.
Before the first script, three quick checks. Bash version: bash --version, need 4.x or higher (on macOS by default costs 3.2 due to licensing restrictions — update through brew install bash; on Windows use WSL2). Tools: nmap, curl, grep, awk – in Kali and Parrot everything is out of the box, on minimal distributions deliver through apt install nmap curl. Working Directory and Shebang: mkdir ~/ctf && cd ~/ctf, each script starts with #!/bin/bash, after creating the file — chmod +x script.sh. Without chmod get Permission denied Everyone goes through this trap.
Ports on clean Bash without nmap
No Russian-language CTF automation guide covers this technique, although it is critically useful in the situation from the introduction: scanning ports without a single external utility.
Bash (exactly bash, not sh and not dash) supports pseudo-devices /dev/tcp/HOST/PORT. When trying to open this path, the shell initiates a TCP connection. The port is open – the connection is established, the command is completed with code 0. Closed — timeout or failure, the return code is non-zero. This is the construction of a minimum scanner for the selection of bash ports.
#!/bin/bash
HOST="${1:?Использование: $0 <IP>
RANGE="${2:-1-1024}"
IFS='-' read -r START END <<< "$RANGE"
for ((port=START; port<=END; port++)); do
(echo >/dev/tcp/$HOST/$port) 2>/dev/null && \
echo "[+] $port open"
done
Construction ${1:?...} checks the presence of an argument - if the IP is not transmitted, the script will output a message and end. The second argument is the port range in the format 1-1024, default first 1024. Team IFS='-' read -r START END breaks the line by hyphen into two variables.
The key line — (echo >/dev/tcp/$HOST/$port) 2>/dev/null. Round brackets run the command into subshell so that the connection error does not kill the main script. Redirect 2>/dev/null suppresses messages "Connection". Operator && works only with a successful connection.
Speed: 1024 ports sequential scan takes 2-5 minutes depending on network timeouts. To speed up, add & at the end of the line with echo — bash will run checks in parallel in background processes, and wait after done wait for the completion of all. Careful: with the range 1-65535, this generates tens of thousands of processes. Limit parallelism with packs: insert [[ $(jobs -r | wc -l) -ge 50 ]] && wait -n inside the cycle. Consider that jobs -r in bash is updated inaccurately in scripting mode and the real number of processes may exceed the limit. A more reliable option is to transfer the list of ports through xargs -P50 -I{} bash -c '(echo >/dev/tcp/$HOST/{}) 2>/dev/null && echo "[+] {} open"' or use a FIFO-based semaphore. Test the real load before being used for combat purposes.
This approach is described in the Penetration Testing Lab EN source as a training TCP scanner. It doesn’t replace nmap — no version definition, no NSE scripts, no UDP. But when there is nothing on the target car, /dev/tcp - the only option. And he works.
When /dev/tcp is not working
Pseudodection /dev/tcp – feature bash, not POSIX standard. On systems with dash by default (Ubuntu, Debian) script must be run explicitly through bash script.sh, not sh script.sh. BusyBox ash also does not support /dev/tcp - and that's where the dances start. Alternative – nc -z -w1 $HOST $port 2>/dev/null && echo "open". Flag -z puts netcat into scanning mode without data transfer, -w1 sets a timeout per second. If there is no netcat, it remains to load the statically compiled nmap binary through curl or wget from your machine.
Parsing output: grep, awk and sed for CTF
Running nmap is half the case. The second half is to pull the desired data out of the output and put it in the next tool. Bash command output parsing is a skill that saves minutes on each CTF machine.
Base bundle: nmap -sV -sC -oN scan.txt TARGET saves the result in a text format suitable for grep. Next, we work with the file.
Extract open ports through the comma for substitution in other utilities: grep '/tcp.*open' scan.txt | cut -d'/' -f1 | tr '\n' ',' | sed 's/,$//'. Conveyor of four teams: grep finds strings with open TCP ports, cut cut the port number to the symbol /, tr replaces translations of lines into commas, sed removes the final comma. Result: 22,80,443,8080 – finished line for nmap -p 22,80,443,8080 on the second pass with deep scripts.
Find HTTP services for further web crawling: grep -E 'http|https' scan.txt | grep -oE '^[0-9]+' | head -5. The first grep looking for strings with the mention of HTTP services, the second extracts port numbers from the beginning of the line. The result is a list of ports that can be pitted gobuster or nikto.
Searching for flags on the file system is an absolute classic. One-line grep -rE 'flag\{|CTF\{|HTB\{|THM\{' / 2>/dev/null recursively looking for strings in standard flag formats. Redirect 2>/dev/null suppresses access errors to secure directories. Alternative: find / -name "*.txt" -exec grep -l "flag" {} \; 2>/dev/null – looking for files containing the word “flag”. MITRE ATT&CK classifies this as T1083 (File and Directory Discovery) and T1119 (Automated Collection).
For repetitive tasks, wrap parsing in a function:
extract_ports() {
grep '/tcp.*open' "$file" | \
cut -d'/' -f1 | tr '\n' ',' | sed 's/,$//'
}
# Использование: PORTS=$(extract_ports scan.txt)
# nmap -sV -p "$PORTS" $TARGET
The function accepts the file name, returns the port string. Save it in ~/.bash_ctf and add source ~/.bash_ctf in .bashrc – the function is available in each terminal without copying.
Awk bundle for structured output
awk more powerful grep for complicated parsing. Extract from nmap-output the table "port - service - version": awk '/^[0-9]+\/tcp/{print $1, $3, $4, $5}' scan.txt. The command processes the rows starting from the port number and outputs the desired columns. For tab-separated format: awk -F' +' '/^[0-9]+\/tcp/{OFS="\t"; print $1, $3, $4}' scan.txt.
sed will be useful for mass replacements and cutting sections: sed -n '/^PORT/,/^$/p' scan.txt – extract only a block with ports from the full output of nmap. The command prints lines from PORT to the first empty line.
The combination of grep, awk and sed for CTF is a universal parser of any text output. No need jq for JSON or xmlstarlet for XML - on the entry-level CTF text format nmap (-oN) covers the vast majority of tasks.
Mini Exploit: Brutforce Web Forms via Curl
Classical entry-level CTF scenario: A form of authorization with a numerical PIN or a short-style wordlist password. No rate limiting, no CSRF tokens. In OWASP terminology, it’s the intersection of Broken Authentication (API2:2023) and Unrestricted Resource Consumption (API4:2023) — the server does not limit the frequency of attempts. On the CTF, this is a deliberate weakness. MITRE ATT&CK classifies the overtaking of passwords as T1110.001 (Password Guessing).
Before writing a mini-exploit, bash is a mandatory manual exploration. Open the form in the browser, enter a deliberately incorrect password and look at the Developer Tools (Network tab). Fix four things: URL forms (e.g. http://target:8080/login), method (POST or GET), field names (user, pass) and the text of the answer in case of error (Access denied). Without these four values, the script shoots blind.
#!/bin/bash
TARGET="http://target:8080/login"
FAIL="Access denied"
while read -r pass; do
resp=$(curl -s -d "user=admin&pass=${pass}" "$TARGET")
echo "$resp" | grep -q "$FAIL" || \
{ echo "[+] Пароль: $pass"; exit 0; }
done < wordlist.txt
Construction while read -r pass Reading wordlist.txt string by line, placing each line in the variable pass. Flag -r prohibits bash from interpreting reverse slashes – without it, the password test\n123 will turn into testn123, and there will be no coincidence. Syntactic catch: < wordlist.txt standing after done, and not at the beginning of the cycle - it catches almost everyone the first time.
Team curl -s -d "user=admin&pass=${pass}" sends a POST request. Flag -s suppresses the progress bar. Line with -d wrapped in double quotes. In single quotes ${pass} not disclosed and flies to the server literally as text ${pass}. This mistake is consistently included in the top 3 of those I have mentored.
Checking grep -q "$FAIL" looking for a string of errors. Flag -q – quiet mode: does not output anything, only sets the return code. Operator || triggered when the error string is not found, so the answer is different from the standard failure. Figure brackets group the output and exit 0.
For numerical PIN replace while read on for pin in $(seq -w 0000 9999); do and set up $pin instead of $pass. Flag -w adds the leading zeros – without it seq Generates 1 instead of 0001, and the four-digit PIN won't match.
Restrictions of bash-brutfors
The script works when there are few options (up to 10 000-50 000) and the server does not limit requests. What is useless against: rate limiting (addition sleep 0.5 stretches the selection of 10 000 options from 40 seconds to 83 minutes), CSRF tokens (for each query you need a unique token - the script grows to 20+ lines and becomes fragile), JavaScript rendering shape. Take on real projects hydra for standard protocols (SSH, FTP, HTTP Basic Auth) or Python with requests.Session() for complex logic. Bash scripting for pentest through curl is a tool for training tasks and legacy applications without WAF.
Debugging bash scripts and typical errors
Write a script – 30% of the case. The other 70% understand why it doesn’t work. Three debugging tools, without which the Linux for beginners CTFs turns into endless bugging of errors.
set -x – tracing mode. Add after the shebang, and the bash will print each command before execution with the substituted variable values. See the difference between what you wrote and what bash is doing. For the shutdown — set +x. On the CTF, when the script silently works incorrectly, set -x Saves tens of minutes.
set -euo pipefail – protection against quiet mistakes. -e stops the script at the first fallen command (without it, the bash continues to execute – the intelligence script, where nmap returned the error, and the gobuster started without a target). -u swears at uninitialized variables instead of silently setting an empty line. -o pipefail monitors errors inside conveyors – without it false | echo "ok" will end successfully, although the first team fell.
Gaps in square brackets – everyone stumbles on this. Construction [ "$VAR" -ne 0 ] is the team challenge test, and the square bracket is its alias. Gaps around brackets and operator are mandatory. Recording ["$VAR"-ne 0] without gaps gives [: command not found. Recording [ "$VAR"-ne"0" ] perceived as one line instead of three arguments and also breaks.
Quotes around variables. if [ $VAR = "test" ] breaks if $VAR empty – bash will turn into if [ = "test" ], which is syntactically incorrect. Always wrap in double quotes: "$VAR". Inside [[ ]] bash processes empty variables correctly, but the habit of quotation marks will save when the script is transferred to other shells.
Wordlist encoding is my favorite pain. The file created on Windows contains \r\n instead of \n. Bash reads the line password\r – curl sends a password to the server with an invisible carriage return symbol. The password is “right” but does not match. Treatment: sed -i 's/\r$//' wordlist.txt before use. Or dos2unix wordlist.txt, if the utility is installed. I came across this problem three times before I started checking automatically – now in .bash_ctf The first line is a function clean_wordlist(), which does sed and sort -u on any input file.
From bash one-liners to reused tulkite
One-time scripts are the beginning. The next step is to collect a collection of features that work from CTF to CTF without edits.
Create a file ~/.bash_ctf with helper functions. extract_ports() parsit nmap output. flag_search() Recursively looking for flags: grep -rE 'flag\{|CTF\{|HTB\{|THM\{' / 2>/dev/null. Function serve() raises the HTTP server with one command: python3 -m http.server 8000. Add source ~/.bash_ctf in .bashrc – the entire set is available in each terminal. The difference between “I’m looking for a script that I wrote a month ago” and “I type recon 10.10.10.5 and I go to read the condition” is tangible. On the CTF with a time limit, this is decided.
tmux for parallel work. Run each scan in a separate panel: tmux new-session -d -s ctf for the establishment of the session, Ctrl+B % for vertical separation, Ctrl+B " for horizontal. In one panel - nmap, in the other - gobuster, in the third - notes. Automation of the routine tasks of the pentester is not only scripts, but also the organization of the workspace.
System data by one team. After receiving shell on the target machine - quick collection: id; uname -a; cat /etc/os-release; ip a; ss -tlnp. Five commands through a semicolon give a full picture: who you are, what OS, what network interfaces, which ports listen to. MITRE ATT&CK classifies this as T1082 (System Information Discovery). Wrap in Alias: alias sysinfo='id; uname -a; cat /etc/os-release; ip a; ss -tlnp' – on each new car you gain sysinfo instead of five teams.
SUID files to increase privileges. Finding binary with SUID-bit is one of the first steps of privesc: find / -perm -4000 -type f 2>/dev/null. Check the list with GTFOBins – if in the output bash, awk, find or other binary from the GTFOBins directory, it is a privilege increase vector. The Atomic Red Team "Harvest SUID executable files" test for T1059.004 does exactly this - looking for SUID binary through shell script.
Base64-decoding. Flags and hints on CTFs are often encoded in base64. One-line echo "dGVzdA==" | base64 -d decodes the string. For files: base64 -d encoded.txt > decoded.bin. For recursive search of lines similar to base64: grep -rEo '[A-Za-z0-9+/]{20,}={0,2}' /var/www/ 2>/dev/null | while read -r line; do echo "$line" | base64 -d 2>/dev/null; done – rough, but working approach for jeopardy tasks.
Each of these techniques is a building block. Bash scripts for CTFs do not require architecture and design patterns. They require a set of workpieces that are combined for the task in two minutes. File .bash_ctf of 10-15 functions, close the bulk of the routine on the entry- and mid-level CTF.
The position I defend in front of each new group: beginners should write their own bash wraps, even if ffuf or AutoRecon Do the same ten times faster. Not because the bash is better – because after writing your own port scanner through /dev/tcp a person understands that nmap inside makes a sequence of TCP SYN/ACK, not magic. After writing a brutforser on curl – understands that hydra inside sends exactly the same POST request, only in a thousand streams.