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:
6# http://bazaar.launchpad.net/~ubuntu-bugcontrol/qa-regression-testing/master/view/head:/scripts/test-kernel-security.py
7#    Copyright (C) 2008-2011 Canonical Ltd.
8#
9#    This program is free software: you can redistribute it and/or modify
10#    it under the terms of the GNU General Public License version 3,
11#    as published by the Free Software Foundation.
12#
13#    This program is distributed in the hope that it will be useful,
14#    but WITHOUT ANY WARRANTY; without even the implied warranty of
15#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16#    GNU General Public License for more details.
17#
18#    You should have received a copy of the GNU General Public License
19#    along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21import pwd
22import tempfile
23import shutil
24import logging, os
25from autotest_lib.client.bin import test, utils
26from autotest_lib.client.common_lib import error
27
28class security_SymlinkRestrictions(test.test):
29    version = 1
30
31    def _passed(self, msg):
32        logging.info('ok: %s' % (msg))
33
34    def _failed(self, msg):
35        logging.error('FAIL: %s' % (msg))
36        self._failures.append(msg)
37
38    def _fatal(self, msg):
39        logging.error('FATAL: %s' % (msg))
40        raise error.TestError(msg)
41
42    def check(self, boolean, msg, fatal=False):
43        if boolean == True:
44            self._passed(msg)
45        else:
46            msg = "could not satisfy '%s'" % (msg)
47            if fatal:
48                self._fatal(msg)
49            else:
50                self._failed(msg)
51
52    def _read_contents_as(self, path, content, user, fail=False):
53        cat = utils.run("su -c 'cat %s' %s" % (path, user), ignore_status=True)
54        if fail:
55            self.check(cat.exit_status != 0,
56                       "%s not readable by %s (exit status %d)" %
57                       (path, user, cat.exit_status))
58            self.check(cat.stdout != content,
59                       "%s not readable by %s (content '%s')" %
60                       (path, user, cat.stdout))
61        else:
62            self.check(cat.exit_status == 0,
63                       "%s readable by %s (exit status %d)" %
64                       (path, user, cat.exit_status))
65            self.check(cat.stdout == content,
66                       "%s readable by %s (content '%s')" %
67                       (path, user, cat.stdout))
68
69    def _write_path_as(self, path, user, fail=False):
70        rc = utils.system("su -c 'dd if=/etc/passwd of=%s' %s" % (path, user),
71                          ignore_status=True)
72        if fail:
73            self.check(rc != 0, "%s unwritable by %s (exit status %d)" %
74                                (path, user, rc))
75        else:
76            self.check(rc == 0, "%s writable by %s (exit status %d)" %
77                                (path, user, rc))
78
79    def _write_as(self, op_path, chk_path, user, create=False, fail=False):
80        if create:
81            if os.path.exists(chk_path):
82                os.unlink(chk_path)
83            self.check(os.path.exists(chk_path) == False,
84                       "%s does not exist starting _write_as()" % (chk_path))
85        else:
86            open(chk_path, 'w').write('blah blah\n')
87            self.check(os.path.exists(chk_path),
88                       "%s exists" % (chk_path))
89            os.chown(chk_path, pwd.getpwnam(user)[2], 0)
90        self._write_path_as(op_path, user, fail=fail)
91        if fail:
92            if create:
93                self.check(not os.path.exists(chk_path),
94                           "%s does not exist at end of _write_as()" %
95                           (chk_path))
96        else:
97            self.check(os.path.exists(chk_path),
98                       "%s exists at end of _write_as()" % (chk_path))
99            self.check(os.stat(chk_path).st_uid == pwd.getpwnam(user)[2],
100                       "%s owned by %s at end of _write_as()" %
101                       (chk_path, user))
102
103    def _check_symlinks(self, sticky, userone, usertwo):
104        uidone = pwd.getpwnam(userone)[2]
105        uidtwo = pwd.getpwnam(usertwo)[2]
106
107        # Verify we have distinct users.
108        if userone == usertwo:
109            self._failed("The '%s' and '%s' user have the same name!" %
110                         userone, usertwo)
111            return
112        if uidone == uidtwo:
113            self._failed("The '%s' and '%s' user have the same uid!" %
114                         userone, usertwo)
115            return
116
117        # Build a world-writable directory, owned by userone.
118        prefix = 'symlinks-'
119        if not sticky:
120            prefix += 'not'
121        prefix += 'sticky-'
122        tmpdir = tempfile.mkdtemp(prefix=prefix)
123        self._rmdir.append(tmpdir)
124        mode = 0777
125        if sticky:
126            mode |= 01000
127        os.chmod(tmpdir, mode)
128        os.chown(tmpdir, uidone, 0)
129
130        # Verify stickiness behavior, taking uid0's DAC_OVERRIDE into account.
131        drop = os.path.join(tmpdir, "remove.me")
132        open(drop, 'w').write("I can be deleted in a non-sticky directory")
133        os.chown(drop, uidone, 0)
134
135        expected = 0
136        if sticky and (uidtwo != 0):
137            expected = 1
138        rc = utils.system("su -c 'rm -f %s' %s" % (drop, usertwo),
139                          ignore_status=True)
140        if rc != expected:
141            if sticky:
142                self._failed("'%s' was able to delete files owned by '%s' "
143                             "in a sticky world-writable directory" %
144                             (usertwo, userone))
145            else:
146                self._failed("'%s' wasn't able to delete files owned by '%s' "
147                             "in a regular world-writable directory" %
148                             (usertwo, userone))
149            return
150        # File should still exist in a sticky directory.
151        self.check(os.path.exists(drop) == (sticky and uidtwo != 0),
152                   "'%s' should only exist in a sticky directory" % (drop))
153
154        # Create target files.
155        message = 'not very sekrit'
156        target = os.path.join(tmpdir, 'target')
157        open(target, 'w').write(message)
158        os.chmod(target, 0644)
159
160        sekrit_userone = 'sekrit %s' % (userone)
161        target_userone = os.path.join(tmpdir, 'target-%s' % (userone))
162        open(target_userone, 'w').write(sekrit_userone)
163        os.chmod(target_userone, 0400)
164        os.chown(target_userone, uidone, 0)
165
166        sekrit_usertwo = 'sekrit %s' % (usertwo)
167        target_usertwo = os.path.join(tmpdir, 'target-%s' % (usertwo))
168        open(target_usertwo, 'w').write(sekrit_usertwo)
169        os.chmod(target_usertwo, 0400)
170        os.chown(target_usertwo, uidtwo, 0)
171
172        # Create symlinks to target as different users.
173        userone_symlink = os.path.join(tmpdir, '%s.symlink' % (userone))
174        usertwo_symlink = os.path.join(tmpdir, '%s.symlink' % (usertwo))
175
176        utils.system("su -c 'ln -s %s %s' %s" % (target, userone_symlink,
177                                                 userone))
178        utils.system("su -c 'ln -s %s %s' %s" % (target, usertwo_symlink,
179                                                 usertwo))
180        self.check(os.lstat(userone_symlink).st_uid == uidone,
181                   "%s owned by %s" % (userone_symlink, userone))
182        self.check(os.lstat(usertwo_symlink).st_uid == uidtwo,
183                   "%s owned by %s" % (usertwo_symlink, usertwo))
184        # Verify userone symlink and directory are owned by the same uid.
185        self.check(os.lstat(userone_symlink).st_uid == os.lstat(tmpdir).st_uid,
186                   "%s and %s have same owner" %
187                   (tmpdir, userone_symlink))
188
189        ## Perform read verifications.
190        # Global target should be directly readable by both users.
191        self._read_contents_as(target, message, userone)
192        self._read_contents_as(target, message, usertwo)
193        # Individual targets should only be readable by owner, verifying
194        # DAC sanity, before we check symlink restriction tweaks, though
195        # we have to account for uid0's DAC_OVERRIDE.
196        self._read_contents_as(target_userone, sekrit_userone, userone)
197        self._read_contents_as(target_usertwo, sekrit_usertwo, usertwo)
198        self._read_contents_as(target_userone, sekrit_userone,
199                                usertwo, fail=(uidtwo != 0))
200        self._read_contents_as(target_usertwo, sekrit_usertwo,
201                                userone, fail=(uidone != 0))
202        # Global target should be readable through symlink by symlink owner,
203        self._read_contents_as(userone_symlink, message, userone)
204        self._read_contents_as(usertwo_symlink, message, usertwo)
205        # Global target should be readable through symlink of directory owner.
206        self._read_contents_as(userone_symlink, message, usertwo)
207        # Global target should not be readable through symlink when directory
208        # is sticky and the symlink and directory owner are different.
209        self._read_contents_as(usertwo_symlink, message, userone,
210                               fail=sticky)
211
212        ## Perform write verifications.
213        # Global target should be directly writable by both users.
214        self._write_as(target, target, userone)
215        self._write_as(target, target, usertwo)
216        # Global target should be writable through owner's symlink.
217        self._write_as(userone_symlink, target, userone)
218        self._write_as(usertwo_symlink, target, usertwo)
219        # Global target should be writable through symlink of directory owner.
220        self._write_as(userone_symlink, target, usertwo)
221        # Global target should be unwritable through symlink when directory
222        # is sticky and the symlink and directory owner are different.
223        self._write_as(usertwo_symlink, target, userone, fail=sticky)
224
225        ## Perform write-with-create verifications.
226        # Global target should be directly creatable by both users.
227        self._write_as(target, target, userone, create=True)
228        self._write_as(target, target, usertwo, create=True)
229        # Global target should be creatable through owner's symlink.
230        self._write_as(userone_symlink, target, userone, create=True)
231        self._write_as(usertwo_symlink, target, usertwo, create=True)
232        # Global target should be creatable through symlink of directory owner.
233        self._write_as(userone_symlink, target, usertwo, create=True)
234        # Global target should be uncreatable through symlink when directory
235        # is sticky and the symlink and directory owner are different.
236        self._write_as(usertwo_symlink, target, userone, create=True,
237                       fail=sticky)
238
239    def run_once(self):
240        # Empty failure list means test passes.
241        self._failures = []
242
243        # Prepare list of directories to clean up.
244        self._rmdir = []
245
246        # Verify symlink restrictions sysctl exists and is enabled.
247        sysctl = "/proc/sys/fs/protected_symlinks"
248        if (not os.path.exists(sysctl)):
249            # Fall back to looking for Yama link restriction sysctl.
250            sysctl = "/proc/sys/kernel/yama/protected_sticky_symlinks"
251        self.check(os.path.exists(sysctl), "%s exists" % (sysctl), fatal=True)
252        self.check(open(sysctl).read() == '1\n', "%s enabled" % (sysctl),
253                   fatal=True)
254
255        # Test the basic "root follows evil symlink" situation first, in
256        # a more auditable way than the extensive behavior tests that follow.
257        if os.path.exists("/tmp/evil-symlink"):
258            os.unlink("/tmp/evil-symlink")
259        utils.system("su -c 'ln -s /etc/shadow /tmp/evil-symlink' chronos")
260        rc = utils.system("cat /tmp/evil-symlink", ignore_status=True)
261        if rc != 1:
262            self._failed("root user was able to follow malicious symlink")
263        os.unlink("/tmp/evil-symlink")
264
265        # Test symlink restrictions, making sure there is no special
266        # behavior for the root user (DAC_OVERRIDE is ignored).
267        self._check_symlinks(sticky=False, userone='root', usertwo='chronos')
268        self._check_symlinks(sticky=False, userone='chronos', usertwo='root')
269        self._check_symlinks(sticky=True, userone='root', usertwo='chronos')
270        self._check_symlinks(sticky=True, userone='chronos', usertwo='root')
271
272        # Clean up from the tests.
273        for path in self._rmdir:
274            if os.path.exists(path):
275                shutil.rmtree(path, ignore_errors=True)
276
277        # Raise a failure if anything unexpected was seen.
278        if len(self._failures):
279            raise error.TestFail((", ".join(self._failures)))
280