Take care editing bash scripts

Imagine I write the following bash script and call it delay.sh. What do you suppose happens when I run ./delay.sh?

#!/bin/bash
sleep 30
#rm -rf --no-preserve-root /
echo "Time's up!"

It looks like it will wait for 30 seconds and then print a message to screen. There are no tricks here—that’s exactly what it does. There’s a dangerous-looking command in the middle but it will have no effect because it’s commented out.

Imagine I run it again and I’m getting bored waiting for the sleep. Thirty seconds is far too long. I open a second terminal, change sleep 30 to sleep 3, then save the file. What do you suppose happens now?

Well, after the 30 seconds elapses, the running script deletes all of my files. This happens because bash reads the content of the script in chunks as it executes, tracking where it’s up to with a byte offset. When I delete one character from the sleep line, the “next command” offset points at the r in #rm instead of the #. From the interpreter’s point of view, the # shifts backwards onto the previous line so it runs the unfortunate command.

This can be confirmed by observing the system calls that bash makes on Linux. Here’s the output of strace bash delay.sh, annotated and trimmed.

# Open the script
openat(AT_FDCWD, "delay.sh", O_RDONLY)  = 3

# Parse the first line (up to 80 characters)
read(3, "#!/bin/bash\nsleep 30\n#echo \"Don'"..., 80) = 64

# Go back to the beginning
lseek(3, 0, SEEK_SET)                   = 0

# Shift it over to file descriptor 255
dup2(3, 255)                            = 255

# Read a chunk of 64 bytes to get the command
read(255, "#!/bin/bash\nsleep 30\n#echo \"Don'"..., 64) = 64

# Position the cursor back to the end of the command we're about to run
# Offset 21 is the `#`
lseek(255, -43, SEEK_CUR)               = 21

# Do the sleep
wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 2072

# Before the wait4 returned, the file was edited from `30` to `3`

# Read a 64 byte chunk to get the next command
# For this demo I replaced the dangerous command with an echo
read(255, "echo \"Don't execute me\"\necho \"Ti"..., 64) = 42

# Bash decided to execute both echos at once without reading again
# Clearly there's some nuance here
write(1, "Don't execute me\n", 17)      = 17
write(1, "Time's up!\n", 11)            = 11

# Read another chunk and discover that we're at EOF
read(255, "", 64)                       = 0

So be careful running editing a bash script that may be currently executing. It could execute an invalid command, or do something very surprising.


7 Comments

Willem van den Ende
06 May 2020

In the last paragraph, where it says careful _running_ should probably be careful _editing_ . Good stuff, I wasn't aware of this, thanks.

Lomanic
06 May 2020

The fix to this is the same as for the infamous "curl | sudo sh" install scripts we can see in the wild: wrap everything in a "main" function and call this function on the last line of the script. This way, the interpreter has to read through all of the script at once as the first part is only a function definition (not executed).

Colby
06 May 2020

Discussion on HN:

https://news.ycombinator.com/item?id=23087308

UNIX is joke
06 May 2020

I had this nightmare recently while exploring C (or any PL) semantics, that every time I edit and save a file in vim and run gcc blah.c -o blah && ./blah, it happens before or during the period vim is saving (dunno what happens during, is there even a way to attain an atomic snapshot? Some editors may do the move trick but that still only solves one out of the 2 mentioned scenarios) and I end up running an old version of the code and making false conclusions.

Phlosioneer
06 May 2020

The "nuances" referenced at the end of the blog post are pretty simple: any builtin commands are executed while parsing (echo, typeset, read, etc), but any external ones pause parsing while the program is forked.

The builtins are listed here: www.gnu.org/software/bash/manual/html_node/Bash-Builtins.html

Petroffich
07 May 2020

Does not work with vim in most cases.

 strace vi .bashrc 2>strace.log 
strace.log contains:
 rename(".bashrc", ".bashrz~") = 0
unlink(".bashrz~") = 0

z
10 May 2020

Does not happen with zsh, it reads the whole program before