1# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4#
5#    Based on tests from http://bazaar.launchpad.net/~ubuntu-bugcontrol/qa-regression-testing/master/view/head:/scripts/test-kernel-security.py
6#    Copyright (C) 2008-2011 Canonical Ltd.
7#
8#    This program is free software: you can redistribute it and/or modify
9#    it under the terms of the GNU General Public License version 3,
10#    as published by the Free Software Foundation.
11#
12#    This program is distributed in the hope that it will be useful,
13#    but WITHOUT ANY WARRANTY; without even the implied warranty of
14#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15#    GNU General Public License for more details.
16#
17#    You should have received a copy of the GNU General Public License
18#    along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20import pwd
21import tempfile
22import shutil
23import logging, os
24from autotest_lib.client.bin import test, utils
25from autotest_lib.client.common_lib import error
26
27class security_HardlinkRestrictions(test.test):
28    version = 1
29
30    def _passed(self, msg):
31        logging.info('ok: %s' % (msg))
32
33    def _failed(self, msg):
34        logging.error('FAIL: %s' % (msg))
35        self._failures.append(msg)
36
37    def _fatal(self, msg):
38        logging.error('FATAL: %s' % (msg))
39        raise error.TestError(msg)
40
41    def check(self, boolean, msg, fatal=False):
42        if boolean == True:
43            self._passed(msg)
44        else:
45            msg = "could not satisfy '%s'" % (msg)
46            if fatal:
47                self._fatal(msg)
48            else:
49                self._failed(msg)
50
51    def _is_readable(self, path, user, expected=True):
52        rc = utils.system("su -c 'cat %s' %s" % (path, user),
53                          ignore_status=True)
54        status = (rc == 0)
55
56        if status != expected:
57            if expected:
58                self._failed("'%s' was unable to read file '%s'" %
59                             (user, path))
60            else:
61                self._failed("'%s' was able to read file '%s'" %
62                             (user, path))
63        return status
64
65    def _is_writable(self, path, user, expected=True):
66        rc = utils.system("su -c 'echo > %s' %s" % (path, user),
67                          ignore_status=True)
68        status = (rc == 0)
69
70        if status != expected:
71            if expected:
72                self._failed("'%s' was unable to write file '%s'" %
73                             (user, path))
74            else:
75                self._failed("'%s' was able to write file '%s'" %
76                             (user, path))
77        return status
78
79    def _can_hardlink(self, source, target, user, expected=True):
80        rc = utils.system("su -c 'ln %s %s' %s" % (source, target, user),
81                          ignore_status=True)
82        status = (rc == 0)
83
84        if status != expected:
85            if expected:
86                self._failed("'%s' was unable to hardlink file '%s' as '%s'" %
87                             (user, source, target))
88            else:
89                self._failed("'%s' was able to hardlink file '%s' as '%s'" %
90                             (user, source, target))
91
92        # Check and clean up hardlink if it was created.
93        if os.path.exists(target):
94            if not expected:
95                self._failed("'%s' was able to create hardlink '%s' to '%s'" %
96                             (user, target, source))
97            os.unlink(target)
98
99        return status
100
101    def _check_hardlinks(self, user):
102        uid = pwd.getpwnam(user)[2]
103
104        # Verify we have a distinct user.
105        if uid == 0:
106            self._failed("The '%s' user is root(%d)!" % (user, uid))
107            return
108
109        # Build a world-writable directory, owned by user.
110        tmpdir = tempfile.mkdtemp(prefix='hardlinks-')
111        self._rmdir.append(tmpdir)
112        os.chown(tmpdir, uid, 0)
113
114        # Create test target files.
115        secret = tempfile.NamedTemporaryFile(prefix="secret-")
116        readable = tempfile.NamedTemporaryFile(prefix="readable-")
117        os.chmod(readable.name, 0444)
118        available = tempfile.NamedTemporaryFile(prefix="available-")
119        os.chmod(available.name, 0666)
120
121        # Verify secret target is unreadable/unwritable.
122        self._is_readable(secret.name, user, expected=False)
123        self._is_writable(secret.name, user, expected=False)
124        # Verify readable target is only readable.
125        self._is_readable(readable.name, user)
126        self._is_writable(readable.name, user, expected=False)
127        # Verify available target is both readable/writable.
128        self._is_readable(available.name, user)
129        self._is_writable(available.name, user)
130
131        # Create pathnames for hardlinks.
132        mine = os.path.join(tmpdir, 'mine')
133        evil = os.path.join(tmpdir, 'evil')
134        not_evil = os.path.join(tmpdir, 'not-evil')
135
136        # Allow hardlink to files owned by the user, or writable.
137        self._is_writable(mine, user)
138        self._can_hardlink(mine, not_evil, user)
139        self._can_hardlink(available.name, not_evil, user)
140
141        # Disallow hardlinking to unwritable or unreadlabe files.
142        self._can_hardlink(readable.name, evil, user, expected=False)
143        self._can_hardlink(secret.name, evil, user, expected=False)
144
145        # Disallow hardlinks to unowned non-regular files. This uses
146        # /dev because the other locations are mounted nodev, which
147        # will cause the read/write tests to fail.
148        devdir = tempfile.mkdtemp(prefix="hardlinks-", dir="/dev")
149        self._rmdir.append(devdir)
150        os.chown(devdir, uid, 0)
151        null = os.path.join(devdir, "null")
152        dev_evil = os.path.join(devdir, "evil")
153        dev_not_evil = os.path.join(devdir, "not-evil")
154        utils.system("mknod -m 0666 %s c 1 3" % (null))
155        self._is_readable(null, user)
156        self._is_writable(null, user)
157        self._can_hardlink(null, dev_evil, user, expected=False)
158
159        # Allow hardlinks to owned non-regular files.
160        os.chown(null, uid, 0)
161        self._can_hardlink(null, dev_not_evil, user)
162
163        # Allow CAP_FOWNER to hardlink non-regular files.
164        self._can_hardlink(null, dev_not_evil, "root")
165
166    def run_once(self):
167        # Empty failure list means test passes.
168        self._failures = []
169
170        # Prepare list of directories to clean up.
171        self._rmdir = []
172
173        # Verify hardlink restrictions sysctl exists and is enabled.
174        sysctl = "/proc/sys/fs/protected_hardlinks"
175        if (not os.path.exists(sysctl)):
176            # Fall back to looking for Yama link restriction sysctl.
177            sysctl = "/proc/sys/kernel/yama/protected_nonaccess_hardlinks"
178        self.check(os.path.exists(sysctl), "%s exists" % (sysctl), fatal=True)
179        self.check(open(sysctl).read() == '1\n', "%s enabled" % (sysctl),
180                   fatal=True)
181
182        # Test the basic "user hardlinks to unwritable source" situation
183        # first, in a more auditable way than the extensive behavior tests
184        # that follow.
185        if os.path.exists("/tmp/evil-hardlink"):
186            os.unlink("/tmp/evil-hardlink")
187        rc = utils.system("su -c 'ln /etc/shadow /tmp/evil-hardlink' chronos",
188                          ignore_status=True)
189        if rc != 1 or os.path.exists("/tmp/evil-hardlink"):
190            self._failed("chronos user was able to create malicious hardlink")
191
192        # Test hardlink restrictions.
193        self._check_hardlinks(user='chronos')
194
195        # Clean up from the tests.
196        for path in self._rmdir:
197            if os.path.exists(path):
198                shutil.rmtree(path, ignore_errors=True)
199
200        # Raise a failure if anything unexpected was seen.
201        if len(self._failures):
202            raise error.TestFail((", ".join(self._failures)))
203