Pearl - ImaginaryCTF 2025

Source code was provided for this challenge



alt text

Upon visiting the website, I’m greeted by a store page, with a list of pearl products each with a button labeled “Add to Cart”. After clicking around & fuzzing the website, I found that this webpage is completely static and there is no backend logic to this initial page. I then decided to look at the source code for the site, and I found that there is some interesting logic regarding viewing files.

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
my $webroot = "./files";

my $d = HTTP::Daemon->new(LocalAddr => '0.0.0.0', LocalPort => 8080, Reuse => 1) || die "Failed to start server: $!";

print "Server running at: ", $d->url, "\n";

while (my $c = $d->accept) {
while (my $r = $c->get_request) {
if ($r->method eq 'GET') {
my $path = CGI::unescape($r->uri->path);
$path =~ s|^/||; # Remove leading slash
$path ||= 'index.html';

my $fullpath = File::Spec->catfile($webroot, $path);
# $webroot = ./files


if ($fullpath =~ /\.\.|[,\`\)\(;&]|\|.*\|/) {
$c->send_error(RC_BAD_REQUEST, "Invalid path");
next;
}

if (-d $fullpath) {
# Serve directory listing
opendir(my $dh, $fullpath) or do {
$c->send_error(RC_FORBIDDEN, "Cannot open directory.");
next;
};

my @files = readdir($dh);
closedir($dh);

my $html = "<html><body><h1>Index of /$path</h1><ul>";
foreach my $f (@files) {
next if $f =~ /^\./; # Skip dotfiles
my $link = "$path/$f";
$link =~ s|//|/|g;
$html .= qq{<li><a href="/$link">} . escapeHTML($f) . "</a></li>";
}
$html .= "</ul></body></html>";

my $resp = HTTP::Response->new(RC_OK);
$resp->header("Content-Type" => "text/html");
$resp->content($html);
$c->send_response($resp);

} else {
open(my $fh, $fullpath) or do {
$c->send_error(RC_INTERNAL_SERVER_ERROR, "Could not open file.");
next;
};
binmode $fh;
my $content = do { local $/; <$fh> };
close $fh;

my $mime = 'text/html';

my $resp = HTTP::Response->new(RC_OK);
$resp->header("Content-Type" => $mime);
$resp->content($content);
$c->send_response($resp);
}
} else {
$c->send_error(RC_METHOD_NOT_ALLOWED);
}
}
$c->close;
undef($c);
}


Upon closer inspection of the code, I found that there is a regex check that filters out any requests that contain the following patterns:

  • .. (two consecutive periods)
  • Any one character from the following set: , ( ) & ;
  • A sequence that starts with or ends with a pipe (|)

My initial assumption when I first looked at the application logic is some sort of LFI (Local File Inclusion) vulnerability, but after looking at the code more carefully, I realized that this was not the case. The regex filter makes LFI impossible.

For multiple hours, I tried bypassing the regex filter, but at this point I was tunnel-visioned into trying some sort of LFI, but nothing would work. I noticed the code logic allowed you to list files in whatever directory you specified, but I was stuck in the current directory, as there was no way to backwards traverse the directory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (-d $fullpath) {
# Serve directory listing
opendir(my $dh, $fullpath) or do {
$c->send_error(RC_FORBIDDEN, "Cannot open directory.");
next;
};

my @files = readdir($dh);
closedir($dh);

my $html = "<html><body><h1>Index of /$path</h1><ul>";
foreach my $f (@files) {
next if $f =~ /^\./; # Skip dotfiles
my $link = "$path/$f";
$link =~ s|//|/|g;
$html .= qq{<li><a href="/$link">} . escapeHTML($f) . "</a></li>";
}
$html .= "</ul></body></html>";

my $resp = HTTP::Response->new(RC_OK);
$resp->header("Content-Type" => "text/html");
$resp->content($html);
$c->send_response($resp);

In the above code is where the directory listing logic resides. Whatever path you specify in your GET request is appended to the ./files directory, and then the directory listing is served. For example, if you make a GET request to /files/.., the server will try to list the contents of the ./files/.. directory, which is the parent directory of the ./files directory. This is where the regex filter comes into play, as it filters out any requests that contain the .. pattern. With this information, I was able to just list the contents of the ./files directory, which just contained the index.html file.

alt text


After being lost on this for a while, I decided to paste the source code into an LLM & ask it if there were any vulnerable functions in this code, and it outlined that the open() function in perl has a built-in functionality (funnily enough, not a vulnerability) that allows you to just straight up run shell commands.

alt text


After finding this out, I decided to test it out & see if I am able to get command execution.
alt text

As you can see, this did not work. After further investigation as to why this wasn’t working, I was playing around with the code & seeing what is the exact string that is being passed into the open() function, and it looked like this.

1
"./files/ls|"

The ./files string is there because it is prepended in the line that states $fullpath = File::Spec->catfile($webroot, $path);, and the ls string with the pipe operator appended is there because it is what allows the open() function to execute shell commands.

Knowing this, I needed to find a way to have my command with the pipe operator appended on its own separate line, separate from ./files. It occurred to me to try appending a newline character (\n), and when I tested printing the $fullpath that is printed, I got this:

1
2
"./files/"
"ls|"

Now that the command & the pipe operator are on their own separate lines, I decided to try if this would work, and I was able to get command execution.
alt text


Before going & looking for the flag, I want to quickly explain why the first payload didn’t work.

Essentially, when you append the pipe (|) operator at the end of the string in the 2nd argument of perl’s open() function, it will treat anything before it as a command. Knowing this now, it was seen that the first payload I tried was ./files/ls|, which failed because that isn’t a valid command.

However, when you append the newline character at the beginning, it separates ./files and ls| into two separate strings. In this case, perl would treat the first string as a directory, and the second string as a shell command, which is what I wanted.


After listing out the contents of the root / directory, I found the flag there & catted it out successfully, completing the challenge.
alt text