~paravoid/Utils

7ee8bb94f0d64955877c4e57083a44173c08fb35 — paravoid 1 year, 4 months ago 493b8d5 master
pam_biospw: initial commit
1 files changed, 105 insertions(+), 0 deletions(-)

A pam_biospw/pam_biospw.py
A pam_biospw/pam_biospw.py => pam_biospw/pam_biospw.py +105 -0
@@ 0,0 1,105 @@
# Chad Thundercode just won't stop polluting the global namespace!
from os import fstat, geteuid, getegid
from stat import *
from pwd import getpwuid
from grp import getgrgid
from syslog import *
from hashlib import sha256
import struct

# In glibc, the ident string ("pam_biospw") pointer will be retained internally by the syslog routines, even after we return and get unloaded
# So if a poorly-written PAM-aware program uses syslog() without openlog()ing after pam_authenticate(), a use-after-free will occur
# At best; this will lead to confusion and misdirected emails, since our ident string could appear instead of the program's in the logs
# https://web.archive.org/web/20220223143922/https://www.gnu.org/software/libc/manual/html_node/openlog.html
# https://elixir.bootlin.com/glibc/glibc-2.35/source/misc/syslog.c#L316
# musl, on the other hand, clones the string internally, so at least a UAF won't happen
# https://git.musl-libc.org/cgit/musl/tree/src/misc/syslog.c?h=v1.2.3#n65
# To avoid all that, we use a decorator (overkill, but looks nice) to ensure that we always closelog() after we return from the auth function
def cleanup(pam_sm_func):
    def pam_sm_func_wrap(pamh, flags, argv):
        status = pam_sm_func(pamh, flags, argv)
        closelog()
        return status

    return pam_sm_func_wrap


@cleanup
def pam_sm_authenticate(pamh, flags, argv):
    # It's worth noting that pam_unix does logging a bit differently
    # For example, it uses LOG_AUTHPRIV and LOG_NOTICE (for auth fails)
    # One might argue that we're not following the established logging conventions, and that's ok :)
    openlog("pam_biospw", LOG_CONS | LOG_PID, LOG_AUTH)

    # Reading an efivar is generally slow, so you might want to cache it somewhere (e.g. using an init script) and pass the cached file as an arg
    # Also, Linux does have an efivars read rate-limit for non-root users (100 per second, which is pretty generous)
    # https://lore.kernel.org/lkml/CAKv+Gu86gohjooxurSr1KTfbUKPP-wf+XHdN88OrF=Pta9Ug3A@mail.gmail.com/t/
    # https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=bef3efbeb897b56867e271cdbc5f8adaacaeb9cd
    var_path = "/sys/firmware/efi/efivars/LenovoRuntimePassword-0997fb62-c849-4d91-9266-05333bc55ce7"
    if len(argv) >= 2:
        var_path = argv[1]

    try:
        var_file = open(var_path, "rb")
    except IOError as e:
        syslog(LOG_ERR, "Could not open {}: {}".format(var_path, e.strerror))
        return pamh.PAM_SYSTEM_ERR

    var_file_stat = fstat(var_file.fileno())

    if var_file_stat.st_mode & S_IWOTH:
        syslog(LOG_ERR, "{} is world-writable".format(var_path))
        return pamh.PAM_SYSTEM_ERR

    # This is the default on most Linux distros
    if var_file_stat.st_mode & S_IROTH:
        syslog(LOG_WARNING, "{} is world-readable".format(var_path))

    # Ideally, the efivar would be only readable by us to prevent unwanted snooping
    our_euid = geteuid()
    our_egid = getegid()
    if var_file_stat.st_uid != our_euid or var_file_stat.st_gid != our_egid:
        syslog(LOG_WARNING, "{} isn't owned by {}:{}".format(var_path, getpwuid(our_euid).pw_name, getgrgid(our_egid).gr_name))

    EXPECTED_VAR_SIZE = 0x72
    var_size = var_file_stat.st_size
    if var_size != EXPECTED_VAR_SIZE:
        syslog(LOG_ERR, "Incorrect variable size: Expected {} bytes instead of {}".format(EXPECTED_VAR_SIZE, var_size))
        return pamh.PAM_SYSTEM_ERR

    # Python 2's bytes type is just an alias for str, so we use a bytearray instead
    var_bytes = bytearray(var_file.read())
    var_file.close()

    # The first 4 bytes are ignored as they're the attributes of the variable in Linux
    # https://www.kernel.org/doc/html/v5.17/filesystems/efivarfs.html
    calculated_checksum = sum(var_bytes[0x4:0x6E])

    # Python 2 doesn't have int.from_bytes(), sigh...
    # < = little endian, I = unsigned 32-bit int
    stored_checksum = struct.unpack("<I", var_bytes[0x6E:])[0]
    if calculated_checksum != stored_checksum:
        syslog(LOG_ERR, "Checksum mismatch: Expected {} instead of {}".format(calculated_checksum, stored_checksum))
        return pamh.PAM_SYSTEM_ERR

    # We could simply check 0x2A directly, but there's no harm in going the extra km
    # H = unsigned 16-bit short (integer)
    supervisor_pw_flag = struct.unpack("<H", var_bytes[0x2A:0x2C])[0]
    stored_pw_hash = var_bytes[0x2C:0x3C]
    if supervisor_pw_flag != 1 or stored_pw_hash == bytearray(0x10):
        syslog(LOG_ERR, "No password set")
        return pamh.PAM_SYSTEM_ERR

    pam_message = pamh.Message(pamh.PAM_PROMPT_ECHO_OFF, "Supervisor BIOS Password: ")
    input_pw = pamh.conversation(pam_message).resp.encode("UTF-16LE")
    input_pw_hash = sha256(input_pw).digest()[:0x10]

    if input_pw_hash != stored_pw_hash:
        syslog(LOG_ERR, "Incorrect password")
        return pamh.PAM_AUTH_ERR

    return pamh.PAM_SUCCESS


def pam_sm_setcred(pamh, flags, args):
    return pamh.PAM_SUCCESS