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.
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.
#!/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.
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.
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
- Search for interpolation into
open,system,execand backticks. That is the shortest path to remote code execution. - Search for
printstatements that contain a variable and an angle bracket. - Check the storage format for a delimiter that can appear in the data.
- Check every append for locking.
- Then, and only then, worry about
use strict.