Tief unten im Motorraum des Linux-Systems treibt das Terminalmonster sein Unwesen. Mike Schilli schlüpft in den Blaumann und holt versteckte Technologie ans Licht, um störrischen Skripts seinen Willen aufzuzwingen.
Seit Anbeginn der Unix-Zeit macht es die Shell leicht, Kommandos miteinander zu verknüpfen. Man leitet einfach die Ausgabe des einen Kommandos zur Eingabe des anderen, und zusammenhängende Abläufe in Skripts zu verpacken hilft spielerisch, sukzessive immer komplexere Probleme automatisiert zu knacken. Wer sich aber die Mühe macht, einmal hinter die Kulissen der simpel erscheinenden Oberfläche eines Terminalfensters zu blicken, entdeckt, dass dort erstaunlich komplexe Vorgänge ablaufen, die aus der Steinzeit der Datenverarbeitung stammen und sich über Dekaden praktisch unverändert gehalten haben.
Während der Anwender nämlich auf der Tastatur herumtippt und das Terminalfenster die Werte der eingegebenen Zeichen sowie die Ausgabe abgesendeter Kommandos anzeigt, springt jedes Mal der Kernel dazwischen.Ein Device-Eintrag wie zum Beispiel »/dev/pts/0« verbindet die laufende Shell mit dem Kernel. Dahinter hängt ein sogenanntes TTY (Teletypewriter), ein Relikt aus Zeiten dummer Terminals, die über eine serielle Schnittstelle mit dem Rechner verbunden waren, ausgegebene Zeichen darstellten und die Tastendrücke des Users zurücklieferten.
Heute lesen Applikationen aus der Device-Datei, was der Benutzer getippt hat, und schreiben hinein, was auf dem Terminal erscheinen soll. Der Kernel übernimmt dann die teils komplizierten Details.
Seltenes Tier
Was ist nun eigentlich ein TTY? Eigentlich nur eine aktive Kernel-Funktion, die auf einem Device-Eintrag wie zum Beispiel »/dev/pts/0« in den User-Raum hineinspitzelt und dort zwei Dinge erledigt: Der Kernel reicht Tastatureingaben des Users, die ihm intern über eine USB- oder Bluetooth-Schnittstelle vorliegen, an den Device-Eintrag weiter, von wo aus eine Applikation im Userspace sie lesend entgegennehmen kann. Die Applikation greift, meist über ihre Standardausgabe, schreibend auf den Device-Eintrag zu, worauf der Kernel die Daten entgegennimmt und an das Ausgabeterminal weiterreicht. Bei Letzterem handelt es sich auf aktuellen Linux-Systemen meist um einen Terminalemulator wie Xterm, aber das Prinzip bleibt dasselbe.
Meist nutzt ein TTY übrigens voreingestellt die Echo-Funktion und leitet die Tastatureingaben des Users nicht nur an angeschlossene Applikationen weiter, sondern schreibt sie auch gleich ins Terminal – ohne Umweg über den Userspace. Möchte eine Applikation aber zum Beispiel zur Passworteingabe die Echo-Funktion abstellen, modifiziert sie kurzerhand die TTY-Einstellungen. Daraufhin stoppt der Kernel den Echo-Service temporär, bis die Applikation ihn wieder anfordert. Meist befindet sich ein TTY im sogenannten Cooked Mode und reicht die Eingaben des Users nicht bei jedem Tastendruck an die angeschlossene Applikation weiter, sondern nur in einem Schwung als ganze Zeile, sobald ein Zeilenumbruch kommt.
Dieses Verfahren taugt für Applikationen wie eine Kommandozeilen-Shell, doch Editoren wie zum Beispiel Vi können damit nichts anfangen: Sie benötigen jeden Tastendruck direkt und nicht erst, nachdem der Benutzer die Eingabetaste gedrückt hat. Deshalb stellen sie das verwendete TTY in den sogenannten Raw Mode und bekommen damit immer sofort alles mit. Allerdings müssen sie dafür sorgen, dass sich das Terminal nach Abschluss des Programms wieder im Normalmodus befindet, sonst rauft der Benutzer sich die Haare, wenn seine Shell plötzlich ausflippt und Sonderzeichen ausspuckt.
Schwer zu automatisieren
Dass Programme oder Skripts mit dem TTY kommunizieren, wird schnell klar, wenn man sich den Unterschied zwischen Programmen klarmacht, die über die Standardeingabe Benutzereingaben entgegennehmen, und solchen, die das direkt über das TTY erledigen. Listing 1 nutzt die Shell-Funktion »read«, um eine mit Enter abgeschickte Eingabe des Users einzulesen und diese anschließend auszugeben.
Listing 1
from-stdin.sh
#!/bin/sh read -p "Type something: " input echo "You typed: $input"
Wer den Aufruf von Listing 1 automatisieren möchte, um die geforderte Eingabe über ein Skript bereitzustellen, kann das wie im oberen Teil von Abbildung 2 mithilfe eines Here-Dokuments erledigen. Es pumpt den anliegenden Text einfach in die Standardeingabe der Applikation, die gar nicht weiß, dass die Information nicht vom User am Keyboard stammt, sondern aus der Konserve.
Ganz anders hingegen Listing 2: Es liest den einzugebenden Text direkt vom TTY, sperrt also Skripts kategorisch aus, die Daten in die Standardeingabe schreiben. Dafür mag es gute Gründe geben. So könnten zum Beispiel Passwörter im Spiel sein, die die Applikation lieber live eingetippt sähe als aus der Konserve kommend. Das führt jedoch nur dazu, dass eifrige Automatisierer sie gedankenlos im Dateisystem speichern. Einen schalen Nachgeschmack hinterlässt das Ganze in jedem Fall. Wäre es – im Notfall – denn ganz und gar unmöglich, so ein Programm mit eingeweckten Daten zu füttern?
Listing 2
from-tty.sh
#!/bin/sh /bin/echo -n "Type something: " /bin/echo "You typed:" `head -1 /dev/tty` /bin/echo "End of tty script"
Unter Unix ist ja bekanntermaßen nichts unmöglich, und das gilt auch in diesem Fall. Dieses Problem zu knacken, erfordert jedoch einen Ausflug in die obskure Welt der TTYs und ihrer kamerascheuen Artverwandten, der PTYs.
Pseudo statt Real
Nicht jede Applikation hängt direkt an einem Rechner mit Tastatur und Bildschirm. Was, wenn der Benutzer sich via SSH auf einem Remote-System einloggt und dort einen Terminaleditor wie Vi startet? Der Editor auf dem Remote-System nimmt Tastendrücke von einem dortigen TTY entgegen und stellt seine Ausgabe ebenfalls über dieses TTY dar. Von den Bits, die dabei sowohl bei Ein- als auch bei Ausgaben durch das Netzwerk fliegen, hat er keinen blassen Schimmer.
Die Shell auf dem Remote-System muss also dort aufgerufenen Applikationen ein TTY bereitstellen, das eigentlich keines ist, weil der dortige Kernel weder über ein angeschlossenes Keyboard noch ein Ausgabeterminal verfügt. Vielmehr handelt es sich um ein Pseudo-TTY. Dieses »pty« gaukelt einer angeschlossenen Applikation vor, ein wahres TTY zu sein, aus der sie Tastendrücke lesen und an das sie darzustellende Zeichen senden kann.
Das Pseudo-TTY gibt sich also gegenüber der Applikation auf dem Remote-Rechner als TTY aus, hält aber gleichzeitig eine Verbindung mit dem Host, von dem die SSH-Verbindung ausging. Es leitet die Tastatureingaben des Users dort an die Remote-Shell weiter und schickt die Ausgabe des Editors zurück zum Ursprungs-Host.
Wie Abbildung 3 zeigt, besteht ein PTY generell aus zwei Komponenten, die man politisch unkorrekt Master und Slave nennt. Der Slave gaukelt einer ferngesteuerten Applikation vor, ein lokales TTY zu sein, das Eingaben liefert und Ausgaben entgegennimmt. Der Master hingegen steuert die Slave-Komponente, indem er ihr Eingaben schickt, die der Slave als Tastatureingaben an die Applikation weiterreicht. Zudem holt der Master Ausgaben vom Slave ab, die von der Applikation stammen und für die Anzeige im Terminal bestimmt sind. Er interpretiert und verarbeitet sie.

Abbildung 3: Das Skript glaubt, mit einem TTY zu kommunizieren, redet aber mit einem vom Controller gesteuerten PTY.
Kind ferngesteuert
Damit nun ein Kontrollprogramm ein Skript wie das aus Listing 2 fernsteuern, ihm Eingaben unterjubeln und Ausgaben abfangen kann, erzeugt es die zwei Komponenten eines PTYs und startet das Skript in einem Kindprozess. Dann weist es dem Child die Slave-Komponente des PTYs als TTY zu und verbindet sich selbst mit der Master-Komponente. So bekommt es mit, wenn das Kind etwas schreibt, und kann ihm daraufhin Benutzereingaben unterjubeln und wieder auf Ausgaben lauschen. Genau nach diesem Verfahren arbeiten bekannte Programme wie Expect oder auch Script, das Shell-Sessions bequem und transparent in einer Logdatei aufzeichnet.
Listing 3 zeigt ein C-Programm, das Listing 2 fernsteuert, indem es ihm auf dessen TTY wie gewünscht Eingaben liefert. Zur Wahl der Programmiersprache für diese Aufgabe: Eigentlich bietet Go ja bequemere und sicherere Methoden zur String-Behandlung und eigentlich allem, was mit dem Allokieren von Speicher zu tun hat. In C dagegen arbeitet der Trapezkünstler ohne Netz und doppelten Boden; ein zu kleiner String-Puffer, und schon stürzt das Programm ab oder – noch schlimmer – hält Angreifern durch Buffer Overflows die Tür auf.
Allerdings fehlen in Go recht viele der für diese Aufgabe genutzten Funktionen der Linux-Programmierschnittstelle. Zwar lassen sie sich mittels der CGO-Schnittstelle aus dem Go-Code aufrufen, aber das ist wesentlich umständlicher, als gleich das native C-Interface zu verwenden.
Listing 3
pty.c
#define _XOPEN_SOURCE 600
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#define BUF_SIZE 1024
pid_t pty_fork(int *mfd);
int pty_master_open(char *sname);
int main(int argc, char *argv[]) {
char buf[BUF_SIZE];
char *shell;
int mfd;
size_t numRead;
pid_t cpid;
int written = 0;
cpid = pty_fork(&mfd);
if (cpid ==
-1)
exit(1); /* fork failed */
if (cpid == 0) {
shell = "/bin/sh";
execlp(shell, shell, "-c",
"./from-tty.sh", (char *) NULL);
exit(2); /* exe failed */
}
/* Send response */
sprintf(buf, "blah blah\n");
numRead = strlen(buf);
if (write(mfd, buf, numRead) != numRead)
exit(6);
sleep(2);
}
pid_t pty_fork(int *mfd) {
int sfd;
pid_t cpid;
char sname[BUF_SIZE];
*mfd = pty_master_open(sname);
if (*mfd == -1)
return -1;
cpid = fork();
if (cpid == -1) { /* fork() failed */
close(*mfd);
return -1;
}
if (cpid != 0) {
return cpid; /* parent returns */
}
/* New session */
if (setsid() == -1)
exit(7);
/* spty becomes child's stdin */
sfd = open(sname, O_RDWR);
if (sfd == -1)
exit(8);
if (dup2(sfd, STDIN_FILENO) !=
STDIN_FILENO)
exit(9);
return 0;
}
int pty_master_open(char *sname) {
int mfd;
char *spty;
/* open pty master */
mfd = posix_openpt(O_RDWR | O_NOCTTY);
if (mfd == -1)
return -1;
if (grantpt(mfd) == -1) {
close(mfd);
return -1;
}
if (unlockpt(mfd) == -1) {
close(mfd);
return -1;
}
spty = ptsname(mfd);
if (spty == NULL) {
close(mfd);
return -1;
}
if (strlen(spty) < BUF_SIZE) {
strcpy(sname, spty);
} else { /* buf too small */
close(mfd);
return -1;
}
return mfd;
}
Vater kontrolliert
Das Hauptprogramm »main« ab Zeile 13 erzeugt mit »pty_fork()« in Zeile 21 erst ein PTY-Paar und dann einen Kindprozess, der parallel mit dem Vaterprozess aus der Funktion zurückkommt. Er unterscheidet sich vom Vater darin, dass die Variable »cpid« für das Kind »0« ist und für den Vater den Wert der »pid« des Kinds annimmt.
So kann die If-Bedingung in Zeile 25 den Kindprozess abfangen und ihn dazu veranlassen, das Skript »from-tty.sh« (Listing 2) in einer neu erzeugten Shell auszuführen. Aus »pty_fork()« kommt außer der »pid« des Kindprozesses noch eine weitere Variable zurück: In »mfd« liefert die Funktion einen File-Deskriptor des erzeugten Master-PTYs. Da Funktionen in C im Gegensatz zu Go nur einen Wert zurückgeben können und »pty_fork()« mit der als Rückgabewert gelieferten »pid« schon ausgelastet ist, übergibt Zeile 21 die Variable »mfd« einfach als Pointer. Daraufhin schreibt die Funktion den ermittelten Wert für den Master-File-Deskriptor an die angegebene Adresse, sodass das Hauptprogramm später darauf zugreifen kann.
Um nun dem ferngesteuerten Kindprozess eine Nachricht aufs »pty« zu schreiben, sodass dieses sie als Benutzereingabe übers Terminal interpretiert, muss der Vater lediglich Daten auf den File-Deskriptor »mfd« des Master-PTYs schreiben. Das erledigt Zeile 35 mit der Systemfunktion »write()«, die einen Buffer und dessen Länge entgegennimmt.
Sekundenschlaf zu Testzwecken
Damit das Kontrollprogramm nicht sofort nach dem Senden der Tippsequenz abbricht und die Reaktion des ferngesteuerten Skripts verpasst, wartet Zeile 37 im Hauptprogramm noch zwei Sekunden, bevor »main« sich beendet. Da der Sekundenschlaf keine Garantie für eine zeitgerechte Ausführung bietet, kommt das freilich nur zu Testzwecken infrage. In einer Produktionsumgebung würde der Fernsteuerer die Ausgaben des Fernzusteuernden abfangen und darauf mit Eingaben reagieren, um so sicherzustellen, dass alle Nachrichten auch definitiv angekommen sind.
Das zur Kommunikation zwischen Vater und Kind benötigte PTY-Paar legt die Funktion »pty_master_open()« ab Zeile 75 an. Sie gibt im Erfolgsfall zwei Werte zurück: den Integerwert für den File-Deskriptor des Master-PTYs und, als Pointer im Argumentenfeld, den Dateipfad des Slave-PTYs »sname«. Hierfür erzeugt zunächst die Linux-Systemfunktion »posix_openpt()« ein PTY-Paar. Mit »grantpt()« in Zeile 84 und »unlockpt()« in Zeile 89 erhält der Vaterprozess Zugriff darauf. Den PTY-Pfad zum zugehörigen PTY-Slave liefert die Systemfunktion »ptsname()« in Zeile 94.
Um mit dem Slave Kontakt aufzunehmen und ihn als kontrollierendes Terminal zu adoptieren, muss der Kindprozess in »pty_fork()« ab Zeile 61 mit »setsid()« erst eine neue Session erzeugen (so wie das eine Shell tut). Dann öffnet er den vorher ermittelten PTY-Dateipfad mit »open()« zum Lesen und Schreiben (»O_RDWR«). Die Unix-Systemfunktion »dup2()« in Zeile 68 verbindet anschließend die Standardeingabe STDIN des Kinds (nicht die des Vaters – der ist bereits in Zeile 57 zurückgekehrt) auf den PTY-Slave. Eine Applikation, die sowohl lesend als auch schreibend fernsteuert, würde die Deskriptoren für STDOUT und STDERR ebenso ans TTY hängen.
It’s a Wrap
Sie kompilieren das C-Programm aus Listing 3 mit »cc -o pty pty.c« zu einem Binary »pty«. Übrigens braucht das Listing in Zeile 1 die Makro-Definition »# define _XOPEN_SOURCE 600«, damit es auch den Posix-Standard von 2004 nutzt. Andernfalls kompiliert GCC die PTY-Systemfunktionen nur mit Warnungen, und das Programm stürzt mit einem Segfault ab.
Wer das Binary »pty« ausführt (Listing 4) und das Shell-Skript aus Listing 2 im selben Verzeichnis abgelegt hat, sieht, dass das Ganze wie gewünscht funktioniert. Das Skript »from_tty.sh« dachte offensichtlich, es hätte die Eingabe »blah blah« vom User über das Terminal erhalten, nicht vom fernsteuernden Kontrollprogramm aus Listing 3. So kann man sich irren.
Listing 4
Binary ausführen
$ ./pty
Type something:
You typed: blah blah
End of tty script
Vertiefung gefällig?
Wenn Sie sich weiter in die Materie vertiefen möchten, finden Sie im Jahrhundertwerk von Michael Kerrisk über die Linux-Systemprogrammierung [1] exzellente Erklärungen zur Funktion von PTYs sowie hilfreiche Codebeispiele zur Fernsteuerung. Dazu zählt unter anderem der komplette Quellcode einer simplen Implementierung des Utilitys »script« zur Aufzeichnung von Terminalsitzungen. Der Blogpost von Linus Akesson [2] gibt einen historischen Abriss über Terminals, TTYs sowie PTYs und bietet anschauliche Beispiele. ((uba)/(uba))
Infos
- “The Linux Programming Interface”: https://man7.org/tlpi/
- “TTYs demystified”: http://www.linusakesson.net/programming/tty/index.php
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/06/snapshot/








