PREVIOUS  TABLE OF CONTENTS  NEXT 

Safely Empowering Your CGI Scripts

Lincoln D. Stein

PACKAGES USED
Package Version
Perl 5.004 (or Activestate build 500+)
chat2.pl  

I like to keep my CGI scripts puny and weak, and you should too. CGI scripts are a gateway into your system from the Internet, and are, unfortunately, all too often exploited by unscrupulous people for nefarious ends. The more access a CGI script has to your system, the more dangerous it becomes when used for unintended purposes.

To keep CGI scripts under control, most webmasters, myself included, run the web server under an unprivileged user account. On Unix systems, this is often an account called 'nobody'. On Windows NT, it is an anonymous account with guest logon access. On correctly configured systems, the web server user account has even fewer privileges than an ordinary user. It doesn't own a home directory, have a shell, or even have the ability to log in as a normal user.

Under most circumstances you'll never notice the fact that CGI scripts run as an unprivileged user. However, sometimes this fact becomes inconvenient. For example, what if you want to give remote users read/write access to their home directories from across the web, allow web access to a database that uses account privileges for access control, or perform administrative tasks that require superuser privileges? When you face challenges like these, your only choice is to give the script a little more power than usual. In this article I'll show you how to accomplish this without creating a monster.

THE EXAMPLE SCRIPT

The example I use here lets Unix users change their login passwords remotely via a web page. When the user first accesses the script, the screen shown in Figure 1 prompts him for the account name, old password, and new password (twice). After pressing the 'Change Password' button, the script verifies the input and then attempts to make the requested change. If the change is successful, the user is presented with a confirmation screen.

Figure 1. Changing your system password.

Figure 1: Changing your system password.

Otherwise, an error message (in large red letters) is displayed, and the user is prompted to try again, as shown in Figure 2.

Figure 2. An unsuccessful attempt.

Figure 2: An unsuccessful attempt.

Note that this password changing script is designed to change not the user's web access password, but his system login password. An Internet service provider might use something like this to allow users to change their POP (Post Office Protocol), NNTP (Net News Transfer Protocol), or dialup passwords with-out bothering the system administrator or accessing a shell.

DESIGNING THE SCRIPT

An ordinary CGI script has a snowball's chance in hell of accomplishing this password changing task. It can't modify the system password file directly, because write access to the file is off-limits to anyone but the superuser. It can't even run the system passwd utility on the remote user's behalf, because passwd prevents one user from changing another's password, and will detect the attempt by the web server account to do so as a security violation. To get around these problems, we have several choices:

  1. Launch the CGI script as the superuser (with suid), and modify the system password files directly.

  2. Launch the CGI script as the superuser (with suid), and run the system passwd utility to change the user's password.

  3. Launch the CGI script as the superuser (with suid), immediately change to the remote user's access privileges, and run the system passwd utility to change the password.

  4. Launch the CGI script normally, and call the su program to run the passwd utility under the privileges of the remote user.

The first solution is by far the worst. Running a CGI script as the superuser, and then using its far-reaching powers to modify essential system files, is an invitation to disaster. The solution is also extremely non-portable, since many Unix systems use shadow password systems or Network Information System (NIS) databases to hold user account information.

The second solution is somewhat more appealing. Instead of modifying system files directly, we call the system passwd utility to change the user's password on our behalf. This avoids many of the portability problems because the passwd program presumably knows all about the fiddly details of the system password database. However, it still requires that the script be run as root, and this makes me nervous.

The next solution isn't very different The CGI script is again launched with root privileges, but it quickly changes its identity to run as the remote user. With the remote user's account privileges, it then invokes passwd. This is an improvement because the script gives away its superuser privileges as soon as possible. However, the script is still launched as root, and this is a Bad Thing.

I like the last solution the best. The script isn't run as root at all. Instead, after parsing the CGI parameters and deciding what to do, it calls the su program to change its identity to that of the remote user. In order to run su, the script needs the remote user's password, which, conveniently enough, he has already provided. Provided su grants the request, the script then calls the passwd program to change the user's password. Not only does this solution avoid the problem of becoming root, but it works with systems that have disabled suid scripts and even with servers that don't run CGI scripts as separate processes, such as Apache equipped with mod_perl (see TPJ #9).

This is the design we use here.

THE CHAT2.PL LIBRARY

Unfortunately there's one little problem. Both su and passwd are interactive programs. They read their input directly from the terminal rather than from standard input, so you can't just send them input via a pipe. Instead, you have to trick them into thinking they're talking to a human typing at a terminal rather than to a Perl script.

Happily, there's a ready-made solution. The chat2.pl library, part of the standard Perl 5.004 distribution, allows you to open up a pseudo tty to any program on the system and hold an interactive conversation with it. All we have to do is to figure out what prompts the program produces and what inputs to provide.

In preparation for writing a script that uses chat2.pl, it's good to run the desired program from the command line a few times and provide it with a range of inputs so that you can see all the possible outcomes. Here's a transcript of the session that I used to design the password changing script:

1> su -c /usr/bin/passwd impostor
su: user impostor does not exist

2> su -c /usr/bin/passwd wanda
Password: wrong_password
su: incorrect password

3> su -c /usr/bin/passwd wanda
Password: llamas2
Changing password for wanda
Enter old password: wrong_password
Illegal password, impostor.

4> su -c /usr/bin/passwd wanda
Password: llamas2
Changing password for wanda
Enter old password: llamas2
Enter new password: zebras
The password must have both upper- and lowercase
letters, or non-letters; try again.
Enter new password: zeBrAs
Re-type new password: zeBras
You misspelled it. Password not changed.

5> su -c /usr/bin/passwd wanda
Password: llamas2
Changing password for wanda
Enter old password: llamas2
Enter new password: ZeBrAs
Re-type new password: ZeBrAs
Password changed.

In each attempt, I called su with the -c flag to make it run the passwd program with the privileges of the indicated user. In the first attempt, I deliberately gave su the name of a bogus user, and it replied with an error message. In the second attempt, I gave su the name of a legitimate user of the system, but deliberately mistyped her password.

In the third try, I gave su the correct password; it accepted the password and passed me on to the passwd program, which printed Changing password for wanda. I then deliberately entered the incorrect password at this point, to see the message Illegal password.

Continuing to work my way through the possibilities, I invoked the program again, this time giving the correct password both times. This got me to the Enter new password: prompt. When I typed in zebras, however, the passwd program rejected it because it was too easy (my system rejects passwords that are too short or consist only of lowercase letters; other systems may have even more stringent rules). The system accepted ZeBrAs as a password, but when I confirmed it, I made a spelling error and was booted out.

Finally, on trial 5, I was able to work my way through the pass-word changing process, getting to the final confirmation Password changed.

Armed with this information, we can design a series of calls to chat2.pl that automate the password changing operation.

OOPS

But not quite yet. Soon after I began writing this script I discovered that the chat2.pl library, which was originally written for Perl 4, hasn't been brought up to date for a long time. As a result it's not as portable as other parts of Perl 5.004. chat2.pl uses a number of system-specific constants for creating sockets and pseudo ttys. Some of the constants are obtained from .ph files (system include files that have been run through the h2ph converter), while others are, unfortunately, hard coded. h2ph is notoriously difficult to run correctly, and the .ph files it produces often have to be tuned by hand. Worse, the hard-coded value for one essential constant, TIO-CNOTTY, was just plain wrong for my Linux system, causing chat2.pl to fail miserably.

To get things working, I patched my copy of chat2.pl slightly to bring it up to date. The patch replaces hardwired and .ph con-stants with ones imported from the Socket.pm and Ioctl.pm modules. You can find a copy of this patch file on the TPJ web site at http://tpj.com.

Although Socket.pm is a standard part of the 5.004 distribution, Ioctl.pm is not. You'll have to download it from CPAN. Be warned that installing Ioctl.pm is not as straightforward as most other modules. After the standard perl Makefile.PL and make steps, you must open a file named Ioctl.def and define a comma-delimited list of those constants you wish to make available. A good list can be found in the autogenerated file genconst.pl, where it is, inexplicably, commented out. I created an Ioctl.def for my system by cutting and pasting between the two files. After this, you must make again and then make install.

Recently, Eric Arnold (eric.arnold@sun.com) wrote an alternative to chat2.pl called Comm.pl. Its advantages over chat2.pl include a more intuitive interface that resembles Tcl's expect program, and includes some extra goodies like an interact() function for interactively getting input from the user. However, Comm.pl is still a Perl 4 library with lots of hard-coded system-specific constants. Until Comm.pl is updated to use Perl 5's Socket and Ioctl modules, I'll continue to use my patched copy of chat2.pl. For those who want to investigate Comm.pl further, it can be found at http://www.perl.com/CPAN/modules/authors/id/ERICA/. [Editor's note: after Lincoln wrote this article, the Expect module was announced. See page 37. -Jon]

THE CGI SCRIPT

The complete password-changing script is shown in Listing 1. We'll focus first on lines 58 through 110, where the subroutine named set_passwd() is defined. This is the core of the script, where the password is actually changed.

Our first step is to bring in chat2.pl, which we do using an old-fashioned require(), because chat2.pl is still a Perl 4 library file. It's not a real module, so we can't use use. We also define some constants: $passwd and $SU give the absolute path to the passwd and su programs respectively, and $TIMEOUT specifies a timeout of two seconds for our conversation with the su and passwd programs. If an expected output is not seen within this time, the subroutine aborts.

On line 64, we recover the name of the account to change as well the old and new passwords. We then call the chat::open_proc() function to open up a pseudo tty to the command su -c /usr/bin/passwd username. If successful, the chat package returns a filehandle that we use for the subsequent conversation. Otherwise, we abort with an error message.

Next, we wait for su to prompt for the original password (lines 69 through 73) by calling the function chat::expect(). This function takes the pseudo tty filehandle, a timeout value, and a series of pattern/expression pairs, and scans through the opened program's output looking for a match with each of the provided patterns. When a match is found, its corresponding expression is eval()'d and the result is returned. If no pattern is matched during the specified timeout period, an undef value is returned.

In the first call to expect(), we're looking for two possible patterns. The first pattern is the string "Password:" indicating that su is prompting for the user's current password. The second possible pattern is "user \w+ does not exist", which indicates that the account name we are attempting to su to is invalid. In the first case, we return the string "ok." In the second case we return the string "unknown user." Notice that because these expressions will be passed to eval(), we must enclose them in quotes in order to prevent Perl from trying to execute them as functions or method calls.

Next, in lines 74 to 76, we examine the return value from chat::expect() and act on it. If there's no return value at all, we return an error indicating that we timed out before seeing one of the expected patterns. If the return value is the "unknown user" string, we abort with an appropriate error message. Otherwise, we know that su is waiting for the password. We oblige it by calling chat:print() to send the old password to su.

We now repeat this chat::expect() and chat::print() sequence several times. First we await confirmation from su that the password was correct (lines 78-83). Next we provide passwd with the old and new passwords (lines 85-106) and wait for confirmation that they were acceptable. When done, we close the pseudo tty by calling chat::close() (line 107).

The only trick worth noting here is the call to chat::expect() on lines 95 to 98, where we provide passwd with the user's new password. With my version of passwd, there's a chance of the new password being rejected for being too simple. Sometimes the password is rejected for being too short, sometimes for being composed of lower-case letters only, and sometimes for other reasons. In addition to detecting the fact that the password has been rejected, we'd like to capture the reason given by passwd. We do this using parentheses in the regular expression match to create a backreference. The matched string is then returned from expect() when the expression "$1" is evaluated.

The return value from set_passwd() is a two-element array. The first element is a numeric result code, where a true value indicates a successful outcome. The second element is a string that gives the reason for failure, if any.

THE REST OF THE SCRIPT

Changing the password was the hard part. Let's step back now and walk through the rest of the script. At the top of the script we invoke Perl with the -T switch to turn taint checks on. Taint checks cause Perl to abort if we attempt to pass unchecked user input to external programs or shells. Since we invoke the su and passwd programs, it is a good idea to include these checks. We'd use the -w warning switch too, but chat2.pl generates many noise warnings about unused variables.

Lines 2 through 4 are there to make the taint checks happy. Explicitly setting the PATH and IFS environment variables prevents programming practices that rely on unsafe assumptions about the environment. We turn off I/O buffering on line 7, to avoid conflicts between the standard I/O buffering used by Perl and the external programs we launch.

On line 9 we load the standard CGI library and import the stan-dard and HTML3 sets of functions. The HTML3 set gives us access to HTML table definition tags. We now print the standard HTTP header, and begin the HTML page (lines 11 through 14).

Line 16 calls CGI::import_names() to import all the current CGI parameters into like-named Perl variables in the Q:: namespace. This script expects five different CGI parameters:

userthe name of the user
old the user's old password
new1the user's new password
new2 confirmation of the user's new password
refererthe URL of the page that originally linked to the script

After import_names() is called, there will be variables named $Q::user, $Q::old, and so forth.

Lines 18 through 33 define a block labeled TRY. In TRY we attempt to recover the user's information and set the password. If we encounter an error during this process, we call last TRY to fall through to the bottom of the block immediately (this is essentially a goto written in a structured manner). First we test whether the $Q::user parameter is defined at all. If it isn't, we just jump to the end of the block. Otherwise, we call a routine named check_consistency() to check whether all the other parameters are present and are in the expected format. If check_consistency() fails, we print out an error message and exit the block.

If we pass the consistency check, we call the set_passwd() routine that we looked at in detail above. If set_passwd() is successful, we print an acknowledgment message and set the variable $OK to true.

The actual call to set_passwd() is on line 27. The mess above and below it are a workaround for an error message that I found appearing in my server's error log: "stty: standard input: Invalid argument". This error message is issued when su tries to suppress the terminal's echo of the user's typed password. Since this error is irrelevant, we suppress it by temporarily redirecting standard error to /dev/null.

Outside the TRY block, line 35 calls create_form() to generate the fill-out form. We do this when $OK is false, causing the form to be displayed the first time the script is called, and regenerated if any errors occur during the TRY block. Because CGI.pm generates "sticky" fill-out forms automatically, the values the user previously typed into the form fields are retained.

Lines 37 through 42 generate the end of the page, a hypertext link labeled "EXIT SCRIPT" that takes the user back to the page that originally linked to the script, and a link to the site's home page. The URL for the EXIT SCRIPT link is generated from a CGI parameter named "referer." If that isn't defined, it uses the value returned by the referer() function. The rationale for this is discussed in: The Unix Pasword Changing Script..

Finally, let's look at the definitions of check_consistency() and create_form(). The check_consistency() subroutine, defined in lines 44 to 56, performs various sanity checks on the username and other CGI parameters. First it checks that the $Q::user, $Q::old, $Q::new1 and $Q::new2 fields are all present, and returns a warning message if any are missing. Next it checks that the $Q::new1 and $Q::new2 passwords are identical. If not, it warns the user that the new password and its confirmation don't match. The routine now verifies that the username has only printable non-whitespace characters only, and is no longer than 8 characters (this is the limit on my Linux system; it may be different on yours). Passwords must be no more than 30 characters in length. Finally, the routine uses get-pwnam() to check that the username provided is a valid account name on this system. If getpwnam() returns an empty list, the name is unknown and we return an error message to that effect. If the user name corresponds to the root user (user ID equals 0), we also return an error. It's not a good idea to let anyone change the superuser password via the web!

Lines 112 to 130 define create_form(), the routine responsible for creating the fill-out form. Using CGI's HTML shortcuts it generates a straightforward 2 row by 4 column table that prompts the user for her account name, and old and new passwords. We use call textfield() to generate the field that prompts the user for her account name, and call password_field() to create input fields for the passwords. (Password fields differ from ordinary text fields in that the letters the user types in are displayed as stars.)

The only trick in this form appears on line 127, where we create a hidden field named referer. This field stores the value returned by CGI::referer(), the URL of the page that linked to the script. We use the hidden field to turn this value into an invisible CGI parameter the very first time the script is called, which we later retrieve and use to generate the link labeled "EXIT SCRIPT." We have to store this value when the form is first generated because later, after the form has been submitted and the script reinvoked, referer() will return the URL of the script itself rather than the original page. The stickiness of CGI form fields guarantees that the original value of referer will be maintained through all subsequent invocations.

Lines 132 to 135 define do_error(), which creates a standard error message. The call to CGI::font() creates an HTML tag that causes the text within it to be enlarged and colored red.

CAVEATS

Before you install this script on your own system, you may need to make a few changes. Your versions of su and passwd may not behave exactly like mine. You may need to experiment a bit and change the prompt strings that chat::expect() scans for. This is particularly likely to be true if you use NIS or a shadow password system.

You should also be aware that web communications are not, by default, encrypted. When the remote user fills out the form and sends in her account name and passwords, this information could, theoretically, be intercepted by someone armed with a packet sniffer who had somehow gained access to one of the computer systems on the link between the remote user and the web server. If this bothers you, you can avoid the risk by installing the script on a server that uses the SSL (Secure Sockets Layer) encryption protocol, and configuring the server so that users can only access the page when SSL is active.

If you run an NT-based system, this script won't work at all because, gratefully, the Windows NT interfaces to user account databases are quite different from their Unix counterparts. Thanks to David Roth's excellent Win32::AdminMisc module, you can change Windows NT passwords simply by replacing the set_passwd() routine with this much simpler piece of code:

sub set_passwd ($$$) {
    use Win32::AdminMisc;
    use Win32::NetAdmin;
    my $DOMAIN = "NT Domain";
    my $CONTROLLER = '';
	
    my($user,$old,$new) = @_;
    return (undef,
            "Couldn't get primary domain controller name.")
      unless
        Win32::NetAdmin::GetController('','',$CONTROLLER);
    return (undef,
             "Couldn't log in as $user.")
      unless
        Win32::AdminMisc::LogonAsUser($DOMAIN,$user,$old);
    return (undef,
             "Couldn't change password for $user.")
      unless
         Win32::AdminMisc::SetPassword($CONTROLLER,$user,$new);
    return (1,"Password changed successfully for $Q::user.");
}

You'll need to change $DOMAIN to contain the correct domain for your Windows NT system.
See David's article NT Administration with Perl in TPJ #8 for more details.

__END__


Lincoln D. Stein wrote CGI.pm.
PREVIOUS  TABLE OF CONTENTS  NEXT