Seit Anbeginn der Unix-Zeit ist die Shell einzigartig darin, wie einfach sie es macht, Kommandos miteinander zu verknüpfen. Die Ausgabe des einen Kommandos zur Eingabe des anderen zu leiten ist ein Kinderspiel, und zusammenhängende Abläufe in Skripts zu verpacken hilft spielerisch sukzessive immer komplexere Probleme automatisiert zu knacken.
Wer sich die Mühe macht, einmal hinter die Kulissen der simpel erscheinenden Oberfläche eines Terminal-Windows 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 User nämlich auf der Tastatur herumtippt und das Terminalfenster die Werte der eingegebenen Zeichen sowie die Ausgabe abgeschickter Kommandos anzeigt, springt jedes Mal der Kernel dazwischen. Die laufende Shell ist über einen Device-Eintrag wie zum Beispiel /dev/pts/0
mit dem Kernel verbunden. Dahinter hängt ein sogenanntes Tty, 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 User getippt hat, und schreiben hinein, was auf dem Terminal erscheinen soll. Der Kernel übernimmt dann die teils komplizierten Details.
Was ist nun eigentlich ein Tty? Eigentlich nur eine aktive Kernelfunktion, die auf einem Device-Eintrag wie zum Beispiel /dev/pts/0
in den User-Raum hineinspitzelt und dort zwei Dinge erledigt: Einmal reicht der Kernel Tastatureingaben des Users, die ihm intern über eine USB- oder Bluetooth-Schnittstelle vorliegen, an den Device-Eintrag weiter, von wo aus eine Applikation im User-Space sie lesend entgegennehmen kann. Und zweitens kann die Applikation, meist über ihre Standardausgabe, auf den Device-Eintrag schreibend zugreifen, worauf der Kernel diese Daten entgegennimmt und an das Ausgabe-Terminal weiterreicht. Letzteres ist auf aktuellen Linux-Systemen meist ein Terminal-Emulator wie das Programm xterm
, aber das Prinzip bleibt das Gleiche.
Meist hat ein Tty übrigens auch noch die Echo-Funktion eingestellt, und leitet die Tastatureingaben des Users nicht nur an angeschlossene Applikationen weiter, sondern schreibt sie auch gleich ins Terminal ohne den Umweg über den User-Space. Möchte eine Applikation aber zum Beispiel zur Passworteingabe die Echo-Funktion abstellen, modifiziert sie kurzerhand die Tty-Einstellungen. Daraufhin stellt der Kernel den Echo-Service temporär ein, bis die Applikation ihn wieder anfordert.
Und 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 ist gut für Applikationen wie eine Kommandozeilen-Shell, doch Editoren wie zum Beispiel vi
können damit nichts anfangen, da sie jeden Tastendruck direkt brauchen und nicht erst nachdem der User "Enter" gedrückt hat. Deshalb stellen sie das verwendete Tty in den "raw mode", und bekommen damit immer sofort alles mit. Allerdings müssen sie dafür sorgen, dass das Terminal nach Abschluss des Programms wieder im Normalmodus ist, sonst rauft der User sich die Haare, wenn seine Shell plötzlich ausflippt und Sonderzeichen ausspuckt.
Abbildung 1: Tastatur und Terminal verbindet der Kernel normalerweise über ein Tty mit dem Skript. |
Dass Programme oder Skripts mit dem Tty kommunizieren, wird schnell klar, wenn man sich den Unterschied zwischen Programmen klar macht, die über die Standardeingabe User-Eingaben entgegennehmen und solche, die dies direkt über das Tty machen. Listing 1 nutzt die Shell-Funktion read
, um eine mit Enter abgeschickte Eingabe des Users einzulesen und diese anschließend auszugeben.
Wer den Aufruf von Listing 1 automatisieren möchte, um die geforderte Eingabe über ein Skript bereitzustellen, kann dies wie im oberen Teil von Abbildung 2 mit Hilfe eines Here-Dokuments erledigen. Dieses pumpt den anliegenden Text einfach in die Standardeingabe der Applikation, und letztere weiß gar nicht, dass die Information nicht vom User am Keyboard, sondern aus der Konserve stammt.
1 #!/bin/sh 2 3 read -p "Type something: " input 4 echo "You typed: $input"
Abbildung 2: Das erste Skript lässt sich automatisieren, das zweite nicht. |
Ganz anders hingegen Listing 2: Es liest den einzugebenden Text direkt vom Tty, sperrt also Skripts, die Daten in die Standardeingabe schreiben, kategorisch aus. Dafür mag es gute Gründe geben, weil zum Beispiel Passwörter im Spiel sind, die die Applikation lieber live eingetippt sähe als aus der Konserve kommend, was doch nur dazu führt, dass eifrige Automatisierer sie gedankenlos im Dateisystem speichern. Aber einen schalen Nachgeschmack hinterlässt das Ganze schon, wäre es -- im Notfall! -- denn ganz und gar unmöglich, so ein Programm mit eingeweckten Daten zu füttern?
1 #!/bin/sh 2 3 /bin/echo -n "Type something: " 4 /bin/echo "You typed:" `head -1 /dev/tty` 5 /bin/echo "End of tty script"
Auf Unix ist ja bekanntermaßen nichts unmöglich, und das gilt auch in diesem Fall, aber um dieses Problem zu knacken, ist ein Ausflug in die obskure Welt der Ttys und ihren kamerascheuen Artverwandten, den Ptys, unumgänglich.
Nicht jede Applikation hängt direkt an einem Rechner mit Tastatur und Bildschirm. Was, wenn der User sich via ssh
auf einem Remote-System einloggt, und dort einen Terminal-Editor wie vi
startet? Der vi
auf dem Remote-System nimmt Tastendrücke von einem Tty dort entgegen, und stellt seine Ausgabe ebenfalls über dieses Tty dar. Von Bits, die sowohl bei Ein- als auch bei Ausgaben dabei über's 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 über ein Pseudo-Tty, ein pty
, das einer angeschlossenen Applikation vorgaukelt, ein wahres Tty zu sein, aus der sie Tastendrücke lesen und an das sie darzustellende Zeichen senden kann.
Abbildung 3: Das Skript denkt, mit einem Tty zu kommunizieren, redet aber mit einem vom Controller gesteuerten Pty. |
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, leitet die Tastatureingaben des Users dort an die Remote-Shell weiter, und schickt die Ausgabe des Editors zurück zum Ursprungs-Host. Wie Abbildung 2 zeigt, besteht ein Pty generell aus zwei Komponenten, die Master und Slave heißen. 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. Weiter holt der Master Ausgaben vom Slave ab, die von der Applikation stammen und für das Terminal zum Anzeigen bestimmt sind, interpretiert und verarbeitet sie.
Damit nun ein Kontroll-Programm ein Skript wie Listing 2 fernsteuern kann, 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, kann ihm daraufhin Usereingaben 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.
001 #define _XOPEN_SOURCE 600 002 #include <fcntl.h> 003 #include <unistd.h> 004 #include <stdlib.h> 005 #include <string.h> 006 #include <stdio.h> 007 008 #define BUF_SIZE 1024 009 010 pid_t pty_fork(int *mfd); 011 int pty_master_open(char *sname); 012 013 int main(int argc, char *argv[]) { 014 char buf[BUF_SIZE]; 015 char *shell; 016 int mfd; 017 size_t numRead; 018 pid_t cpid; 019 int written = 0; 020 021 cpid = pty_fork(&mfd); 022 if (cpid == -1) 023 exit(1); /* fork failed */ 024 025 if (cpid == 0) { 026 shell = "/bin/sh"; 027 execlp(shell, shell, "-c", 028 "./from-tty.sh", (char *) NULL); 029 exit(2); /* exe failed */ 030 } 031 032 /* Send response */ 033 sprintf(buf, "blah blah\n"); 034 numRead = strlen(buf); 035 if (write(mfd, buf, numRead) != numRead) 036 exit(6); 037 sleep(2); 038 } 039 040 pid_t pty_fork(int *mfd) { 041 int sfd; 042 pid_t cpid; 043 char sname[BUF_SIZE]; 044 045 *mfd = pty_master_open(sname); 046 if (*mfd == -1) 047 return -1; 048 049 cpid = fork(); 050 051 if (cpid == -1) { /* fork() failed */ 052 close(*mfd); 053 return -1; 054 } 055 056 if (cpid != 0) { 057 return cpid; /* parent returns */ 058 } 059 060 /* New session */ 061 if (setsid() == -1) 062 exit(7); 063 064 /* spty becomes child's stdin */ 065 sfd = open(sname, O_RDWR); 066 if (sfd == -1) 067 exit(8); 068 if (dup2(sfd, STDIN_FILENO) != 069 STDIN_FILENO) 070 exit(9); 071 072 return 0; 073 } 074 075 int pty_master_open(char *sname) { 076 int mfd; 077 char *spty; 078 079 /* open pty master */ 080 mfd = posix_openpt(O_RDWR | O_NOCTTY); 081 if (mfd == -1) 082 return -1; 083 084 if (grantpt(mfd) == -1) { 085 close(mfd); 086 return -1; 087 } 088 089 if (unlockpt(mfd) == -1) { 090 close(mfd); 091 return -1; 092 } 093 094 spty = ptsname(mfd); 095 if (spty == NULL) { 096 close(mfd); 097 return -1; 098 } 099 100 if (strlen(spty) < BUF_SIZE) { 101 strcpy(sname, spty); 102 } else { /* buf too small */ 103 close(mfd); 104 return -1; 105 } 106 107 return mfd; 108 }
Listing 3 zeigt ein 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 Stringbehandlung und eigentlich allem was mit dem Allokieren von Speicher zutun hat. In C arbeitet der Trapezkünstler ohne Netz und doppelten Boden, ein zu kleiner Stringpuffer und schon stürzt das Programm ab oder, noch schlimmer, hält Angreifern wegen Bufferoverflows die Tür auf. Allerdings fehlen in Go recht viele der in dieser Ausgabe genutzten Funktionen der Linux-Programmierschnittstelle. Zwar ist es möglich, sie mittels der CGO-Schnittstelle aus dem Go-Code aufzurufen, aber das ist umständlicher als gleich die natürliche C-Schnittstelle zu verwenden.
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 und sich vom Vater darin unterscheidet, dass die Variable cpid
für das Kind 0
ist und für den Vater den Wert der pid
des Kindes annimmt.
So kann die if
-Bedingung in Zeile 25 den Kindprozess abfangen und es 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, worauf die Funktion den ermittelten Wert für den Master-File-Deskriptor an die angegebene Adresse schreibt, sodass das das Hauptprogramm später auf den Wert zugreifen kann.
Um nun dem ferngesteuerten Kindprozess eine Nachricht aufs pty
zu schreiben, auf dass dieses sie als Usereingabe übers Terminal interpretiert, muss der Vater lediglich Daten auf den File-Deskriptor mfd
des Master-Ptys schreiben, wie das Zeile 35 mit der Systemfunktion write()
tut, die einen Buffer und dessen Länge entgegennimmt.
Damit das Kontrollprogramm nicht sofort nach dem Senden der Tippsequenz abbricht und die Reaktion des ferngesteuerten Skripts verpasst, wartet Zeile 37 im Hauptprogramm in Listing 3 noch zwei Sekunden, bevor main
sich beendet. Das ist freilich nur zu Testzwecken akzeptabel, da Sekundenschlaf keine Garantie zur zeitgerechten Ausführung bietet. In einer Produktionsumgebung würde der Fernsteuerer Ausgaben des Fernzusteuernden abfangen und darauf mit Eingaben reagieren, damit sichergestellt ist, 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 in Listing 3 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 und 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 letzterem 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), und dann den vorher ermittelten Pty-Dateipfad mit open()
zum Lesen und Schreiben (O_RDWR) öffnen. Die Unix-Systemfunktion dup2()
in Zeile 68 verbindet anschließend die Standardeingabe STDIN des Kindes (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.
Das C-Programm in Listing 3 kompiliert sich mit cc -o pty pty.c
zu einem Binary pty
. Übrigens braucht Listing 3 in Zeile 1 die Makro-Definition
# define _XOPEN_SOURCE 600
damit es auch den Posix-Standard von 2004 nutzt, sonst kompiliert gcc
die Pty-Systemfunktionen nur mit Warnungen und das Programm stürzt mit einem Segfault ab.
Wer das Binary pty
ausführt, und das Shell-Skript von Listing 2 im gleichen Verzeichnis abgelegt hat, sieht, dass das Ganze wie gewünscht funktioniert:
$ ./pty
Type something:
You typed: blah blah
End of tty script
Das Shellskript from_tty.sh
dachte offensichtlich, es hätte die Eingabe blah blah
vom User über's Terminal erhalten und nicht von dem fernsteuernden Kontrollprogramm in Listing 3. So kann man sich irren.
Wer sich weiter in die Materie vertiefen möchte, findet im Jahrhundertwerk von Michael Kerrisk zur Linux-Systemprogrammierung ([2]) exzellente Erklärungen zur Funktion von Ptys und hilfreiche Codebeispiele zur Fernsteuerung, unter anderem der komplette Source-Code einer simplen Implementierung der Utility script
zur Aufzeichnung von Terminal-Sessions. Das Blogpost von Linus Akesson ([3]) gibt einen historischen Abriss über Terminals, Ttys und Ptys und bietet anschauliche Beispiele.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/05/snapshot/
Michael Kerrisk, "The Linux Programming Interface", No Starch Press, 2010, ISBN 1593272200, Free online: https://man7.org/tlpi/
"TTYs demystified", http://www.linusakesson.net/programming/tty/index.php
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc