Bash shell scripting introduction

10 minute read

Shells come in many varieties, the two that I am most familiar with are bash and zsh. I use zsh and in fact, since 2019, macOS has been shipping with zsh as the default shell. Being able to effectively write a shell script is a super power. There is a ton of automation that can be done with shell scripts, and what’s possible to write is only limited by your imagination. The purpose of this post is to give a high-level overview of what shell scripts are, and to dive right in to an introduction of things to know when crafting shell scripts. We’ll only skim the surface of shell scripts by reviewing variables, the test command, if-else statements, and some other basics to get you started.

A hello world script

Let’s dive right into it with a Hello World! script!

#!/bin/sh
# This is a bash script to display 'Hello World!'
echo "Hello World!"

Breakdown:

  • #!/bin/sh - the ‘shebang’, this is needed at the top of the file to indicate that it is a shell script
  • # This is a bash script to display 'Hello World!' - anything starting with # is a single-line comment
  • echo " Hello World!" - the echo command just echos back whatever input was made

We can see in this StackOverflow article that the ‘shebang’ tells the OS what interpreter to use to run the program. I don’t have a perl or php interpreter on my local, so replacing the shebang with examples other than sh such as:

  • false
  • perl
  • php

results in failed execution of the script, but once we revert it to sh then it can run successfully: Example shebang usage

You’ll also want to run chmod +x <name of file> in order to be able to execute the program. And as seen in the above screenshot, you execute the program by prepending ./ to the name of the file.

Now that we’ve got the very basics out of the way, let’s look into some other features of programming shell scripts.

Initializing variables

To initialize a variable, type in the name of the variable that you want to initialize followed by an equal (=) sign and the value that you want to assign it, some examples:

  • x=test
  • my_name=Elijah
  • the_year=2023

You can’t space out the end of the variable name from the equal sign to the beginning of the variable value. You also can’t simply declare a variable without initializing it. This is because if you simply try to declare a variable, you’ll receive a command not found: error

To see the value of the variable, run: echo $variable. Here are some basic variable rules regurgitated from here:

  • A variable in single quotes ' is treated as a literal string, and not as a variable.
  • Variables in quotation marks " are treated as variables.
  • To get the value held in a variable, you have to provide the dollar sign $.
  • A variable without the dollar sign $ only provides the name of the variable.

The availability of the variable only lasts as long as the command-line window or program is active.

You can also reference other variables in another variable, here’s an example:

  • combined_variable="$x $my_name" will print out test Elijah when used with the echo statement: echo $combined_variable

The test command

While I was learning about shell scripts initially, I remember being frightened away from them because of all of the ‘weird’ syntax is that gets combined together, this was several years ago now, because I never really dove into the inner workings of said syntax.

Today, however, I can’t help but to get entrenched in fascination with the ways in which completely different syntax can perform the same task.

Take the test command for instance, running man test prints out the following on the command line:

The test utility evaluates the expression and, if it evaluates to true, returns a zero (true) exit status; otherwise it returns 1 (false).  If there is no expression, test also returns 1 (false).

What follows in the manual are a series of flags that you can use in combination with the test command to run different kinds of boolean evaluations (don’t worry about memorizing, or frankly becoming super familiar with all or any of these flags):

-b file       True if file exists and is a block special file.

-c file       True if file exists and is a character special file.

-d file       True if file exists and is a directory.

-e file       True if file exists (regardless of type).

-f file       True if file exists and is a regular file.

-g file       True if file exists and its set group ID flag is set.

-h file       True if file exists and is a symbolic link.  This operator is retained for compatibility with previous versions of
              this program.  Do not rely on its existence; use -L instead.

-k file       True if file exists and its sticky bit is set.

-n string     True if the length of string is nonzero.

-p file       True if file is a named pipe (FIFO).

-r file       True if file exists and is readable.

-s file       True if file exists and has a size greater than zero.

-t file_descriptor
              True if the file whose file descriptor number is file_descriptor is open and is associated with a terminal.

-u file       True if file exists and its set user ID flag is set.

-w file       True if file exists and is writable.  True indicates only that the write flag is on.  The file is not writable on a
              read-only file system even if this test indicates true.

-x file       True if file exists and is executable.  True indicates only that the execute flag is on.  If file is a directory,
              true indicates that file can be searched.

-z string     True if the length of string is zero.

-L file       True if file exists and is a symbolic link.

-O file       True if file exists and its owner matches the effective user id of this process.

-G file       True if file exists and its group matches the effective group id of this process.

-S file       True if file exists and is a socket.

file1 -nt file2
              True if file1 exists and is newer than file2.

file1 -ot file2
              True if file1 exists and is older than file2.

file1 -ef file2
              True if file1 and file2 exist and refer to the same file.

string        True if string is not the null string.

s1 = s2       True if the strings s1 and s2 are identical.

s1 != s2      True if the strings s1 and s2 are not identical.

s1 < s2       True if string s1 comes before s2 based on the binary value of their characters.

s1 > s2       True if string s1 comes after s2 based on the binary value of their characters.

n1 -eq n2     True if the integers n1 and n2 are algebraically equal.

n1 -ne n2     True if the integers n1 and n2 are not algebraically equal.

n1 -gt n2     True if the integer n1 is algebraically greater than the integer n2.

n1 -ge n2     True if the integer n1 is algebraically greater than or equal to the integer n2.

n1 -lt n2     True if the integer n1 is algebraically less than the integer n2.

n1 -le n2     True if the integer n1 is algebraically less than or equal to the integer n2.

As we can see, there are many choices, but I suspect only a few are used often. The -z flag being one that I know is used often, especially for error handling.

Recall that the -z flag returns true if the length of string is zero. This would help in situations where a user fails to supply the program a string because the program could simply exit out with some sort of echo message.

But what was also interesting to me was how these flags can also be used with if-else statements. We’ll talk more about this when we get to that section, but for now, let’s run a few example test commands to show how it works.

The basic syntax for using the test command looks like this:

test “var1” operator “var2”

An example looks like this:

test 10 -eq 20

This will run just fine, but we won’t receive any output. Recall that -eq is an ‘equals’ boolean evaluation. The command-line will silently return false in the background. However, we can add to this script to get human-readable output:

test 10 -eq 20 && echo "true" || echo "false"

This will return false and if we change it such that the two integers are equal to one another, then it will return true.

Continuing to build on this, we don’t even have to use the test command, instead we can use square brackets:

[ 10 -eq 20 ] && echo "true" || echo "false"

Running the above will also return false. Interesting, those square brackets might come in handy with if-else statements, if you’d like to read more about the test command, I recommend checking out this link.

If-else statements

If-else statemens are the bread and butter of any programming language, whether we explicitly state them like we do when we explicitly use an if-else statement or implicitly execute an if-else statement like we did with the test command, the if-else concept is a cornerstone of programming.

If you want to read more about if-else statements, you can check out this link. I’m going to keep my example much more brief.

An example if-else statement program that builds on the test command discussed earlier looks like this:

#!/bin/bash

echo -n "Enter a number: "
read VAR

if [ $VAR -gt 10 ]
then
  echo "The variable is greater than 10."
elif [ $VAR -eq 10 ]
then
  echo "The variable is equal to 10."
else
  echo "The variable is less than 10."
fi

This program reads a VAR environment variable, runs an if-else statement of the user inputted value, and then returns a statement about whether the value was greater than, equal to, or less than 10. You can read more about the read command here.

The VAR command could have been anything, we’re simply creating an ephemeral environment variable to use in our program until the program exits. A few more things I’d like to point out are:

  • elif is just a short-hand for else-if
  • After if and elif we pass the then keyword, but not after the else
  • We end if-else statements with the fi keyword to finish the statement, this holds true for if, if-else, and if-else-if statements

Notice that we’re using special keywords in combination with the test command alias of square brackets to craft our if-else statements. Also take note that we’re only using a single set of square brackets, and that we have spaces inside of the square brackets. We could use single or double square brackets, but for reasons that you can read more about here we really only need a single set of square brackets. If we don’t include spaces inside of our square brackets, the program will error, so keep that in mind. With that said, you will receive instant feedback if you forget to include spaces that looks something like this:

./elif_demo.sh: line 6: [: missing `]'

The single most important takeaway that I want to mention with regards to the test commmand and the if-else statement is that when you are reading through bash scripts that contain if-else statements, you are also implicitly seeing the test command in action. I thought this was really interesting which is why I wanted to discuss the test command before diving into the if-else statement. I first learned about this relationship in this StackOverflow link.

Conclusion

I hope you enjoyed reading about some high-level shell scripting capabilities, the possibilities are truly endless with shell scripting, and as one grows more advanced in their information security career, they’ll undoubtedly write shell scripts to help automate tasks to use against targets. As I continue to learn about shell scripting, I may either update this blog, or create additional posts to discuss specific learnings.

For my own learnings, I have begun to maintain a repository that contains bash scripts, you can find it here:

This will continue to grow as I add more scripts that can be used. Ideally, I’d like to separate out the repository into scripts that I found from other people and scripts that I have created myself through the use of some folders.

Cheers!