ejecución segura de comandos con Python: subproceso.Popen

La seguridad es importante para mí durante el desarrollo del cliente picockpit.

Lo siguiente se aplica a los sistemas Linux (pero probablemente es aplicable a todos los sistemas tipo Unix, incluido macOS)

Python permite ejecutar comandos externos utilizando el módulo de subproceso.

importar subproceso

En la próxima versión de PiCockpit, los usuarios podrán crear sus propios botones (simplemente editando un archivo JSON en la Pi) para ejecutar comandos.

PiCockpit se enviará con tres botones por defecto:

imagen

Todos estos comandos requieren privilegios de root. picockpit-client, por defecto, se ejecuta con privilegios de root en su sistema.

Pero otros comandos que quieras ejecutar, deben ser ejecutados como usuarios con menos privilegios (por defecto tenemos el usuario "pi"). De hecho, algunos comandos, como el navegador Chromium residuos para ejecutar como root.

En resumen, necesitamos una solución para ejecutar estos procesos utilizando un usuario diferente.

Esto es posible, gracias a la preexec_fn para popen.

Aquí está mi código:

def thread_run(self):
     def sanitize(cadena_de_entrada):
         #https://maxharp3r.wordpress.com/2008/05/15/pythons-minidom-xml-and-illegal-unicode-characters/
         RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])' + \
                          u'|' + \
                          u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])’ % \
                          (chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff),
                           chr(0xd800), chr(0xdbff), chr(0xdc00), chr(0xdfff))
         return re.sub(RE_XML_ILLEGAL, "", input_string)

    def demote(user, uid, gid):
         def función_demostración():
             print("empezando")
             print ('uid, gid = %d, %d' % (os.getuid(), os.getgid())
             print (os.getgroups())
             ¡# initgroups debe ser ejecutado antes de perder el privilegio de establecerlo!
             os.initgroups(user, gid)
             os.setgid(gid)
             # esto debe ejecutarse en último lugar
             os.setuid(uid)
             print("finalizado el descenso de categoría")
             print('uid, gid = %d, %d' % (os.getuid(), os.getgid())
             print (os.getgroups())
         return función_demostración

    def ejecutar_cmd(cmd, cmd_def):
         código_de_retorno = 0
         return_error = ""
         return_resultado = ""
         tiempo de espera = Ninguno
         usuario = "pi"
         si "timeout" en cmd_def:
             si isinstance(cmd_def["timeout"], int):
                 si cmd_def["timeout"] > 0:
                     timeout = cmd_def["timeout"]
         si "usuario" en cmd_def:
             if isinstance(cmd_def["user"], str):
                 user = cmd_def["user"]
         Inténtalo:
             print("picontrol :: configuración de los derechos de los usuarios")
             uid = pwd.getpwnam(user).pw_uid
             gid = pwd.getpwnam(user).pw_gid
             print("picontrol :: usuario solicitado " + usuario + " uid " + str(uid) + " gid " + str(gid))
             print("picontrol :: iniciando el comando ... ")
             print(cmd)
             proc = subproceso.Popen(
                 cmd,
                 stdout=subproceso.PIPE,
                 stderr=subproceso.PIPE,
                 preexec_fn=demote(user, uid, gid)
             )
             # TODO: manejar el tiempo de espera de manera diferente
                 # timeout=timeout)               
             print("picontrol :: AFTER subprocess.Popen")
         excepto FileNotFoundError:
             código_de_retorno = errno.ENOENT
             return_error = "Comando no encontrado en su sistema " + " ".join(cmd)
         # TODO: ¿se llamará esto realmente alguna vez?
         excepto subproceso.TimeoutExpired:
             código_de_retorno = errno.ETIME
             return_error = "El tiempo de espera del comando ha expirado (" + str(cmd_def["timeout"]) + " sec)"
         excepto Excepción como e:
             si hasattr(e, 'errno'):
                 código_de_retorno = e.errno
             Si no:
                 código_de_retorno = 1
             si hasattr(e, 'mensaje'):
                 return_error = str(e.message)
             Si no:
                 return_error = str(e)
         Si no:
             El proceso # puede ejecutarse normalmente, sin excepciones en el arranque
             process_running = True
             mientras se ejecuta el proceso:
                 si proc.poll() no es None:
                     process_running = False
                 si self.stop_flag.is_set():
                     print("bandera de parada recibida en process_running, poniendo process_running en false")
                     process_running = False
                     # y también hacer los pasos para la terminación activa después de eso.
                 # medio segundo de resolución
                 time.sleep(0.5)
             código_de_retorno = proc.código_de_retorno
             return_error = sanitize(proc.stderr.read().decode('UTF-8').rstrip("\r\n"))
             return_result = sanitize(proc.stdout.read().decode('UTF-8').rstrip("\r\n"))

        return (código_de_retorno, error_de_retorno, resultado_de_retorno)

Este código es un trabajo en progreso, por lo que algunas partes pueden no funcionar. Sin embargo, las partes que quiero discutir en esta entrada del blog SÍ funcionan.

Usuario en Linux

A un nombre de usuario, en Linux, se le asignará un identificador de usuario, un grupo de usuarios, y el usuario será un miembro de otros grupos.

/etc/passwd contiene los usuarios definidos en el sistema:

imagen

Las entradas pueden descodificarse como sigue:

usuario : contraseña : identificador de usuario (uid) : identificador de grupo (gid) : nombre completo del usuario (GECOS) : directorio principal del usuario : shell de acceso

Nota lateral interesante: el almacenamiento de la contraseña en /etc/passwd (de forma encriptada) es opcional (en caso de que no se almacene la contraseña, un x se almacena aquí); dado que este archivo es legible para todo el mundo, muchos sistemas optan por utilizar el archivo /etc/shadow, legible para el usuario root, en su lugar:

imagen

que sí contiene la contraseña.

Para picockpit, obtenemos el nombre de usuario del archivo de configuración JSON, y queremos configurar todo lo demás en consecuencia. Así que empezamos con:

uid = pwd.getpwnam(user).pw_uid
gid = pwd.getpwnam(user).pw_gid

(Asegúrese de importar pwd).

El módulo pwd nos permite acceder al archivo /etc/passwd usando Python. getpwnam busca la línea correspondiente por nombre de usuario, en nuestro caso "pi", o "root" (o lo que quieras, lo que se establezca en el archivo JSON).

Esto nos ayudará a obtener el id de usuario (uid) y el id de grupo (gid) del usuario.

A continuación llamamos al subproceso.Popen:

proc = subproceso.Popen(
     cmd,
     stdout=subproceso.PIPE,
     stderr=subproceso.PIPE,
     preexec_fn=demote(user, uid, gid)
)

El stdout y el stderr son PIPEd, por lo que podemos capturarlos más tarde.

el preexec_fn se establece como demote(user, uid, gid).

¡Importante!

Esta es una parte importante: preexec_fn espera una función handle, que ejecutará en el contexto del nuevo proceso que está iniciando (¡así que el proceso se degradará a sí mismo!). Esta función se ejecutará antes del código real, así podemos estar seguros de que el código se ejecutará en el contexto que queremos que se ejecute.

De la forma en que pasamos la degradación aquí, parece que la función está siendo llamada.

Pero mira la definición de degradar:

def demote(user, uid, gid):
     def función_demostración():
        print("empezando")
         print ('uid, gid = %d, %d' % (os.getuid(), os.getgid())
         print (os.getgroups())
         ¡# initgroups debe ser ejecutado antes de perder el privilegio de establecerlo!
         os.initgroups(user, gid)
         os.setgid(gid)
         # esto debe ejecutarse en último lugar
         os.setuid(uid)
         print("finalizado el descenso de categoría")
         print('uid, gid = %d, %d' % (os.getuid(), os.getgid())
         print (os.getgroups())
     return función_demostración

Esta función envuelve otra función dentro de sí misma, la cual devuelve. Usando la sintaxis que se especifica arriba, realmente devolver una función pero también pueden pasarle parámetros.

Es como tener tu pastel y comerlo. Bonito Sonrisa

Aquí hay una captura de pantalla, en caso de que WordPress se meta con la sintaxis anterior:

imagen

Bien, ahora explicaré el código de esta función de degradación:

os.initgroups:

establecemos los grupos a los que el proceso debe pertenecer (sí, tenemos que hacer esto - de lo contrario el proceso sólo será miembro del grupo pi por defecto) - este comando espera que el usuario nombre

os.setgid:

aquí configuramos el id del grupo para el nuevo proceso.

También existe una función os.setegid() que se describe como "Establecer el id de grupo efectivo del proceso actual". 

¿Cuál es la diferencia?

setegid establece el gid de forma temporal, para que el proceso pueda volver al gid original más tarde. Esto no es lo que queremos, ya que el proceso que estamos llamando podría volver a actualizar sus derechos.

os.setuid(uid):

Del mismo modo, existe una os.seteuid(euid) que permitiría al proceso ejecutado "volver a actualizar". Así que no vamos a usar eso.

El orden de estos comandos es importante. Si estableciéramos primero el ID de usuario, no tendríamos suficientes privilegios para establecer los grupos, por ejemplo. Por lo tanto, hay que establecer el ID de usuario en último lugar.

Nota al margen: preexec_fn podría usarse para configurar variables de entorno y otras cosas, también, para permitir más flexibilidad. Posiblemente en una futura actualización de picockpit-client, ya veremos.

Nota al margen 2: el preexec_fn escribe en el nuevo flujo de salida del proceso (recuerde que se preejecuta en su contexto):

Por eso, la salida de depuración se envía al frontend de Picockpit:

imagen

Ref

Otros enlaces de interés:

Bonificación: Cromo

chromium-browser -display=:0 https://www.picockpit.com

imagen

le permitirá ejecutar el navegador Chromium desde la línea de comandos (en el shell). -display=:0 establece la pantalla X que se utilizará para la salida de Chromium, por lo que no es necesario configurar la variable de entorno apropiada.

Peter.sh ofrece un montón de interruptores de línea de comandos, que puede utilizar para configurar Chromium a su gusto (por ejemplo, el modo de quiosco, etc).

Ten en cuenta que NECESITAS ejecutar un servidor X, si quieres mostrar Chromium. En la Raspberry Pi, la forma más fácil de hacerlo es ejecutar el Raspberry Pi Desktop.

¿Busca asesoramiento y desarrollo profesional para la plataforma Raspberry Pi?

Ofrecemos servicios de consultoría y desarrollo en caso de que esté buscando a alguien que le configure una aplicación de quiosco segura basada en Chromium. Póngase en contacto hoy mismo.