Script Rescue · 01

A 1998 CGI guestbook, read line by line

Four vulnerability classes in ninety lines, then a rewrite that keeps the behaviour and drops the execution model. The point is not that the original was bad code. The point is which of its habits were reasonable in 1998 and which were already wrong then.

About the specimen The code below is a reconstruction, not a quotation. It is written to be representative of the guestbook scripts distributed on shareware archives in that period, so that nothing is reproduced without rights and nobody’s specific script is held up as an example. Every flaw shown was common enough to be generic.

The shape of the original

It reads a form POST, appends the entry to a flat file, mails the site owner, and prints the whole guestbook back as HTML. Ninety lines, no dependencies beyond what was already installed, and it worked.

guestbook.cgi · reconstruction, c. 1998 Do not deploy
#!/usr/bin/perl
read(STDIN, $in, $ENV{'CONTENT_LENGTH'});
foreach $pair (split(/&/, $in)) {
    ($k, $v) = split(/=/, $pair);
    $v =~ tr/+/ /;
    $v =~ s/%(..)/pack("C", hex($1))/ge;
    $FORM{$k} = $v;
}

open(GB, ">>$datafile");
print GB "$FORM{name}|$FORM{email}|$FORM{comment}\n";
close(GB);

open(MAIL, "|/usr/lib/sendmail -t $FORM{email}");
print MAIL "New entry from $FORM{name}\n";
close(MAIL);

print "Content-type: text/html\n\n";
print "<p>Thanks, $FORM{name}!</p>";

Four classes, in order of how badly they end

1 · Command injection — line 14

The email address is interpolated into a shell pipeline. A submitted value containing a semicolon or a backtick runs as the web server user. This one was already understood as a mistake in 1998; the habit persisted because the pipe-to-sendmail idiom was in every tutorial.

2 · Cross-site scripting — line 19

The name is printed into markup unescaped, and so is every stored entry when the book is rendered. Stored XSS, not reflected: the payload survives in the data file and fires for every later visitor.

3 · Delimiter injection — line 11

The record format is pipe-separated with no escaping. A comment containing a pipe or a newline corrupts the file structure, which at best breaks rendering and at worst lets an attacker forge fields in a record they do not own.

4 · Unlocked concurrent append — lines 10–12

Two simultaneous submissions can interleave. Under CGI this is not hypothetical: each request is its own process and nothing coordinates them. No flock, no atomic write.

What is not on this list The lack of use strict, the two-argument open, and the unchecked return values are all real problems — but they are style and robustness, not vulnerability classes. It is worth keeping the two categories separate when you audit old code, because conflating them makes the actual security findings harder to see.

The rewrite

Same behaviour: accept a submission, store it, notify, acknowledge. Different execution model, different escaping defaults, and a storage format that cannot be corrupted by its own contents.

guestbook.pl · Mojolicious::Lite PSGIresident
use Mojolicious::Lite -signatures;
use Mojo::JSON qw(encode_json);
use Fcntl qw(:flock O_WRONLY O_APPEND O_CREAT);

my $FILE = app->home->child('guestbook.ndjson');

post '/entries' => sub ($c) {
    my $v = $c->validation;
    $v->required('name')->size(1, 80);
    $v->required('comment')->size(1, 2000);
    $v->optional('email')->like(qr/^[^@\s]+\@[^@\s]+$/);
    return $c->render(text => 'Invalid submission', status => 400) if $v->has_error;

    sysopen(my $fh, $FILE, O_WRONLY | O_APPEND | O_CREAT, 0640) or die $!;
    flock($fh, LOCK_EX) or die $!;
    print $fh encode_json({
        name => $v->param('name'), comment => $v->param('comment'),
        at   => time,
    }) . "\n";
    close $fh;

    $c->render(template => 'thanks', name => $v->param('name'));
};

The template renders <%= $name %>, which escapes by default; producing raw output now requires typing <%== %> on purpose. No shell is invoked at all — notification belongs on a queue or a provider API, not on a pipe to sendmail.

What we kept, and what we refused to keep

Kept: one file
It is still a single script you can read in one sitting. That was the original’s real virtue and it survives the rewrite.
Kept: flat-file storage
Newline-delimited JSON instead of a pipe-separated line. Still greppable, still appendable, no longer corruptible by its own contents.
Dropped: the mail pipe
Not modernised — removed. There is no safe version of interpolating user input into a shell command line.
Dropped: the CGI execution model
The rewrite runs under any PSGI server. The fork-per-request cost is gone, and so is the assumption that no two requests exist at once.

If you are auditing something like this today

  1. Search for interpolation into open, system, exec and backticks. That is the shortest path to remote code execution.
  2. Search for print statements that contain a variable and an angle bracket.
  3. Check the storage format for a delimiter that can appear in the data.
  4. Check every append for locking.
  5. Then, and only then, worry about use strict.
Examples are not CI-tested Both listings are written to be read and were not executed by an automated harness for this build. The original is a reconstruction and must never be deployed.
Public correction trail Prefer a public record? Open a sourced issue in the PerlCoders repository.