Christian Seiler: Verständnisfehler Set-UID

Beitrag lesen

Hallo Dennis,

* Die Saved-Set-User-ID

Wenn Du per setuid() die Benutzerkennung wechselst, dann werden reale und effektive Benutzer-ID auf eben die neue Benutzerkennung angepasst. Die Saved-Set-User-ID wird dann auf die alte Benutzerkennung gesetzt - damit weiß der Kernel, dass bei einem eventuellen, neuen Zurückwechseln-Wollen der Prozess berechtigt ist, zur *alten* Benutzerkennung zurückzukehren.

Ok, wenn Du ein normales Programm hast, dann hat das ja alle 3 Benutzerkennungen gleich:

R | E | S
---+---+---
 1 | 1 | 1

Wenn das Programm dem User mit der UID 2 gehört und setuid ist, dann wird (*falls* es kein Script ist) es mit der effektiven und Saved-Set-User-ID 2 gestartet (der weiter unten erklärte Systemaufruf execve() macht dies):

R | E | S
---+---+---
 1 | 2 | 2

Das Programm greift auf's Dateisystem etc. zu wie ein Programm des Users 2. Wenn das Programm aber wieder als User 1 arbeiten will, dann kann es setuid (1) machen (das darf es, weil 1 eine der UIDs ist, die das Programm besitzt; wenn es setuid (3) machen wollte, bekäme es eine Fehlermeldung).

R | E | S
---+---+---
 1 | 1 | 2

Das Programm kann dann wieder ein setuid (2) machen, um wieder zum ursprünglichen Zustand zurückzukehren:

R | E | S
---+---+---
 1 | 2 | 2

Zusätzlich gibt's noch die Funktion setreuid (), mit der man die reale User-ID auch ändern kann. Dazu gleich mehr:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
  system("/bin/bash /home/vps/bin/pre-update.sh");
  return 0;
}

Ok, hier spielt Dir Deine Shell einen Streich.

Dein ursprüngliches Problem ist, dass der Kernel das setuid-Bit nicht auf Scripte anwendet. Dein jetztiges Problem ist, dass die Bash beim Start die effektive User-ID auf die reale User-ID setzt - d.h. Dein Script ist wieder "unprivilegiert". Du kannst übrigens auch etwas anderes als /bin/bash ins system() reinschreiben, system() führt immer /bin/sh -c parameter aus, wobei parameter das ist, was Du system() übergibst.

#include <stdio.h>  
#include <stdlib.h>  
#include <sys/types.h>  
#include <unistd.h>  
  
int main (int argc, char **argv) {  
  // hier kann dann entweder die UID fest kodiert stehen wie in  
  // meinem einfachen Beispiel oder eben eine Logik, die den  
  // Pfad des ausgeführten Programms herausfindet und die UID  
  // über stat() ausliest (wird relativ kompliziert, wenn man es  
  // richtig machen will)  
  if (setreuid (555, 555) == -1) { // 555 sei hier die UID vom user 'vps'  
    // Fehler  
    perror (argv[0]);  
    return 1;  
  }  
  // system() ist eigentlich böse, wenn bei setuid-Prozessen eingesetzt  
  // (hat mit möglichen Attacken über Umgebungsvariablen zu tun)  
  // richtig[tm] wäre es eigentlich, selbst per execve() das richtige  
  // Script auszuführern, der Code wäre allerdings nicht so anschaulich  
  return system ("...");  
}

Wenn Du's über execve() machen willst, dann wird's komplizierter, dafür hast Du dann die Sicherheit, dass Dir niemand über Umgebungsvariablen reinpfuschen kann. Der wichtigste Unterschied zwischen execve() und system() ist der: execve() führt ein neues Programm aus. Allerdings wird das in der Form ausgeführt, als dass das aktuelle Programm durch das neue Programm ersetzt wird, d.h. wenn execve() erfolgreich war, dann wird alles, was *nach* execve() kommt, *nicht* mehr ausgeführt. Wie funktioniert nun system()? Es ruft *zuerst* fork() auf (fork() erzeugt eine 1:1-Kopie des aktuellen Prozesses und an Hand des Rückgabewerts kann man unterscheiden, in welchem Prozess die Ausführung gerade weitergeht), ruft dann im *Kindprozess* mit ein paar Defaultwerten execve() auf und wartet im *Elternprozess* mit wait() oder waitpid() o.ä. bis der Kindprozess fertig ist und gibt den Rückgabewert des Kindprozesses zurück.

Da Du *nach* der Ausführung Deines Scripts ja die Ausführung sowieso beenden willst, brauchst Du den ganzen fork()-Gedöns gar nicht. Daher reicht es für Dich aus, wenn Du folgendes machst:

int main (int argc, char **argv) {  
  char *const p_env[] = {  
    "PATH=/bin:/usr/bin",  
    NULL  
  };  
  char *const p_argv[] = {  
    "/bin/bash",  
    "/home/vps/bin/pre-update.sh",  
    NULL  
  };  
  const char *p_prog = "/bin/bash";  
  // ... hier der setreuid-code  
  execve (p_prog, p_argv, p_env);  
  perror (argv[0]);  
  return 1;  
}

execve() ist natürlich komplizierter: Bei system() übernimmt Dir system() das fork(), execve() etc. und die Shell übernimmt Dir das Trennen der Argumente, das Suchen der ausführbaren Datei in PATH etc. Bei execve() musst Du das alles manuell machen. execve() erwartet 3 Parameter:

1. Der Pfad zur ausführbaren Datei selbst. Das muss wirklich ein Pfad sein, den Du auch per fopen() verwenden könntest.

2. Die Parameter, die im neuen Programm über argv[] abrufbar sein sollen, mit NULL terminiert.

3. Die Umgebungsvariablen als String-Liste, ebenfalls mit NULL terminiert.

Wenn Du also system("ls") ausführst, wird im Endeffekt execve() von system() mit folgenden Parametern aufgerufen:

1. "/bin/sh"
2. { "sh", "-c", "ls", NULL }
3. environ  // (die globale Variable, die die Umgebung des *aktuellen* Programms enthält)

Die Shell analysiert nun für Dich das, was nach dem "-c" kommt, löst dann den Programmnamen durch die PATH-Umgebungsvariable auf und führt dann *selbst* wiederum ein execve() aus mit folgenden Argumenten:

1. "/bin/ls"
2. { "ls", NULL }
3. environ

Wenn Du dagegen system ("ls -l") eingibst, dann wird folgendes gemacht: system() macht im Kindprozess execve() mit folgenden Parametern:

1. "/bin/sh"
2. { "sh", "-c", "ls -l", NULL }
3. environ

Die Shell macht nun execve() mit folgenden Argumenten:

1. "/bin/ls"
2. { "ls", "-l", NULL }
3. environ

Ok, was ist jetzt an meinem execve von oben anders? Schauen wir uns die 3 Argumente nochmal an:

1. "/bin/bash"
2. { "/bin/bash", "/home/vps/bin/pre-update.sh", NULL }
3. { "PATH=/bin:/usr/bin", NULL }

Ok, die ersten beiden Argumente sind identisch denen, die die Shell im Durchlauf mit system() auch genutzt hätte. Das dritte Argument ist jedoch anders: Es wird nicht die Umgebung des aktuellen Prozesses übergeben, sondern eine komplett neue, leere, in der nur PATH gesetzt ist (auf etwas minimalistisches). Die Frage: Warum?

Ok, stell Dir folgendes vor: Du hast den Code mit system ("/bin/bash /pfad/zum/script"); bei Dir drin. Jemand ruft dann Dein setuid-Programm auf, setzt aber vorher *irgendwelche* Umgebungsvariablen, die eine besondere Bedeutung haben, auf bösartige Werte. Dann werden die Umgebungsvariablen zum Script durchgereicht. Nehmen wir (das einfachste Beispiel) PATH: Wenn der Aufrufer bei sich PATH auf /boeser/pfad:/bin:/usr/bin:... setzt und bei sich in /boeser/pfad eine ausführbare Datei namens 'ls' existiert und Dein Script selbst ruft 'ls' auf - dann würde Dein Script plötzlich eben diese bösartige Datei ausführen. Du hattest diesmal sogar Glück, dass Du im system()-Aufruf "/bin/bash ..." stehen hattest und nicht "bash ..." - dann wäre nämlich schon in der Phase ein Angriff möglich. Und PATH ist nicht die einzige Umgebungsvariable, mit der man Sachen anstellen kann, es ist nur die einfachste und anschaulichste Lösung. Weitere böse Umgebungsvariablen beinhalten beispielsweise LD_LIBRARY_PATH und LD_PRELOAD.

Mit der execve()-Lösung hast Du also eine saubere Umgebung. Wenn Dir PATH nicht ausreicht, sondern Du mehr Umgebungsvariablen schon gesetzt haben musst, dann kannst Du diese natürlich zu obigem Code hinzufügen.

Der Gesamtcode sähe also wie folgt aus:

#include <stdio.h>  
#include <stdlib.h>  
#include <sys/types.h>  
#include <unistd.h>  
  
int main (int argc, char **argv) {  
  char *const p_env[] = {  
    "PATH=/bin:/usr/bin",  
    // eventuell noch weitere Umgebungsvariablen, wie z.B.  
    "EDITOR=/usr/bin/nano",  
    NULL  
  };  
  char *const p_argv[] = {  
    "/bin/bash",  
    "/home/vps/bin/pre-update.sh",  
    NULL  
  };  
  const char *p_prog = "/bin/bash";  
  // hier kann dann entweder die UID fest kodiert stehen wie in  
  // meinem einfachen Beispiel oder eben eine Logik, die den  
  // Pfad des ausgeführten Programms herausfindet und die UID  
  // über stat() ausliest (wird relativ kompliziert, wenn man es  
  // richtig machen will)  
  if (setreuid (555, 555) == -1) { // 555 sei hier die UID vom user 'vps'  
    // Fehler  
    perror (argv[0]);  
    return 1;  
  }  
  // Script ausführen  
  execve (p_prog, p_argv, p_env);  
  perror (argv[0]);  
  return 1;  
}

Damit hast Du dann ein kleines C-Programm, das nichts anderes tut, als Dein Shell-Script mit sauberen (!) Umgebungsvariablen unter einem anderen User aufzurufen.

Viele Grüße,
Christian

--
"I have always wished for my computer to be as easy to use as my telephone; my wish has come true because I can no longer figure out how to use my telephone." - Bjarne Stroustrup