[OLPC Security] Some code

Michael Stone michael at laptop.org
Mon Nov 12 22:44:55 EST 2007


Marcus,

Thanks for the code drop. I've merged your code into the 'permissions'
branch in the 

  users/mstone/security 

repo. I'll try it out as soon as I have the opportunity. 

Michael


On Sat, Nov 10, 2007 at 02:17:01PM -0500, Marcus Leech wrote:
> Michael:
> 
> I've attached code that I've been working on for 'use-camera' and
> 'use-microphone', as well as resource limiting.
>   I also attached a permissions.info file that I've been using with the
> Web activity.
> 
> I'm off to Atlanta for one week starting tomorrow.  I'll likely still
> hop on #olpc while I'm there.
> 
> I still don't have any kind of space on dev.laptop.org -- perhaps you
> should ply your roommate with more drink :-)
> 
> I talked to my management yesterday, and my half-time engagement with
> OLPC has to end by the end of January, or
>   perhaps February.  I'm disappointed, but I hope to visit you guys
> sometime around Dec 15 or so.

> #!/usr/bin/env python
> 
> import os, sys
> from os.path import exists, join, dirname, isdir, isabs
> import pwd
> import shutil
> import subprocess
> import grp
> import resource
> 
> from rainbow import util, permissions
> 
> USAGE = """
> 
> NAME:
> 
>   rainbow-inject
> 
> Purpose:
> 
>   rainbow-inject is a security-wrapper for Sugar Activities. Its purpose is to
>   launch them in a low-privilege environment so that, if they are exploited,
>   they are able to do less harm than would be the case if they ran with the
>   full authority of the XO's human operator.
> 
>   This isolation is accomplished by running each activity as a separate user
>   from a set of users that is known, at system installation time, to have
>   tightly restricted access to the file-system.
> 
>   The environment that rainbow-inject creates for each activity it starts is
>   controlled by an activity-specific security-record created by the
>   rainbow-install-bundle.
> 
> Environment Variables:
> 
>   SUGAR_BUNDLE_PATH  : MUST contain an absolute path to a bundle to launch.
> 
>   SUGAR_BUNDLE_PATH  : MUST contain the unique bundle-id of the bundle at
>                        SUGAR_BUNDLE_PATH.
> 
>   RAINBOW_DIR        : Optional; defaults to /var/rainbow
>                        This directory is used by rainbow-inject for all its
>                        run-time data: currently, this means uid/gid allocation
>                        data and for storing persistent activity-data storage
> 
>   RAINBOW_STRACE_LOG : Optional; defaults to Nothing.
>                        If set, rainbow-inject will cause strace to write a
>                        complete log to the specified path.
> 
> Arguments:
> 
>   Any arguments will be exec'ed verbatim after rainbow-inject properly
>   configures its environment.
> 
> File Descriptors:
> 
>   Open file-descriptors will be passed unmodified to the activity being
>   launched. However, for the foreseeable future, rainbow-inject _will_ print
>   trace information to descriptor 1 and may print error messages to descriptor
>   2 for debugging purposes.
> 
> Return Codes:
> 
>     1: An unhandled exception arose.
> 
> """
> 
> def log_file(filelike):
>     def log(msg, *args):
>         if len(args):
>             filelike.write(msg % args + '\n')
>         else:
>             filelike.write(msg + '\n')
>     return log
> 
> def strace(log, log_path, argv, env):
>     # cjb thinks we should really be using SystemTap here and refers us to
>     # wiki:SystemTap and #83 <MS>
>     log('applying strace')
>     args = ['/usr/bin/strace', '-f', '-o', log_path]
>     for k, v in env.iteritems():
>         args.append('-E')
>         args.append('%s=%s' % (k, v))
>     args.extend(argv)
> 
>     return args, {}
> 
> def grab_home(log, spool, bundle_id):
>     uid = util.grab_uid(join(spool, 'uidpool'))
>     gid = util.grab_gid(join(spool, 'gidpool'), join(spool, 'gid'), bundle_id)
> 
>     assert not exists('/home/%d' % uid)
>     gid_cmd = ['/usr/sbin/groupadd', '-o', '-g', str(gid), str(gid)]
>     log('adding group: %s', ' '.join(gid_cmd))
>     try:
>         subprocess.check_call(gid_cmd)
>     except subprocess.CalledProcessError, e:
>         if e.returncode is not 9: # we don't care if the group already exists
>             raise e
> 
>     uid_cmd = ['/usr/sbin/useradd', '-m', '-u', str(uid), '-g', str(gid),
>                '-c', '%s.%d' % (bundle_id, uid), str(uid)]
>     log('adding user: %s', ' '.join(uid_cmd))
>     subprocess.check_call(uid_cmd)
> 
>     home = '/home/%d' % uid
>     os.chown(home, 0, gid)
>     os.chmod(home, 0750)
> 
>     return (uid, gid, home)
> 
> def configure_scratch_and_home(log, spool, bundle_id, uid, gid, home):
>     scratch_root = join(spool, 'scratch', bundle_id)
>     util.make_dirs(scratch_root, 0, 0, 0755)
> 
>     pw = pwd.getpwnam('olpc')
>     olpc_uid = pw.pw_uid
> 
>     log('making data, conf, and tmp dirs in %s', scratch_root)
>     for frag in ('data', 'conf'):
>         path = join(scratch_root, frag)
>         util.make_dirs(path, olpc_uid, gid, 06770)
>         os.chown(path, olpc_uid, gid)
>         os.chmod(path, 06770)
>         os.symlink(path, join(home, frag))
> 
>     # TODO: Handle old activity versions' config data
> 
>     util.make_dirs(join(home, 'tmp'), uid, gid, 0700)
>     util.mount('tmpfs', join(home, 'tmp'), 'tmpfs', 0, "size=1M,nr_inodes=1024")
>     # /home/<uid> is 700 <uid>.<gid> so tmp is already somewhat protected
> 
>     xauth = join(home, '.Xauthority')
>     shutil.copy('/home/olpc/.Xauthority', xauth)
>     os.chown(xauth, uid, gid)
> 
>     for frag in ['session.info', 'config']:
>         path = join('.sugar/default/', frag)
>         util.make_dirs(dirname(join(home, path)), uid, gid, 0700)
>         shutil.copy(join('/home/olpc', path),
>                     join(home, path))
>         os.chown(join(home, path), uid, gid)
> 
> def launch(log, home, uid, gid, argv, env, pset):
>     env['USER'] = str(uid)
>     env['HOME'] = home
>     env['XAUTHORITY'] = join(home, '.Xauthority')
>     env['SUGAR_ACTIVITY_ROOT'] = home
>     env['TMPDIR'] = join(home, 'tmp')
>     
>     # Check for use-camera, and use-micropphone
>     # Add appropriate groups from /etc/group
>     groups = []
>     gset = False
>     if pset != None:
> 
>         if pset.has_permission('use-camera'):
>            groups.append(grp.getgrnam('camera')[2])
>            log ('Turning on camera access')
>            gset = True
> 
>         if pset.has_permission('use-microphone'):
>            log ('Turning on microphone access')
>            groups.append(grp.getgrnam('microp')[2])
>            gset = True
> 
>     #
>     # Set appropriate group membership(s), depending on requested permissions
>     # List of memberships in 'groups'
>     #
>     if gset == True:
>         os.setgroups(groups)
> 
>     log('dropping privilege to (%d, %d)', uid, gid)
>     os.setgid(gid)
>     os.setuid(uid)
> 
>     # Limit various resources
>     # Must be done *after* setting uid/gid
>     # This should come from the permissions.info file, but this is OK
>     #   for now.
> 
>     try:
>         p = pset.permission_params('lim_nofile')
>         if p != None:
>             x = int(float(p[0]))
>             y = int(float(x*1.10))
>             log('Setting RLIMIT_NOFILE to %d,%d', x, y)
>             resource.setrlimit(resource.RLIMIT_NOFILE, (x,y))
> 
>         p = pset.permission_params('lim_fsize')
>         if p != None:
>             x = int(float(p[0]))
>             y = int(float(x*1.10))
>             log('Setting RLIMIT_FSIZE to %d,%d', x, y)
>             resource.setrlimit(resource.RLIMIT_FSIZE, (x,y))
>         
>         
>         p = pset.permission_params('lim_mem')
>         if p != None:
>             x = int(float(p[0]))
>             y = int(float(x*1.10))
>             log('Setting RLIMIT_AS to %d,%d', x, y)
>             resource.setrlimit (resource.RLIMIT_AS, (int(200e6),int(210e6)))
> 
>         p = pset.permission_params('lim_nproc')
>         if p != None:
>             x = int(float(p[0]))
>             y = int(float(x*1.10))
>             log('Setting RLIMIT_NPROC to %d,%d', x, y)
>             resource.setrlimit (resource.RLIMIT_NPROC, (x,y))
> 
>     except:
>         pass
> 
>     log('chdir to %s' % env['SUGAR_BUNDLE_PATH'])
>     os.chdir(env['SUGAR_BUNDLE_PATH'])
> 
>     # Since we've dropped privilege, there's nothing to verify about strace_log
>     strace_log = env.get('RAINBOW_STRACE_LOG')
>     if strace_log:
>         argv, env = strace(strace_log, argv, env)
> 
>     log('about to execve\nargv: %s\nenv: %s', argv, env)
>     for root, dirs, files in os.walk("/proc/self/fd"):
>         for name in files:
>            fd = int(name)
>            if (fd >= 3):
>                try:
>                    os.close(fd)
>                except:
>                    pass
>     os.execvpe(argv[0], argv, env)
> 
> def verify_bundle_path(path):
>     assert path and os.access(path, os.F_OK | os.R_OK) and isabs(path)
>     return path
> 
> def verify_bundle_id(bundle_id):
>     assert bundle_id and all(s.isalnum() for s in bundle_id.split('.'))
>     return bundle_id
> 
> def verify_argv(argv):
>     assert argv and len(argv) > 1
>     return argv
> 
> def rwx_dir(path):
>     return os.access(path, os.F_OK | os.R_OK | os.W_OK | os.X_OK) \
>        and isdir(path)
> 
> def verify_spool(spool):
>     assert spool
>     for frag in ('uidpool', 'gidpool', 'gid', 'scratch'):
>         assert rwx_dir(join(spool, frag))
> 
>     return spool
> 
> def verify_home(home, uid, gid):
>     saved_uid = os.getuid()
>     saved_gid = os.getgid()
>     os.setregid(-1, gid)
>     os.setreuid(-1, uid)
>     for frag in ('conf', 'data', 'tmp'):
>         assert rwx_dir(join(home, frag))
>     os.setreuid(-1, saved_uid)
>     os.setregid(-1, saved_gid)
> 
> def run(log, env, argv, bundle_path, bundle_id, spool_dir, pset):
>     # Note: exceptions are intended to bubble up to the caller and should
>     # terminate execution.
>     bundle_path = verify_bundle_path(bundle_path)
>     bundle_id   = verify_bundle_id(bundle_id)
>     argv        = verify_argv(argv)
>     spool_dir   = verify_spool(spool_dir)
> 
>     (uid, gid, home) = grab_home(log, spool_dir, bundle_id)
>     configure_scratch_and_home(log, spool_dir, bundle_id, uid, gid, home)
> 
>     verify_home(home, uid, gid)
> 
> 
>     launch(log, home, uid, gid, argv, env, pset)
> 
> def main():
>     run(log_file(sys.stdout),
>         os.environ,
>         sys.argv,
>         os.environ.get('SUGAR_BUNDLE_PATH'),
>         os.environ.get('SUGAR_BUNDLE_ID'),
>         (os.environ.get('RAINBOW_DIR') or '/var/rainbow'), None)
> 
> if __name__ == '__main__':
>     main()

> import os
> import sys
> import traceback
> from signal import SIGCHLD
> from optparse import OptionParser
> 
> import gobject
> 
> import dbus
> import dbus.service
> import dbus.mainloop.glib
> 
> from rainbow import util, inject, permissions
> from rainbow.util.linux.clone import clone, CLONE_NEWNS
> 
> def log(msg, *args):
>     if len(args):
>         print msg % args
>     else:
>         print msg
> 
> class Rainbow(dbus.service.Object):
>     """The Rainbow security service."""
>     SERVICE_NAME = 'org.laptop.security.Rainbow'
>     INTERFACE_NAME = 'org.laptop.security.Rainbow'
> 
>     _foreground_xid = None
>     _id_map = {}
> 
>     def __init__(self, bus_or_name):
>         dbus.service.Object.__init__(self, bus_or_name, '/')
> 
>     @dbus.service.method(INTERFACE_NAME,
>                          in_signature='s',
>                          out_signature='s')
>     def echo(self, msg):
>         return msg
> 
>     @dbus.service.method('org.laptop.security.DBus',
>                          in_signature='si',
>                          out_signature='b')
>     def CheckCanOwn(self, service_name, xid):
>         """Check the the given xid is allowed to bind to the bus with the given
>         name.
>         """
> 
>         # XXX: This should be checking that the thing on the other side is really
>         # the session bus. <NPK>
> 
>         return service_name.startswith('org.laptop.Container%s'%xid)
> 
>     @dbus.service.method(INTERFACE_NAME,
>                          in_signature='s')
>     def ChangeActivity(self, activity_id):
>         """Sets CPU throttling on the foreground and background activities as needed."""
>         pass
> 
>     @dbus.service.method(INTERFACE_NAME,
>                          in_signature='sa{ss}asss',
>                          async_callbacks=('success_cont', 'error_cont'))
>     def CreateActivity(self, log_path, env, argv, bundle_path, bundle_id,
>                        success_cont, error_cont):
> 
>         # Reap zombies
>         try:
>             while os.waitpid(-1,os.WNOHANG) != (0,0):
>                 pass
>                 
>         except OSError:
>             pass
> 
>         #
>         # Try to pick up the capabilities/permissions from the
>         #    bundle, if there isn't a permissions.info file, no
>         #    big deal, the app just doesn't get extra permissions
>         #
>         pset = None
>         permpath = os.path.join(bundle_path, "permissions.info")
> 
>         perm_worked = True
>         try:
>             f = open(permpath)
>             pset = permissions.PermissionSet(f)
>             f.close()
>         except:
>             perm_worked = False
>             pset = None
>             pass
> 
>         try:
>             child_pid = clone(CLONE_NEWNS | SIGCHLD)
>             if not child_pid:
>                 # XXX: I _do not_ yet understand which fds I need to close when.
>                 # For example - should the D-Bus FD needs to be open to send
>                 # error messages? If so, when does it get closed?
>                 #
>                 # patchvonbraun: fixed in inject.py, by querying /proc/self/fd
>                 #  to determine open FDs, and closing any that are >= 3
>                 #
>                 # This means that they're closed at the very last possible
>                 #   moment before calling execvpe() in inject.run
>                 try:
>                     log_fd = os.open(log_path, os.O_WRONLY)
>                     os.dup2(log_fd, 1)
>                     os.dup2(log_fd, 2)
>                     ret = inject.run(log, env, argv, bundle_path, bundle_id,
>                                      '/var/rainbow', pset)
>                 except Exception, e:
>                     util.trace()
>                     error_cont(e)
>                 finally:
>                     os._exit(255)
> 
> 
>         except Exception, e:
>             util.trace()
>             error_cont(e)
> 
> def run():
>     """Start the Rainbow DBus service."""
>     parser = OptionParser(version='0.1')
>     parser.add_option('-f', '--config-file', default='/etc/rainbow/rainbow.conf',
>                       help='The configuration file for Rainbow.')
>     parser.add_option('-d', '--daemon', action='store_true', default=False,
>                       help='Really daemonize; e.g. detach from the controlling terminal.')
>     opts, args = parser.parse_args()
> 
>     # General FS initialization.
>     util.make_dirs('/var/log/rainbow', 0, 0, 0777)
> 
>     if opts.daemon:
>         util.daemon(0, 1)
>         open('/var/run/rainbow.pid', 'w').write(str(os.getpid()))
>         sys.stdout = open('/var/log/rainbow/stdout', 'w', 0)
>         #sys.stderr = open('/var/log/rainbow/stderr', 'w', 0)
>         sys.stderr = sys.stdout
> 
>     dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
> 
>     bus = dbus.SystemBus()
>     name = dbus.service.BusName(Rainbow.SERVICE_NAME, bus)
>     Rainbow(name)
> 
>     print 'Service running mainloop.'
>     mainloop = gobject.MainLoop()
>     mainloop.run()
> 
> def main():
>     run()
> 
> if __name__ == '__main__':
>     main()

> use-camera
> use-microphone
> lim_nofile 20
> lim_mem 190e6
> lim_nproc 8
> lim_fsize 10e6

> DOMAINS = {'network': ('via', 'to', 'port', 'rate', 'burst', 'connection-rate',
>                        'transfer-limit', 'bind-port'),
>           'use-microphone': (),
>           'use-camera': (),
>           'play-background-sound': (),
>           'quota': ('limit'),
>           'lim_nofile': ('@NUM@'),
>           'lim_mem': ('@NUM@'),
>           'lim_nproc': ('@NUM@'),
>           'lim_fsize': ('@NUM@'),
>           'document_read_ro': ('type')
>           }
> 
> class PermissionSet(object):
>     def __init__(self, fp=None):
>         self._permissions = {}
>         self._network_permissions = []
> 
>         for line in fp:
>             line = line.lower().strip()
>             if not line or line.startswith('#'):
>                 continue
> 
>             fields = line.split()
>             if not fields[0] in DOMAINS:
>                 print "Unknown permissions domain: [%s]" % fields[0]
>                 continue
> 
>             for field in fields[1:]:
>                 if '@NUM@' in DOMAINS[fields[0]]:
>                     try:
>                         float(fields[1])
>                     except:
>                         print "Expecting numeric value in domain [%s] (%s)" % (fields[0], fields[1])
>                         continue
>                 else:
>                     key, value = field.split(':')
>                     if not key in DOMAINS[fields[0]]:
>                         print "Unknown flag [%s] in domain [%s]" % (key, fields[0])
>                         continue
> 
>             if fields[0] == 'network':
>                 self._network_permissions.append(fields[1:])
>             else:
>                 self._permissions[fields[0]] = fields[1:] or True
> 
>     def has_permission(self, domain, key=None, value=None):
>         "Fails for network, since it doesn't make sense to query those perms"
>         if domain not in self._permissions:
>             return False
>         elif key and value:
>             for permission in self._permissions[domain]:
>                 this_key, these_values = permission.split(':')
>                 if not key == this_key:
>                     continue
>                 these_values = these_values.split(',')
>                 if value in these_values:
>                     return True
>         elif self._permissions[domain] == True:
>             return True
>         else:
>             return False
> 
>     def permission_params(self, domain):
>         if domain not in self._permissions:
>             return None
>         return(self._permissions[domain])
> 
> 
> if __name__ == '__main__':
>     import cStringIO as stringio
> 
>     perms = """
>     network via:ipv4,ipv6 to:pgp.mit.edu,laptop.org port:80 rate:100Kb/s burst:1Mb
>     network via:ipv4 port:25 connection-rate:10/min transfer-limit:8Mb/hr
>     network via:ipv6 bind-port:1400
> 
>     # Comment
>     use-microphone
>     use-camera
> 
>     # Comment, yay
>     play-background-sound
> 
>     document_read_ro type:text/plain
> 
>     quota limit:15Mb
>     """
> 
>     permfp = stringio.StringIO(perms)
>     permset = PermissionSet(permfp)
>     print permset._permissions
>     print permset._network_permissions





More information about the Security mailing list