diff mbox series

libfs: fix accidental overflow in offset calculation

Message ID 20240510-b4-sio-libfs-v1-1-e747affb1da7@google.com (mailing list archive)
State New, archived
Headers show
Series libfs: fix accidental overflow in offset calculation | expand

Commit Message

Justin Stitt May 10, 2024, 12:35 a.m. UTC
Running syzkaller with the newly reintroduced signed integer overflow
sanitizer gives this report:

[ 6008.464680] UBSAN: signed-integer-overflow in ../fs/libfs.c:149:11
[ 6008.468664] 9223372036854775807 + 16387 cannot be represented in type 'loff_t' (aka 'long long')
[ 6008.474167] CPU: 1 PID: 1214 Comm: syz-executor.0 Not tainted 6.8.0-rc2-00041-gec7cb1052e44-dirty #15
[ 6008.479662] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[ 6008.485276] Call Trace:
[ 6008.486819]  <TASK>
[ 6008.488258]  dump_stack_lvl+0x93/0xd0
[ 6008.490535]  handle_overflow+0x171/0x1b0
[ 6008.492957]  dcache_dir_lseek+0x3bf/0x3d0
...

Use the check_add_overflow() helper to gracefully check for
unintentional overflow causing wraparound in our offset calculations.

Link: https://github.com/llvm/llvm-project/pull/82432 [1]
Closes: https://github.com/KSPP/linux/issues/359
Cc: linux-hardening@vger.kernel.org
Signed-off-by: Justin Stitt <justinstitt@google.com>
---

Historically, the signed integer overflow sanitizer did not work in the
kernel due to its interaction with `-fwrapv` but this has since been
changed [1] in the newest version of Clang. It was re-enabled in the
kernel with Commit 557f8c582a9ba8ab ("ubsan: Reintroduce signed overflow
sanitizer").

Here's the syzkaller reproducer:

| # {Threaded:false Repeat:false RepeatTimes:0 Procs:1 Slowdown:1 Sandbox:
| # SandboxArg:0 Leak:false NetInjection:false NetDevices:false
| # NetReset:false Cgroups:false BinfmtMisc:false CloseFDs:false KCSAN:false
| # DevlinkPCI:false NicVF:false USB:false VhciInjection:false Wifi:false
| # IEEE802154:false Sysctl:false Swap:false UseTmpDir:false
| # HandleSegv:false Repro:false Trace:false LegacyOptions:{Collide:false
| # Fault:false FaultCall:0 FaultNth:0}}
| r0 = openat$sysfs(0xffffffffffffff9c, &(0x7f0000000000)='/sys/kernel/tracing', 0x0, 0x0)
| lseek(r0, 0x4003, 0x0)
| lseek(r0, 0x7fffffffffffffff, 0x1)

... which was used against Kees' tree here (v6.8rc2):
https://git.kernel.org/pub/scm/linux/kernel/git/kees/linux.git/log/?h=wip/v6.9-rc2/unsigned-overflow-sanitizer

... with this config:
https://gist.github.com/JustinStitt/824976568b0f228ccbcbe49f3dee9bf4
---
 fs/libfs.c | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)


---
base-commit: 0106679839f7c69632b3b9833c3268c316c0a9fc
change-id: 20240509-b4-sio-libfs-67947244a4a3

Best regards,
--
Justin Stitt <justinstitt@google.com>

Comments

Al Viro May 10, 2024, 12:49 a.m. UTC | #1
On Fri, May 10, 2024 at 12:35:51AM +0000, Justin Stitt wrote:
> @@ -147,7 +147,9 @@ loff_t dcache_dir_lseek(struct file *file, loff_t offset, int whence)
>  	struct dentry *dentry = file->f_path.dentry;
>  	switch (whence) {
>  		case 1:
> -			offset += file->f_pos;
> +			/* cannot represent offset with loff_t */
> +			if (check_add_overflow(offset, file->f_pos, &offset))
> +				return -EOVERFLOW;

Instead of -EINVAL it correctly returns in such cases?  Why?
Al Viro May 10, 2024, 1:04 a.m. UTC | #2
On Fri, May 10, 2024 at 01:49:06AM +0100, Al Viro wrote:
> On Fri, May 10, 2024 at 12:35:51AM +0000, Justin Stitt wrote:
> > @@ -147,7 +147,9 @@ loff_t dcache_dir_lseek(struct file *file, loff_t offset, int whence)
> >  	struct dentry *dentry = file->f_path.dentry;
> >  	switch (whence) {
> >  		case 1:
> > -			offset += file->f_pos;
> > +			/* cannot represent offset with loff_t */
> > +			if (check_add_overflow(offset, file->f_pos, &offset))
> > +				return -EOVERFLOW;
> 
> Instead of -EINVAL it correctly returns in such cases?  Why?

We have file->f_pos in range 0..LLONG_MAX.  We are adding a value in
range LLONG_MIN..LLONG_MAX.  The sum (in $\Bbb Z$) cannot be below
LLONG_MIN or above 2 * LLONG_MAX, so if it can't be represented by loff_t,
it must have been in range LLONG_MAX + 1 .. 2 * LLONG_MAX.  Result of
wraparound would be equal to that sum - 2 * LLONG_MAX - 2, which is going
to be in no greater than -2.  We will run
			fallthrough;
		case 0:
			if (offset >= 0)
				break;
			fallthrough;
		default:
			return -EINVAL;
and fail with -EINVAL.

Could you explain why would -EOVERFLOW be preferable and why should we
engage in that bit of cargo cult?
Justin Stitt May 10, 2024, 3:26 a.m. UTC | #3
Hi,

On Fri, May 10, 2024 at 02:04:51AM +0100, Al Viro wrote:
> On Fri, May 10, 2024 at 01:49:06AM +0100, Al Viro wrote:
> > On Fri, May 10, 2024 at 12:35:51AM +0000, Justin Stitt wrote:
> > > @@ -147,7 +147,9 @@ loff_t dcache_dir_lseek(struct file *file, loff_t offset, int whence)
> > >  	struct dentry *dentry = file->f_path.dentry;
> > >  	switch (whence) {
> > >  		case 1:
> > > -			offset += file->f_pos;
> > > +			/* cannot represent offset with loff_t */
> > > +			if (check_add_overflow(offset, file->f_pos, &offset))
> > > +				return -EOVERFLOW;
> > 
> > Instead of -EINVAL it correctly returns in such cases?  Why?
> 
> We have file->f_pos in range 0..LLONG_MAX.  We are adding a value in
> range LLONG_MIN..LLONG_MAX.  The sum (in $\Bbb Z$) cannot be below
> LLONG_MIN or above 2 * LLONG_MAX, so if it can't be represented by loff_t,
> it must have been in range LLONG_MAX + 1 .. 2 * LLONG_MAX.  Result of
> wraparound would be equal to that sum - 2 * LLONG_MAX - 2, which is going
> to be in no greater than -2.  We will run
> 			fallthrough;
> 		case 0:
> 			if (offset >= 0)
> 				break;
> 			fallthrough;
> 		default:
> 			return -EINVAL;
> and fail with -EINVAL.

This feels like a case of accidental correctness. You demonstrated that
even with overflow we end up going down a control path that returns an
error code so all is good. However, I think finding the solution
shouldn't require as much mental gymnastics. We clearly don't want our
file offsets to wraparound and a plain-and-simple check for that lets
readers of the code understand this.

> 
> Could you explain why would -EOVERFLOW be preferable and why should we
> engage in that bit of cargo cult?

I noticed some patterns in fs/ regarding -EOVERFLOW and thought this was
a good application of this particular error code.

Some examples:

read_write.c::do_sendfile()
  ...
	if (unlikely(pos + count > max)) {
		retval = -EOVERFLOW;
		if (pos >= max)
			goto fput_out;
		count = max - pos;
	}

read_write.c::generic_copy_file_checks()
  ...
	/* Ensure offsets don't wrap. */
	if (pos_in + count < pos_in || pos_out + count < pos_out)
		return -EOVERFLOW;

... amongst others.

So to answer your question, I don't have strong feelings about what the
error code should be; I was just attempting to match patterns I had seen
within this section of the codebase when handling overflow/wraparound.

If -EOVERFLOW is technically incorrect or if it is just bad style, I'm
happy to send a new version swapping it over to -EINVAL

Thanks
Justin
Al Viro May 10, 2024, 4:48 a.m. UTC | #4
On Fri, May 10, 2024 at 03:26:08AM +0000, Justin Stitt wrote:

> This feels like a case of accidental correctness. You demonstrated that
> even with overflow we end up going down a control path that returns an
> error code so all is good.

No.  It's about a very simple arithmetical fact: the smallest number that
wraps to 0 is 2^N, which is more than twice the maximal signed N-bit
value.  So wraparound on adding a signed N-bit to non-negative signed N-bit
will always end up with negative result.  That's *NOT* a hard math.  Really.

As for the rest... SEEK_CUR semantics is "seek to current position + offset";
just about any ->llseek() instance will have that shape - calculate the
position we want to get to, then forget about the difference between
SEEK_SET and SEEK_CUR.  So noticing that wraparound ends with negative
is enough - we reject straight SEEK_SET to negatives anyway, so no
extra logics is needed.

> However, I think finding the solution
> shouldn't require as much mental gymnastics. We clearly don't want our
> file offsets to wraparound and a plain-and-simple check for that lets
> readers of the code understand this.

No comments that would be suitable for any kind of polite company.
Al Viro May 10, 2024, 6:33 a.m. UTC | #5
On Fri, May 10, 2024 at 05:48:05AM +0100, Al Viro wrote:
> On Fri, May 10, 2024 at 03:26:08AM +0000, Justin Stitt wrote:
> 
> > This feels like a case of accidental correctness. You demonstrated that
> > even with overflow we end up going down a control path that returns an
> > error code so all is good.
> 
> No.  It's about a very simple arithmetical fact: the smallest number that
> wraps to 0 is 2^N, which is more than twice the maximal signed N-bit
> value.  So wraparound on adding a signed N-bit to non-negative signed N-bit
> will always end up with negative result.  That's *NOT* a hard math.  Really.
> 
> As for the rest... SEEK_CUR semantics is "seek to current position + offset";
> just about any ->llseek() instance will have that shape - calculate the
> position we want to get to, then forget about the difference between
> SEEK_SET and SEEK_CUR.  So noticing that wraparound ends with negative
> is enough - we reject straight SEEK_SET to negatives anyway, so no
> extra logics is needed.
> 
> > However, I think finding the solution
> > shouldn't require as much mental gymnastics. We clearly don't want our
> > file offsets to wraparound and a plain-and-simple check for that lets
> > readers of the code understand this.
> 
> No comments that would be suitable for any kind of polite company.

FWIW, exchange of nasty cracks aside, I believe that this kind of
whack-a-mole in ->llseek() instances is just plain wrong.  We have
80-odd instances in the tree.

Sure, a lot of them a wrappers for standard helpers, but that's
still way too many places to spill that stuff over.  And just
about every instance that supports SEEK_CUR has exact same kind
of logics.

As the matter of fact, it would be interesting to find out
which instances, if any, do *not* have that relationship
between SEEK_CUR and SEEK_SET.  If such are rare, it might
make sense to mark them as such in file_operations and
have vfs_llseek() check that - it would've killed a whole
lot of boilerplate.  And there it a careful handling of
overflow checks (or a clear comment explaining what's
going on) would make a lot more sense.

IF we know that an instance deals with SEEK_CUR as SEEK_SET to
offset + ->f_pos, we can translate SEEK_CUR into SEEK_SET
in the caller.
Al Viro May 10, 2024, 7:02 a.m. UTC | #6
On Fri, May 10, 2024 at 07:33:12AM +0100, Al Viro wrote:

> As the matter of fact, it would be interesting to find out
> which instances, if any, do *not* have that relationship
> between SEEK_CUR and SEEK_SET.  If such are rare, it might
> make sense to mark them as such in file_operations and
> have vfs_llseek() check that - it would've killed a whole
> lot of boilerplate.  And there it a careful handling of
> overflow checks (or a clear comment explaining what's
> going on) would make a lot more sense.
> 
> IF we know that an instance deals with SEEK_CUR as SEEK_SET to
> offset + ->f_pos, we can translate SEEK_CUR into SEEK_SET
> in the caller.

FWIW, weird instances do exist.

kernel/printk/printk.c:devkmsg_llseek(), for example.  Or this
gem in drivers/fsi/i2cr-scom.c:
static loff_t i2cr_scom_llseek(struct file *file, loff_t offset, int whence)
{
        switch (whence) {
        case SEEK_CUR:
                break;
        case SEEK_SET:
                file->f_pos = offset;
                break;
        default:
                return -EINVAL;
        }

        return offset;
}
SEEK_CUR handling in particular is just plain bogus: lseek(fd, -9, SEEK_CUR)
doing nothing to current position and returning EBADF.  Even if you've done
lseek(fd, 9, SEEK_SET) just before that...

I suspect that some of those might be outright bugs; /dev/kmsg one probably
isn't, but by the look of it those should be rare.

Then there's orangefs_dir_llseek(), with strange handling of SEEK_SET
(but not SEEK_CUR); might or might not be a bug...

From the quick look it does appear that it might be a project worth
attempting - exceptions are very rare.
diff mbox series

Patch

diff --git a/fs/libfs.c b/fs/libfs.c
index 3a6f2cb364f8..3fdc1aaddd45 100644
--- a/fs/libfs.c
+++ b/fs/libfs.c
@@ -147,7 +147,9 @@  loff_t dcache_dir_lseek(struct file *file, loff_t offset, int whence)
 	struct dentry *dentry = file->f_path.dentry;
 	switch (whence) {
 		case 1:
-			offset += file->f_pos;
+			/* cannot represent offset with loff_t */
+			if (check_add_overflow(offset, file->f_pos, &offset))
+				return -EOVERFLOW;
 			fallthrough;
 		case 0:
 			if (offset >= 0)
@@ -422,7 +424,9 @@  static loff_t offset_dir_llseek(struct file *file, loff_t offset, int whence)
 {
 	switch (whence) {
 	case SEEK_CUR:
-		offset += file->f_pos;
+		/* cannot represent offset with loff_t */
+		if (check_add_overflow(offset, file->f_pos, &offset))
+			return -EOVERFLOW;
 		fallthrough;
 	case SEEK_SET:
 		if (offset >= 0)