跳转至

Java代码审计-基础漏洞篇

SQL注入漏洞

成因

本质是将用户的输入当做代码执行,程序将用户的输入拼接到了sql语句中,改变原来sql语句的语义造成攻击。

常见的一些例子

DAO: 存在拼接的SQL语句

Java
String sql="select * from user where id="+id

payload:

Java
# Payload: id=" or 1=1 --+
String sql="select * from user where id=" or 1=1 --+"

如果sql语句变为String sql = "select id,username,password from sys_user where username = '" + username + "' and password = '" + password + "'";

那么payload还可以为:

Java
// payload
username=admin&password='=0#
String sql = "select id,username,password from sys_user where username = 'admin' and password = ''=0#'";

从而绕过了admin的密码校验

Hibernate框架

Java
session.createQuery("from Book where title like '%" + userInput + "%' and published = true")

Payload:

Java
# paylaod: userInput=x%' or 1=1 --+
session.createQuery("from Book where title like '%x%' or 1=1 --+ %' and published = true")

Mybatis框架

SQL
Select * from news where title like '%${title}%'
Select * from news where id in (${id})
Select * from news where title ='java' order by ${time} asc

Mybatis中的#和$变量的区别

1. # 占位符

  • 原理:# 占位符本质上是使用 PreparedStatement 的参数占位符(?)来实现的。当 MyBatis 解析 SQL 语句时,会将 # 占位符替换为 PreparedStatement 的参数占位符,并在执行 SQL 语句时通过 PreparedStatement 的 setXxx 方法动态设置参数值。
  • 安全性:由于 # 占位符是基于 PreparedStatement 的参数化查询机制,因此能够有效防止 SQL 注入漏洞。用户输入的参数值不会被直接拼接到 SQL 语句中,而是作为独立的参数传递给数据库,数据库会自动对参数值中的特殊字符进行转义,从而避免了恶意输入对 SQL 语句结构的篡改。

2. $ 占位符

  • 原理:$ 占位符是直接将变量的值拼接到 SQL 语句中。MyBatis 在解析 SQL 语句时,会将 $ 占位符替换为变量的实际值。
  • 安全性:由于 $ 占位符是通过字符串拼接的方式将变量值嵌入到 SQL 语句中,因此存在 SQL 注入的风险。如果用户输入的变量值包含恶意的 SQL 片段,这些片段可能会被错误地解释为 SQL 语句的一部分并执行,从而导致 SQL 注入漏洞。

无法预编译时如何防范?

在Java中,以下几种情况可能无法直接使用预编译(PreparedStatement)或参数化查询来防止SQL注入:

动态表名或列名

预编译语句无法处理动态表名或列名,因为这些内容需要在SQL语句中直接拼接,而不能作为参数传递。

Java
String tableName = "users";
String columnName = "username";
String sql = "SELECT " + columnName + " FROM " + tableName + " WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, userId);

ORDER BY 子句中的动态字段

ORDER BY 后面的字段名不能作为参数传递,因为字段名不能加引号。

Java
String orderBy = "username";
String sql = "SELECT * FROM users ORDER BY " + orderBy;
PreparedStatement ps = connection.prepareStatement(sql);

模糊查询中的通配符

在模糊查询中,LIKE 子句中的通配符(如 % 和 _)不能直接作为参数传递。

Java
String searchKeyword = "abc";
String sql = "SELECT * FROM users WHERE name LIKE ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setString(1, "%" + searchKeyword + "%");

动态SQL语句结构

如果SQL语句的结构(如条件判断、表连接方式等)需要根据用户输入动态生成,则无法完全使用预编译。

Java
String condition = "age > 18";
String sql = "SELECT * FROM users WHERE " + condition;
PreparedStatement ps = connection.prepareStatement(sql);

防范SQL注入的替代方法

在这些无法使用预编译或参数化查询的场景中,可以采用以下方法来防范SQL注入:

  • 严格的输入验证

对用户输入进行严格的验证和过滤,确保输入只包含合法字符。

  • 白名单机制

对于动态表名、列名或排序字段,使用白名单机制,只允许预定义的合法值。

  • 手动转义特殊字符

对用户输入进行手动转义,将特殊字符(如单引号 ')替换为安全的格式。

Java
public String escapeSQL(String input) {
    return input.replace("'", "''")
                .replace("--", "")
                .replace("/*", "")
                .replace("*/", "");
}

String tableName = escapeSQL(userInputTableName);
String columnName = escapeSQL(userInputColumnName);
String sql = "SELECT " + columnName + " FROM " + tableName + " WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, userId);

通过这些方法,可以在无法使用预编译或参数化查询的场景中有效降低SQL注入的风险。

Important

凡是字符串但又不能加引号的位置都不能参数化;包括sql关键字、库名表名字段名函数名等等。

审计方法

对于sql注入来讲,只要是与数据库存在交互的地方,应用程序对用户的输入没有进行有效的过滤,都有可能存在SQL注入漏洞。

在实际环境中**,中间件漏洞的sql注入漏洞可能更多:**

  • Mybatis框架中的like、in和order by语句。
  • Hibernate框架中的createQuery()函数

快速定位相关sql语句上下文,查看是否有显式过滤机制。

修复

  • 参数化查询,使用java.sql.PreparedStatement来对数据库发起参数化查询。
Java
stmt=conncetion.prepareStatement(sqlString);
                stmt.setString(1,userName);
                stmt.setString(2,itemName);
                rs=stmt.executeQuery();

参数化查询

PreparedStatement 使用参数化查询的方式,将 SQL 语句中的参数用占位符(?)代替,并在执行时通过方法(如 setString、setInt 等)动态设置参数值。这种方式使得 SQL 语句在预编译阶段就已经确定了结构,用户输入的参数仅作为值被传递,而不会被当作 SQL 语句的一部分进行解析和执行,从而避免了恶意输入对 SQL 语句结构的篡改。

预编译与执行分离

PreparedStatement 在创建时会将 SQL 语句发送给数据库进行预编译,生成一个可重复使用的执行计划。在后续执行时,只需将参数值传递给预编译好的 SQL 语句即可。由于预编译阶段已经确定了 SQL 语句的结构,即使用户输入了恶意的 SQL 片段,这些片段也不会被当作 SQL 语句的一部分执行,从而有效防止了 SQL 注入。

自动转义特殊字符

当使用 PreparedStatement 设置参数时,数据库驱动程序会自动对参数值中的特殊字符(如单引号 '、双引号 " 等)进行转义。这意味着即使用户输入了包含这些特殊字符的恶意数据,这些数据也不会被错误地解释为 SQL 语句的一部分,从而避免了 SQL 注入风险。

  • 使用预编译能够预防绝大多数SQL注入,java.sql.PreparedStatement代替java.sql.Statement,但对于order by后的不能用预编译进行处理,只能手动过滤。

  • Mybatis的SQL配置中,采用#变量名称

XSS漏洞

成因

网站与后端交互的输入输出没有做好过滤,导致攻击者可以插入恶意js语句进行攻击。根据后端代码不同 ,大致可以分为反射型、存储型、DOM型

反射型

Java
@RequestMapping("/xss")
public ModelAndView xss(HttpServletRequest request,HttpServletResponse
response) throws ServletException,IOException{
 String name = request.getParameter("name");
 ModelAndView mav = new ModelAndView("mmc");
 mav.getModel().put("uname", name);
 return mav;
}

// ModelAndView类用于传递参数给模版,并进行渲染

这里接收了用户输入的参数name,然后又直接输出到了页面,整个过程没有任何过滤,所以造成了xss漏洞

修复后的代码:

Java
@RequestMapping("/xss")
public ModelAndView xss(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String name = request.getParameter("name");
    // 对输入进行HTML实体编码
    name = StringEscapeUtils.escapeHtml4(name);
    ModelAndView mav = new ModelAndView("mmc");
    mav.getModel().put("uname", name);
    return mav;
}

存储型

根据已知的用户ID查询该用户的数据并显示在JSP页面上。如果存入的数据存在未经过滤的恶意js代码。就会造成xss攻击。

Java Server Page
<%  ...
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("select * from users where id =" + id);
    String address = null;
    if (rs != null) {
        rs.next();
        address = rs.getString("address");
    }
%>

这段代码同时有sql注入和存储型xss的风险,sql注入在于id参数为拼接传入sql语句并且id用户可控

xss风险分析如下:

如果数据库中某个用户的 address 字段值为 <script>alert('XSS')</script>,那么在页面渲染时,这段脚本可能会被浏览器执行,弹出一个警告框。

Dom型

Java
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
Date: <span style="color: red;"></span>
<input type="hidden" value="<%=request.getParameter("date")%>" />
<script>
    var date = document.getElementsByTagName("input")[0].value;
    document.getElementsByTagName("span")[0].innerHTML = date;
</script>

正常请求测试:http://localhost:8000/modules/servlet/dom.jsp?date=2020-11-15%2015:57:22

img

XSS攻击测试:http://localhost:8000/modules/servlet/dom.jsp?date=%3Cimg%20src=1%20onerror=alert(/xss/)%20/%3E

img

审计方法

全局搜索用户的输入与输出,查找是否存在过滤。

修复

  • 配置全局过滤器web.xml
XML
<filter>  
        <filter-name>XssSafe</filter-name>  
        <filter-class>XssFilter</filter-class>  
    </filter>  
    <filter-mapping>  
        <filter-name>XssSafe</filter-name>  
        <url-pattern>/*</url-pattern>  
    </filter-mapping>
  • 使用commons.lang包,主要提供了字符串查找、替换、分割、去空白、去掉非法字符等等操作。有几个函数可以用来过滤。
  • StringEscapeUtils.escapeHtml(string),使用HTML实体,转义字符串中的字符。
  • StringEscapeUtils.escapeJavaScript(string),使用JavaScript字符串规则转义字符串中的字符。

  • 设置 Content Security Policy (CSP):通过 HTTP 响应头或 <meta> 标签设置 CSP,限制页面可以加载的资源,防止恶意脚本的执行。

XXE漏洞

成因

XXE就是XML外部实体注入。当允许引用外部实体时,通过构造恶意内容,就可能导致任意文件读取、系统命令执行、内网端口探测、攻击内网网站等危害。

审计方法

XML解析一般在导入配置、数据传输接口等场景会用到,xml解析器是否禁用外部实体。

全局搜索如下函数:

Text Only
javax.xml.parsers.DocumentBuilder
javax.xml.stream.XMLStreamReader
org.jdom.input.SAXBuilder
org.jdom2.input.SAXBuilder
javax.xml.parsers.SAXParser
org.dom4j.io.SAXReader 
org.xml.sax.XMLReader
javax.xml.transform.sax.SAXSource 
javax.xml.transform.TransformerFactory 
javax.xml.transform.sax.SAXTransformerFactory 
javax.xml.validation.SchemaFactory
javax.xml.bind.Unmarshaller
javax.xml.xpath.XPathEx

示例:

Java
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import java.io.IOException;
import java.io.StringReader;

@WebServlet("/parseXml")
public class XXEVulnerableServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 从请求参数中获取 XML 数据
        String xmlData = request.getParameter("xml");

        try {
            // 创建 DocumentBuilderFactory 实例
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
            // 创建 DocumentBuilder 实例
            DocumentBuilder db = dbf.newDocumentBuilder();

            // 解析用户输入的 XML 数据
            Document doc = db.parse(new InputSource(new StringReader(xmlData)));

            // 输出解析结果
            response.getWriter().write("Parsed XML: " + doc.getDocumentElement().getTextContent());
        } catch (Exception e) {
            e.printStackTrace(response.getWriter());
        }
    }
}

在这个代码中:

  1. 用户输入的 XML 数据:通过 request.getParameter("xml") 获取用户输入的 XML 数据。

  2. 未禁用 DTD 和外部实体:默认情况下,DocumentBuilderFactory 允许解析 DTD 和外部实体。攻击者可以通过构造恶意的 XML 数据,使解析器加载本地文件或其他资源。

  3. 恶意 XML 数据示例:假设攻击者通过 URL 发送以下请求:

Text Only
http://example.com/parseXml?xml=%3C!DOCTYPE%20foo%20[%3C!ENTITY%20xxe%20SYSTEM%20%27file:///etc/passwd%27%20%3E]%3Cfoo%3E&xxe;%3C/foo%3E

解码后的 XML 数据为:

xml复制

XML
<!DOCTYPE foo [<!ENTITY xxe SYSTEM 'file:///etc/passwd' >]><foo>&xxe;</foo>

当解析器解析这个 XML 数据时,会尝试加载 /etc/passwd 文件的内容,并将其输出。

修复

  • 使用白名单检验,例如上面的代码增加正则匹配
Java
if (!Pattern.matches("[_a-bA-B0-9]+", user.getUserId()))
if (!Pattern.matches("[_a-bA-B0-9]+", user.getDescription()))
  • 在初始化解析器时禁用 DTD 和外部实体的解析
Java
// 禁用 DTD 和外部实体
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
  • 使用安全的XML库,使用dom4j来构建XML,dom4j会对文本数据域进行xml编码。

SSRF漏洞

成因

代码中提供了从其他服务器应用获取数据的功能但没有对目标地址做过滤与限制。

java的SSRF利用方式比较局限:

  • 利用file协议任意文件读取。
  • 利用http协议端口探测

支持的一些协议

Text Only
file ftp mailto http https jar netdoc

举例:

Java
String url = request.getParameter("url");
String htmlContent;
try {
    URL u = new URL(url);
  //URL对象用openconnection()获得openConnection类对象。
  URLConnection urlConnection = u.openConnection();
  HttpURLConnection httpUrl = (HttpURLConnection) urlConnection;
  BufferedReader base = new BufferedReader(new InputStreamReader(httpUrl.getInputStream(), "UTF-8"));
  //用inputStream获取字节流然后使用InputStreamReader转化为字符流。
  StringBuffer html = new StringBuffer();
  while ((htmlContent = base.readLine()) != null) {
        html.append(htmlContent);
    }
}

漏洞代码四种情况

  • Request
Java
Request.Get(url).execute()
  1. 发送 HTTP GET 请求:
  2. Request.Get(url) 创建一个 HTTP GET 请求对象,目标是指定的 url。
  3. .execute() 方法执行该请求,并返回响应。
  4. 获取响应:
  5. 执行请求后,通常会返回一个响应对象,其中包含服务器返回的状态码、响应头和响应体。
  • openStream
Java
URL u;
int length;
byte[] bytes = new byte[1024];
u = new URL(url);
inputStream = u.openStream();

//inputStream存储从url读取到的数据
  • HttpClient
Java
String url = "http://127.0.0.1";
CloseableHttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(url);
HttpResponse httpResponse;
try {
    // 该行代码发起网络请求
    httpResponse = client.execute(httpGet);
  • URLConnection和HttpURLConnection
Text Only
URLConnection urlConnection = url.openConnection();
HttpURLConnection urlConnection = url.openConnection();

审计方法

只要是能够对外发起网络请求的地方,就有可能会出现SSRF漏洞。重点查找以下函数。

Text Only
HttpClient.execute
HttpClient.executeMethod
HttpURLConnection.connect
HttpURLConnection.getInputStream
URL.openStream
  • new URL():构造一个url对象
  • openConnection():创建一个实例URLConncetion.
  • getInputStream():获取URL的字节流

修复

  1. 取URL的Host
  2. 取Host的IP
  3. 判断是否是内网IP,是内网IP直接return,不再往下执行
  4. 请求URL
  5. 如果有跳转,取出跳转URL,执行第1步
  6. 当判断完成最后会去请求URL

  7. 限制协议

仅允许 HTTP 和 HTTPS 协议,禁用其他协议(如 file://、ftp:// 等),以防止攻击者利用其他协议读取本地文件或访问其他服务。

  1. 限制请求端口

限制请求的端口为常见的 HTTP/HTTPS 端口(如 80、443、8080 等),避免访问其他可能暴露敏感信息的端口。

  1. 禁止 30x 跳转

禁止 HTTP 重定向(30x 状态码),防止攻击者通过跳转绕过限制。

  1. 设置白名单

维护一个允许访问的 URL 白名单,仅允许访问白名单中的域名或 IP 地址。

任意文件操作类漏洞

成因

常见的一些java文件操作类的漏洞:任意文件的读取、下载、删除、修改,这类漏洞的成因基本相同,都是因为程序没有对文件和目录的权限进行严格控制,或者说程序没有验证请求的资源文件是否合法导致的。

举例:

任意文件读取

路径拼接

Java
@GET
@Path("/images/{image}")
@Produces("images/*")
public Response getImage(@javax.ws.rs.PathParam("image") String image) {
    File file = new File("resources/images/", image); //Weak point

    if (!file.exists()) {
        return Response.status(Status.NOT_FOUND).build();
    }

    return Response.ok().entity(new FileInputStream(file)).build();
}
def getWordList(value:String) = Action {
  if (!Files.exists(Paths.get("public/lists/" + value))) {
    NotFound("File not found")
  } else {
    val result = Source.fromFile("public/lists/" + value).getLines().mkString // Weak point
    Ok(result)
  }
}

payload:

Java
// payload image=../../../../../etc/passwd

File file = new File("resources/images/", image); //Weak point
// 读取resources/images/../../../../../etc/passwd --> /etc/passwd

val result = Source.fromFile("public/lists/" + value).getLines().mkString // Weak point
// 读取public/lists/../../../../../etc/passwd

参数注入

漏洞代码如下

Java
@GetMapping("/codeinject")  
public String codeInject(String filepath) throws IOException {  

    String[] cmdList = new String[]{"sh", "-c", "ls -la " + filepath};  
    ProcessBuilder builder = new ProcessBuilder(cmdList);  
    builder.redirectErrorStream(true);  
    Process process = builder.start();  
    return WebUtils.convertStreamToString(process.getInputStream());  
}

注入截断即可

Text Only
codeinject?filepath=/tmp;cat /etc/passwd

修复代码如下

Java
@GetMapping("/codeinject/sec")  
public String codeInjectSec(String filepath) throws IOException {  
    String filterFilePath = SecurityUtil.cmdFilter(filepath);  
    if (null == filterFilePath) {  
        return "Bad boy. I got u.";  
    }  
    String[] cmdList = new String[]{"sh", "-c", "ls -la " + filterFilePath};  
    ProcessBuilder builder = new ProcessBuilder(cmdList);  
    builder.redirectErrorStream(true);  
    Process process = builder.start();  
    return WebUtils.convertStreamToString(process.getInputStream());  
}

自定义检查类如下

Java
public class SecurityUtil {  
    private static final Pattern FILTER_PATTERN = Pattern.compile("^[a-zA-Z0-9_/\\.-]+$");
}

检查类使用了正则表达式检查是否存在特殊字符

任意文件写入

Java
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.File" %>
<%@ page import="java.io.FileOutputStream" %>

<%
    File file = new File(request.getParameter("f"));
    FileOutputStream fos = new FileOutputStream(file);
    fos.write(request.getParameter("c").getBytes());
    fos.flush();
    fos.close();

    out.println(file.getAbsoluteFile() + "\t" + file.exists());
%>

攻击者可能期望跨目录写入文件,如写入 SSH KEY、写入计划任务等等方式进行进一步的攻击。

请求:http://localhost:8000/modules/filesystem/file-w.jsp?f=../../a.rar&c=aaa,如下图:

img

攻击者通过传入恶意的参数f和c参数可以使用绝对路径在服务器上写入恶意的WebShell后门或其他文件,请求:http://localhost:8000/modules/filesystem/file-w.jsp?f=/tmp/2.txt&c=webshell,如下图:

img

任意文件复制/移动

Java
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.IOException" %>
<%@ page import="java.nio.file.Files" %>
<%@ page import="java.nio.file.Path" %>
<%@ page import="java.nio.file.Paths" %>
<pre>
<%
    try {
        Path path = Files.copy(Paths.get(request.getParameter("source")), Paths.get(request.getParameter("dest")));

        out.println(path);
    } catch (IOException e) {
        e.printStackTrace();
    }
%>
</pre>

攻击者传入恶意的source和dest参数可以实现复制任何文件到任意的目录,比如攻击者可以在用户中心上传一张内容为WebShell恶意代码的1.jpg图片文件,然后通过漏洞将1.jpg图片文件,复制到同级目录并更新名称为1.jsp的可解析脚本文件,访问1.jsp文件即可实现控制服务器的目的,如下图:

img

在实际环境中,应用系统可能根据需求在配置文件如web.xml中或代码层面如filter设置某些目录(如上传目录、资源目录等)禁止对 .jsp 脚本文件等可执行文件进行解析,因此,攻击者需要将恶意文件移动或复制到其他能够执行的目录进行解析。请求:http://localhost:8000/modules/filesystem/files-copy.jsp?source=/tmp/1.jsp&dest=/Users/yz/Desktop/apache-tomcat-8.5.31/webapps/ROOT/1.jsp,如下图:

image-20200920225852244

任意文件重命名

Java
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.File" %>
<%
    String fileName1 = request.getParameter("s");
    String fileName2 = request.getParameter("d");

    File f = new File(fileName1);
    File d = new File(fileName2);

    f.renameTo(d);

    out.println(d + "\t" + d.exists());
%>

攻击者传入恶意的s和d参数即可将文件名为1.txt的文本文件重命名为1.jsp可执行脚本文件,请求:http://localhost:8000/modules/filesystem/file-rename.jsp?s=/tmp/1.txt&d=/tmp/1.jsp,如下图:

img

攻击者会使用重命名的方式将(txt、jpg等资源文件)重命名为可执行脚本文件(jsp)来获得webshell从而控制Web应用系统,并绕过某些安全防护机制。常见的攻击手段是在文件上传时,上传包含恶意代码的图片文件,再利用重命名将其转为可执行的脚本文件。

目录遍历漏洞

Java
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="java.io.File" %>

<pre>
<%
    String[] files = new File(request.getParameter("dir")).list();

    for (String file : files) {
        out.println(file);
    }
%>
</pre>

这个漏洞可能由Web应用本身的开发不规范导致,也有可能是因为MVC框架、项目依赖的第三方库、Web服务器自身导致的。如果是由于自身开发不规范导致的那么需要程序严格控制用户传入目录参数是否合法!

payload:

Text Only
dir=/etc
dir=../etc

审计方法

全局搜索关键字或者方法

  1. JDK原始的java.io.FileInputStream、java.io.FileOutputStream类;
  2. JDK原始的java.io.RandomAccessFile类;
  3. Apache Commons IO提供的org.apache.commons.io.FileUtils类;
  4. JDK1.7新增的基于NIO非阻塞异步读取文件的java.nio.channels.AsynchronousFileChannel类;
  5. JDK1.7新增的基于NIO读取文件的java.nio.file.Files类。常用方法如:Files.readAllBytes、Files.readAllLines;
  6. java.io.File类的list、listFiles、listRoots、delete方法;

除此之外,还可以搜索一下FileUtil/FileUtils很有可能用户会封装文件操作的工具类。

排查程序的安全策略配置文件,查找permission Java.io.FilePermission,查看IO方案是否只对程序的绝对路径赋予读写权限。

修复方法

  • 配置全局安全策略
  • 使用File.getCanonicalPath()方法,该方法会对所有别名、快捷方式以及符号链接进行一致地解析。特殊的文件名,例如“..”会被移除。

文件上传漏洞

Java Server Page
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="org.apache.commons.fileupload.FileItemIterator" %>
<%@ page import="org.apache.commons.fileupload.FileItemStream" %>
<%@ page import="org.apache.commons.fileupload.servlet.ServletFileUpload" %>
<%@ page import="org.apache.commons.fileupload.util.Streams" %>
<%@ page import="java.io.File" %>
<%@ page import="java.io.FileOutputStream" %>
<%
    if (ServletFileUpload.isMultipartContent(request)) {
        ServletFileUpload fileUpload       = new ServletFileUpload();
        FileItemIterator  fileItemIterator = fileUpload.getItemIterator(request);

        String dir       = request.getServletContext().getRealPath("/uploads/");
        File   uploadDir = new File(dir);

        if (!uploadDir.exists()) {
            uploadDir.mkdir();
        }

        while (fileItemIterator.hasNext()) {
            FileItemStream fileItemStream = fileItemIterator.next();
            String         fieldName      = fileItemStream.getFieldName();// 字段名称

            if (fileItemStream.isFormField()) {
                String fieldValue = Streams.asString(fileItemStream.openStream());// 字段值
                out.println(fieldName + "=" + fieldValue);
            } else {
                String fileName   = fileItemStream.getName();
                File   uploadFile = new File(uploadDir, fileName);
                out.println(fieldName + "=" + fileName);
                FileOutputStream fos = new FileOutputStream(uploadFile);

                // 写文件
                Streams.copy(fileItemStream.openStream(), fos, true);

                out.println("文件上传成功:" + uploadFile.getAbsolutePath());
            }
        }
    } else {
%>

编码特性

QP编码

QP编码( quoted-printable)是邮件协议中的一种内容编码方式,Quoted-printable是使用可打印的ASCII字符(如字母、数字与“=”)表示各种编码格式下的字符,以便能在7-bit数据通路上传输8-bit数据, 或者更一般地说在非8-bit clean媒体上正确处理数据,这被定义为MIME content transfer encoding。

示例 - JavaQP编码代码:

Java Server Page
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="javax.mail.internet.MimeUtility" %>
<%
    String qp = request.getParameter("qp");
    String encode = MimeUtility.encodeWord(qp);
    String decode = MimeUtility.decodeWord(encode);

    out.println("<pre>\nQP-Encoding: " + encode + "\nQP-Decode: " + decode);
%>

字符串:测试.jsp编码后的结果如下:

img

QP编码本与文件上传没有什么关系,但是由于在Java中最常用的Apache commons fileupload库从1.3开始支持了RFC 2047 Header值编码,从而支持解析使用QP编码后的文件名。

上传文件的时候选一个文件名经过QP编码后的文件,如:=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?=(测试.jsp)。

示例 - 文件上传测试:

img

示例 - Payload:

Java
Content-Disposition: form-data; name="file"; filename="=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?="

编码处理类:org.apache.commons.fileupload.util.mime.MimeUtility#decodeText

img

文件上传成功后文件名被编码成了测试.jsp。

Spring MVC中同样支持QP编码,在Spring中有两种处理Multipart的Resolver: org.springframework.web.multipart.commons.CommonsMultipartResolver和org.springframework.web.multipart.support.StandardServletMultipartResolver。CommonsMultipartResolver使用的是commons fileupload解析的所以支持QP编码。StandardMultipartHttpServletRequest比较特殊,Spring 4没有处理QP编码:

img

但是在Spring 5修改了实现,如果文件名是=?开始?=结尾的话会调用javax.mail库的MimeDelegate解析QP编码:

img

javax.mail库不是JDK自带的,必须自行引包,如果不存在该包也将无法解析,SpringBoot + Spring4默认使用的是StandardServletMultipartResolver,但是基于配置的Spring MVC中经常会使用CommonsMultipartResolver,如:

XML
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
    <property name="defaultEncoding" value="UTF-8"></property>
    <property name="maxUploadSize" value="50000000"></property>
    <property name="maxInMemorySize" value="1024"></property>
</bean>

Spring 内置文件名编码特性

Spring会对文件上传的名称做特殊的处理,org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest内置了一种比较特殊的解析文件名的方式,如果传入的multipart请求无法直接使用filename=解析出文件名,Spring还会使用content-disposition解析一次(使用filename*=解析文件名)。

在文件上传时,修改Content-Disposition中的filename=为filename*="UTF-8'1.jpg'1.jsp":

img

Spring4的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest解析逻辑:

img

Spring4的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#extractFilenameWithCharset代码如下:

img

extractFilenameWithCharset支持对传入的文件名编码,示例中传入的UTF-8'1.jpg'1.jsp会被解析成UTF-8编码,最终的文件名为1.jsp,而1.jpg则会被丢弃。

Spring5的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest除了支持QP编码以外,优化了Spring4的解析文件名的方式:

img

org.springframework.http.ContentDisposition#parse代码:

img

文件上传成功:

img

示例 - Payload:

Java
Content-Disposition: form-data; name="file"; filename*="1.jsp"
Content-Disposition: form-data; name="file"; filename*="UTF-8'1.jpg'1.jsp"
Content-Disposition: form-data; name="file"; filename*="UTF-8'1.jpg'=?UTF-8?Q?=E6=B5=8B=E8=AF=95=2Ejsp?="

Multipart字段解析问题

在2013年左右,测试过非常多的WAF都不支持Multipart解析,当时经常使用Multipart请求方式来绕过WAF。Multipart所以使用请求与普通的GET/POST参数传输有非常大的区别,因为Multipart请求需要后端Web应用解析该请求包,Web容器也不会解析Multipart请求。WAF可能会解析Multipart但是很多时候可以直接绕过,比如很多WAF无法处理一个数据量较大的Multipart请求或者解析Multipart时不标准导致绕过。

在PHP中默认会解析Multipart请求,也就是说我们除了可以以GET/POST方式传参,还可以使用发送Multipart请求,后端一样可以接受到Multipart中的参数。在Java的MVC框架中Spring MVC、Struts2等实现了和PHP类似的功能,当框架发现请求方式是Multipart时就会主动的解析并将解析结果封装到HttpServletRequest中。

示例 - Spring MVC 注入代码片段:

Java
@ResponseBody
@RequestMapping("/getArticleById.php")
public SysArticle getArticleByID(String id) {
        return jdbcTemplate.queryForObject(
                "select * from sys_article where id = " + id,
                BeanPropertyRowMapper.newInstance(SysArticle.class)
        );
}

访问示例程序:http://localhost:8000/getArticleById.php?id=100000:

img

使用Multipart请求注入数据库信息测试:

img

审计方法

  1. 查找上传相关代码:在代码中搜索关键字如“upload”、“multipart/form-data”、“FileItem”、“MultipartFile”等,找到文件上传功能的实现代码。
  2. 检查文件类型验证:
  3. 查看是否对上传文件的扩展名进行了限制,如只允许上传图片、文档等安全文件类型。
  4. 检查是否对文件的 MIME 类型进行了验证,防止通过修改文件扩展名绕过限制。
  5. 检查文件名处理:
  6. 看是否对文件名进行了过滤,去除了特殊字符、空白字符等,防止路径遍历攻击。
  7. 检查是否对文件名进行了重命名,避免文件名重复或被恶意利用。
  8. 检查文件存储路径:查看文件是否被存储在安全的路径下,是否限制了访问权限,防止攻击者通过上传文件获取服务器的敏感信息。
  9. 检查文件大小限制:查看是否对上传文件的大小进行了限制,防止攻击者上传过大的文件导致系统性能问题或存储溢出。

修复方法

  1. 严格限制文件类型:
  2. 扩展名白名单:只允许上传指定的安全文件扩展名,如图片格式(jpg、png 等)、文档格式(doc、pdf 等),拒绝其他扩展名。
  3. MIME 类型验证:结合 MIME 类型进行验证,防止通过修改文件扩展名绕过限制。
  4. 文件内容检测:使用工具(如 Apache Tika)检测文件的实际内容,确保上传的文件与声明的类型一致。
  5. 安全处理文件名:
  6. 去除文件名中的特殊字符、空白字符等。
  7. 生成随机文件名,避免文件名重复问题,防止攻击者利用文件名进行攻击。
  8. 限制文件存储路径:
  9. 将上传的文件存储在安全的目录中,避免存储在 Web 目录下,防止被直接访问。
  10. 对存储路径进行严格限制,防止路径遍历攻击。
  11. 限制文件大小:设置合理的文件大小限制,防止攻击者上传过大的文件导致系统性能问题或存储溢出。
  12. 其他安全措施:
  13. 对上传的文件进行安全扫描,检测是否包含恶意代码。
  14. 设置合适的文件访问权限,防止未经授权的访问。
  15. 使用安全的文件上传框架或库,避免使用存在已知漏洞的组件。

命令执行漏洞

成因

服务端没有针对执行命令的函数进行过滤,导致攻击者可以提交恶意构造语句。java中常见如:java.lang.Runtime、java.lang.ProcessBuilder、java.lang.UNIXProcess/ProcessImpl。

举例

Java中的命令执行离不开调用反射的机制,在实际的场景往往离不开反序列化的利用。

Java
import java.io.*;
public class DirList {
    public static void main(String[] args) {
        String dir = System.getProperty(“dir”);
        Process process = null;
        InputStream istream = null;
        try {
            process = Runtime.getRuntime().exec("cmd.exe /c dir" + dir);
            int result = process.waitFor();
            if (result != 0) {
                System.out.println("process error: " + result);
            }
            istream = (result == 0) ? process.getInputStream() : process.getErrorStream();
            byte[] buffer = new byte[512];
            while (istream.read(buffer) != -1) {
                System.out.print(new String(buffer, “gb2312”));
            }
        } catch (IOException e1) {
            e1.printStackTrace();
        } catch (InterruptedException e2) {
            e2.printStackTrace();
        } finally {
            if (istream != null) {
                try {
                    istream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (process != null) {
                process.destroy();
            }
        }
    }
}

上面的代码利用Runtime.exec()方法调用dir命令。

攻击者可以利用&符号执行多条命令,例如

Text Only
java -Ddir="..\\ & whoami

审计方法

RCE出现的原因和场景很多,以后慢慢学习~

Text Only
服务端直接存在可执行函数(exec()等),且对传入的参数过滤不严格导致 RCE 漏洞
服务端不直接存在可执行函数(exec()等),且对传入的参数过滤不严格导致 RCE 漏洞
由表达式注入导致的RCE漏洞,常见的如:OGNL、SpEL、MVEL、EL、Fel、JST+EL等
由java后端模板引擎注入导致的 RCE 漏洞,常见的如:Freemarker、Velocity、Thymeleaf等
由java一些脚本语言引起的 RCE 漏洞,常见的如:Groovy、JavascriptEngine等
由第三方开源组件引起的 RCE 漏洞,常见的如:Fastjson、Shiro、Xstream、Struts2、weblogic等

审计的时候可以重点寻找:

  • Runtime.exec()
  • Process
  • ProcessBuilder.start()

修复

  • 正则表达式匹配用户输入
Text Only
if (!Pattern.matches("[0-9A-Za-z@.]+", dir)) {
  • 使用白名单限制允许执行的命令。

  • 使用安全的 API(如 ProcessBuilder)并以数组形式传递参数。

  • 限制应用程序的运行权限。

反序列化漏洞

成因

当输入的反序列化的数据可被用户控制,那么攻击者即可通过构造恶意输入,让反序列化产生非预期的对象,在此过程中执行构造的任意代码。

示例:

Java
@RequestMapping("/rememberMe/vuln")  
public String rememberMeVul(HttpServletRequest request)  
        throws IOException, ClassNotFoundException {  

    Cookie cookie = getCookie(request, Constants.REMEMBER_ME_COOKIE);  
    if (null == cookie) {  
        return "No rememberMe cookie. Right?";  
    }  

    String rememberMe = cookie.getValue();  
    byte[] decoded = Base64.getDecoder().decode(rememberMe);  

    ByteArrayInputStream bytes = new ByteArrayInputStream(decoded);  
    ObjectInputStream in = new ObjectInputStream(bytes);  
    in.readObject();  
    in.close();  

    return "Are u ok?";  
}

修复代码:

Java
@RequestMapping("/rememberMe/security")  
public String rememberMeBlackClassCheck(HttpServletRequest request)  
        throws IOException, ClassNotFoundException {  

    Cookie cookie = getCookie(request, Constants.REMEMBER_ME_COOKIE);  

    if (null == cookie) {  
        return "No rememberMe cookie. Right?";  
    }  
    String rememberMe = cookie.getValue();  
    byte[] decoded = Base64.getDecoder().decode(rememberMe);  

    ByteArrayInputStream bytes = new ByteArrayInputStream(decoded);  

    try {  
        AntObjectInputStream in = new AntObjectInputStream(bytes);  // throw InvalidClassException  
        in.readObject();  
        in.close();  
    } catch (InvalidClassException e) {  
        logger.info(e.toString());  
        return e.toString();  
    }  

    return "I'm very OK.";  
}

修复方式是通过Hook resolveClass来校验反序列化的类

序列化数据结构可以了解到包含了类的名称及serialVersionUID的ObjectStreamClass描述符在序列化对象流的前面位置,且在readObject反序列化时首先会调用resolveClass读取反序列化的类名,所以这里通过重写ObjectInputStream对象的resolveClass方法即可实现对反序列化类的校验

跟进查看重写类代码

Java
/**  
 * 只允许反序列化SerialObject class  
 *  
 * 在应用上使用黑白名单校验方案比较局限,因为只有使用自己定义的AntObjectInputStream类,进行反序列化才能进行校验。  
 * 类似fastjson通用类的反序列化就不能校验。  
 * 但是RASP是通过HOOK java/io/ObjectInputStream类的resolveClass方法,全局的检测白名单。  
 *  
 */  
@Override  
protected Class<?> resolveClass(final ObjectStreamClass desc)  
        throws IOException, ClassNotFoundException  
{  
    String className = desc.getName();  

    // Deserialize class name: org.joychou.security.AntObjectInputStream$MyObject  
    logger.info("Deserialize class name: " + className);  

    String[] denyClasses = {"java.net.InetAddress",  
       "org.apache.commons.collections.Transformer",  
                            "org.apache.commons.collections.functors"};  

    for (String denyClass : denyClasses) {  
        if (className.startsWith(denyClass)) {  
            throw new InvalidClassException("Unauthorized deserialization attempt", className);  
        }  
    }  

    return super.resolveClass(desc);  
}

审计方法

反序列化操作常常出现在**导入模版文件、网络通信、数据传输、日志格式化存储或者数据库存储**等业务功能处,在代码审计时可重点关注一些反序列化操作函数并判断输入是否可控。

  • ObjectInputStream.readObject
  • ObjectInputStream.readUnshared
  • XMLDecoder.readObject
  • XStream.fromXML
  • 第三方jar包:ObjectMapper.readValue,jackson中的JSON.parseObject,fastjson中的api

修复

  • 升级服务端所依赖的可能被利用的jar包,包括JDK。
  • 在执行反序列前对InputStream对象进行检查过滤

中间件漏洞

成因

**中间件**是提供系统软件和应用软件之间连接的软件,它将应用程序运行环境与操作系统隔离,从而实现应用程序开发者不必为更多系统问题忧虑,而直接关注该应用程序在解决问题上的能力 。容器就是中间件的一种。

java常见的中间件:

img

审计方法

直接打开pom.xml文件查看其 使用的中间件及其版本,然后到漏洞库里找漏洞即可。

img

修复

及时更新项目使用的java中间件的版本。

业务逻辑漏洞

成因

常见的业务逻辑漏洞主要是越权,分为平行越权和垂直越权。在javaweb的各个功能点中都可能存在越权漏洞。

主要原因还是因为程序没有对当前用户的权限进行严格控制,或者是后台没有判断当前用户id。

示例

  • 用户类
Java
public class User {
    private int id;
    private String username;
    private String role; // 用户角色,如 "user" 或 "admin"

    public User(int id, String username, String role) {
        this.id = id;
        this.username = username;
        this.role = role;
    }

    public int getId() {
        return id;
    }

    public String getUsername() {
        return username;
    }

    public String getRole() {
        return role;
    }
}
  • 数据库
Java
import java.util.HashMap;
import java.util.Map;

public class Database {
    private static Map<Integer, User> users = new HashMap<>();

    static {
        users.put(1, new User(1, "Alice", "user"));
        users.put(2, new User(2, "Bob", "user"));
        users.put(3, new User(3, "Charlie", "admin"));
    }

    public static User getUserById(int id) {
        return users.get(id);
    }
}
  • 存在越权漏洞的代码
Java
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/getUserInfo")
public class UserInfoServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 获取当前登录用户信息(假设存储在 session 中)
        User currentUser = (User) request.getSession().getAttribute("currentUser");

        // 获取请求中的用户 ID
        int userId = Integer.parseInt(request.getParameter("userId"));

        // 从数据库中获取用户信息
        User requestedUser = Database.getUserById(userId);

        if (requestedUser != null) {
            response.getWriter().write("User ID: " + requestedUser.getId() + ", Username: " + requestedUser.getUsername() + ", Role: " + requestedUser.getRole());
        } else {
            response.getWriter().write("User not found.");
        }
    }
}

Note

存在漏洞的本质在于没有对当前用户作权限校验,该用户是否具有获取其他用户信息权限

  • 修复后代码
Java
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@WebServlet("/getUserInfo")
public class UserInfoServlet extends HttpServlet {
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 获取当前登录用户信息(假设存储在 session 中)
        User currentUser = (User) request.getSession().getAttribute("currentUser");

        // 获取请求中的用户 ID
        int userId = Integer.parseInt(request.getParameter("userId"));

        // 从数据库中获取用户信息
        User requestedUser = Database.getUserById(userId);

        if (requestedUser != null && requestedUser.getId() == currentUser.getId()) {
          // 获取本用户信息
            response.getWriter().write("User ID: " + requestedUser.getId() + ", Username: " + requestedUser.getUsername() + ", Role: " + requestedUser.getRole());
        } else if (requestedUser != null && "admin".equals(requestedUser.getRole()) && "admin".equals(currentUser.getRole())) {
          // 如果是admin, 可以查看其他用户信息
            response.getWriter().write("User ID: " + requestedUser.getId() + ", Username: " + requestedUser.getUsername() + ", Role: " + requestedUser.getRole());
        } else {
            response.getWriter().write("Access denied.");
        }
    }
}

审计方法

在每个request.getParameter("userid");之后查看是否有检验当前用户与要进行增删改查的用户。

修复

获取当前登陆用户并校验该用户是否具有当前操作权限,并校验请求操作数据是否属于当前登陆用户,当前登陆用户标识不能从用户可控的请求参数中获取。

其他漏洞

java中存在的安全问题还有很多,比如ldap注入、jndi注入、rmi反序列化等等会在后面的文章单独拿出来说,更多的java安全漏洞可以参考先知的java安全手书:

Text Only
https://xz.aliyun.com/t/3372
https://xz.aliyun.com/t/3460
https://xz.aliyun.com/t/3416
https://xz.aliyun.com/t/3358

最后推荐一个自动化搜索java敏感函数的脚本:Cryin/JavaID: java source code static code analysis and danger function identify prog (github.com)