From 9e03e24764d15b039d0e28f8386b75aab0277838 Mon Sep 17 00:00:00 2001 From: Peter Cai Date: Fri, 10 Feb 2017 20:09:22 +0800 Subject: [PATCH] terminal: support PAM authentication Fix #129 Actually, we are reinventing the wheel... But after all, it is not possible to change the profile name of `su`, so we just pull in the PAM bindings for Python and use it for PAM authentication. A new option `--pam_profile` has been added for users to specify their preferred PAM profile. Note that Butterfly should be started as ROOT or it will not be possible to authenticate via PAM. --- README.md | 14 ++++ butterfly.server.py | 2 + butterfly/pam.py | 171 ++++++++++++++++++++++++++++++++++++++++++ butterfly/terminal.py | 27 ++++--- 4 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 butterfly/pam.py diff --git a/README.md b/README.md index bd62763..208a715 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ To get an overview of butterfly features. $ butterfly.server.py --host=myhost --port=57575 ``` +Or with login prompt + +```bash +$ butterfly.server.py --host=myhost --port=57575 --login +``` + +Or with PAM authentication (ROOT required) + +```bash +# butterfly.server.py --host=myhost --port=57575 --login --pam_profile=sshd +``` + +You can change `sshd` to your preferred PAM profile. + The first time it will ask you to generate the certificates (see: [here](http://paradoxxxzero.github.io/2014/03/21/butterfly-with-ssl-auth.html)) diff --git a/butterfly.server.py b/butterfly.server.py index 6ba58c3..17e8385 100755 --- a/butterfly.server.py +++ b/butterfly.server.py @@ -52,6 +52,8 @@ tornado.options.define("unsecure", default=False, help="Don't use ssl not recommended") tornado.options.define("login", default=False, help="Use login screen at start") +tornado.options.define("pam_profile", default="", type=str, + help="When --login=True provided and running as ROOT, use PAM with the specified PAM profile for authentication and then execute the user's default shell. Will override --shell.") tornado.options.define("force_unicode_width", default=False, help="Force all unicode characters to the same width." diff --git a/butterfly/pam.py b/butterfly/pam.py new file mode 100644 index 0000000..44674fd --- /dev/null +++ b/butterfly/pam.py @@ -0,0 +1,171 @@ +# (c) 2007 Chris AtLee +# Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php +# +# Original author: Chris AtLee +# +# Modified by David Ford, 2011-12-6 +# added py3 support and encoding +# added pam_end +# added pam_setcred to reset credentials after seeing Leon Walker's remarks +# added byref as well +# use readline to prestuff the getuser input +# Modified by Peter Cai, 2017-02-10 +# interactive login for Butterfly + +''' +PAM module for python +Provides an authenticate function that will allow the caller to authenticate +a user against the Pluggable Authentication Modules (PAM) on the system. +Implemented using ctypes, so no compilation is necessary. +''' + +import sys +import os + +from ctypes import CDLL, POINTER, Structure, CFUNCTYPE, cast, byref, sizeof +from ctypes import c_void_p, c_size_t, c_char_p, c_char, c_int +from ctypes import memmove +from ctypes.util import find_library + +class PamHandle(Structure): + """wrapper class for pam_handle_t pointer""" + _fields_ = [ ("handle", c_void_p) ] + + def __init__(self): + Structure.__init__(self) + self.handle = 0 + +class PamMessage(Structure): + """wrapper class for pam_message structure""" + _fields_ = [ ("msg_style", c_int), ("msg", c_char_p) ] + + def __repr__(self): + return "" % (self.msg_style, self.msg) + +class PamResponse(Structure): + """wrapper class for pam_response structure""" + _fields_ = [ ("resp", c_char_p), ("resp_retcode", c_int) ] + + def __repr__(self): + return "" % (self.resp_retcode, self.resp) + +conv_func = CFUNCTYPE(c_int, c_int, POINTER(POINTER(PamMessage)), POINTER(POINTER(PamResponse)), c_void_p) + +class PamConv(Structure): + """wrapper class for pam_conv structure""" + _fields_ = [ ("conv", conv_func), ("appdata_ptr", c_void_p) ] + +# Various constants +PAM_PROMPT_ECHO_OFF = 1 +PAM_PROMPT_ECHO_ON = 2 +PAM_ERROR_MSG = 3 +PAM_TEXT_INFO = 4 +PAM_REINITIALIZE_CRED = 8 + +libc = CDLL(find_library("c")) +libpam = CDLL(find_library("pam")) +libpam_misc = CDLL(find_library("pam_misc")) + +calloc = libc.calloc +calloc.restype = c_void_p +calloc.argtypes = [c_size_t, c_size_t] + +pam_end = libpam.pam_end +pam_end.restype = c_int +pam_end.argtypes = [PamHandle, c_int] + +pam_start = libpam.pam_start +pam_start.restype = c_int +pam_start.argtypes = [c_char_p, c_char_p, POINTER(PamConv), POINTER(PamHandle)] + +pam_setcred = libpam.pam_setcred +pam_setcred.restype = c_int +pam_setcred.argtypes = [PamHandle, c_int] + +pam_strerror = libpam.pam_strerror +pam_strerror.restype = c_char_p +pam_strerror.argtypes = [PamHandle, c_int] + +pam_authenticate = libpam.pam_authenticate +pam_authenticate.restype = c_int +pam_authenticate.argtypes = [PamHandle, c_int] + +misc_conv = libpam_misc.misc_conv + +class PAM(): + code = 0 + reason = None + + def __init__(self): + pass + + def authenticate(self, username, service='login', encoding='utf-8', resetcreds=True): + """PAM authentication through standard input for the given service. + Returns True for success, or False for failure. + self.code (integer) and self.reason (string) are always stored and may + be referenced for the reason why authentication failed. 0/'Success' will + be stored for success. + Python3 expects bytes() for ctypes inputs. This function will make + necessary conversions using the supplied encoding. + Inputs: + username: username to authenticate + service: PAM service to authenticate against, defaults to 'login' + Returns: + success: True + failure: False + """ + + # python3 ctypes prefers bytes + if sys.version_info >= (3,): + if isinstance(username, str): username = username.encode(encoding) + if isinstance(service, str): service = service.encode(encoding) + else: + if isinstance(username, unicode): + username = username.encode(encoding) + if isinstance(service, unicode): + service = service.encode(encoding) + + if b'\x00' in username or b'\x00' in service: + self.code = 4 # PAM_SYSTEM_ERR in Linux-PAM + self.reason = 'strings may not contain NUL' + return False + + handle = PamHandle() + conv = PamConv(conv_func(misc_conv), 0) + retval = pam_start(service, username, byref(conv), byref(handle)) + + if retval != 0: + # This is not an authentication error, something has gone wrong starting up PAM + self.code = retval + self.reason = "pam_start() failed" + return False + + retval = pam_authenticate(handle, 0) + auth_success = retval == 0 + + if auth_success and resetcreds: + retval = pam_setcred(handle, PAM_REINITIALIZE_CRED); + + # store information to inform the caller why we failed + self.code = retval + self.reason = pam_strerror(handle, retval) + if sys.version_info >= (3,): + self.reason = self.reason.decode(encoding) + + pam_end(handle, retval) + + return auth_success + +def login_prompt(username, profile, env): + pam = PAM() + + success = pam.authenticate(username, profile) + print('{} {}'.format(pam.code, pam.reason)) + + if success: + su = '/usr/bin/su' + if not os.path.exists(su): + su = '/bin/su' + os.execvpe(su, [su, username], env) + return success diff --git a/butterfly/terminal.py b/butterfly/terminal.py index a836bce..5603fc4 100644 --- a/butterfly/terminal.py +++ b/butterfly/terminal.py @@ -31,7 +31,7 @@ import tornado.process import tornado.web import tornado.websocket from logging import getLogger -from butterfly import utils, __version__ +from butterfly import pam, utils, __version__ log = getLogger('butterfly') ioloop = tornado.ioloop.IOLoop.instance() @@ -223,7 +223,7 @@ class Terminal(object): # Unsecure connection with su if server.root: if self.socket.local: - if self.callee != self.caller: + if self.callee != self.caller and tornado.options.options.pam_profile == "": # Force password prompt by dropping rights # to the daemon user os.setuid(daemon.uid) @@ -234,17 +234,20 @@ class Terminal(object): sys.exit(1) os.setuid(daemon.uid) - if os.path.exists('/usr/bin/su'): - args = ['/usr/bin/su'] - else: - args = ['/bin/su'] + if (not server.root) or tornado.options.options.pam_profile == "": + if os.path.exists('/usr/bin/su'): + args = ['/usr/bin/su'] + else: + args = ['/bin/su'] - args.append('-l') - if sys.platform == 'linux' and tornado.options.options.shell: - args.append('-s') - args.append(tornado.options.options.shell) - args.append(self.callee.name) - os.execvpe(args[0], args, env) + args.append('-l') + if sys.platform == 'linux' and tornado.options.options.shell: + args.append('-s') + args.append(tornado.options.options.shell) + args.append(self.callee.name) + os.execvpe(args[0], args, env) + else: + pam.login_prompt(self.callee.name, tornado.options.options.pam_profile, env) def communicate(self): fcntl.fcntl(self.fd, fcntl.F_SETFL, os.O_NONBLOCK)