Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

many: fix behavior of deny rules created as a result of a prompt reply #14369

Merged
merged 3 commits into from
Aug 19, 2024

Conversation

olivercalder
Copy link
Member

@olivercalder olivercalder commented Aug 15, 2024

When an application tries to carry out an operation and AppArmor mediates the operation via prompting, it sends to snapd a struct with an Allow and Deny field, among others. The Allow field contains the bits for the permissions which the application already possessed, while the Deny` field contains the bits for the permissions which the application currently lacks.

The listener and related code was originally written with the assumption that a permission could not occur in both the Allow and Deny fields in the same request, and more generally, that any permission which occurred in the Allow field should be granted regardless of the prompt decision. The thought process was that this field was permissions which were matched by allow rules and were not in need of mediation.

However, in testing, requests from the kernel usually, if not always, receive the same permissions in both the Allow and Deny fields. This may be a bug, or it may be that the AppArmor profile happens to have both allow rules and prompt rules, with the prompt rules causing the permissions to be placed in the Deny field and passed to snapd.

Regardless, the solution to the immediate bug is relatively simple: if a permission occurs in both the Allow and Deny fields, ignore the fact that it was originally allowed, and treat it just like any other permission in the Deny field. When constructing the response message to send back to the kernel, the allowed permissions should be those from the request Allow field and not the request Deny field, along with any permissions from Deny which were explicitly allowed by the user. The denied permissions in the response should be any permissions from the message Deny field which were not explicitly allowed by the user. Thus, all permissions from the original request are included in the response, and we never allow previously denied permissions without explicit user consent.

This new design relies on the idea that replies always contain the set of permissions which were explicitly granted by the user. However, the existing mechanism for sending a reply to the listener is via a Response struct which has fields for outcome and permissions. Even when the user replies with a denial, we want to allow any permissions which were allowed by request rules prior to the eventual denial which resulted in the response to the kernel. Using an allow outcome in order to send the subset of allowed permissions even when the reply was a denial is confusing at best, and certainly superfluous, as the existing implementation ignored the replied permissions whenever the outcome was deny.

A cleaner implementation of the response mechanism forgoes the outcome field entirely, and instead simply takes the set of permissions that the user explicitly allowed and passes that to the listener, which then sorts out denying any permissions which were not explicitly allowed. This requires more than the minimum set of changes to fix the bug, but should simplify and clarify the code and its handling of allowed and denied permissions.

This is tracked internally by https://warthogs.atlassian.net/browse/SNAPDENG-30187

@github-actions github-actions bot added the Needs Documentation -auto- Label automatically added which indicates the change needs documentation label Aug 15, 2024
@olivercalder
Copy link
Member Author

CC @pedronis @sminez

@olivercalder
Copy link
Member Author

I've reproduced on a clean lxd VM prepared via make prepare-vm from the client repo. Open firefox, get prompt, respond with deny forever, subsequent requests are auto-allowed.

@olivercalder
Copy link
Member Author

olivercalder commented Aug 15, 2024

Figured out the problem: whenever we receive a message from the kernel, we're getting the requested permission in both the allow and deny fields, every time. So we treat all the requested permissions as originally allowed by AppArmor rules.

I think this should not be happening, my understanding was that the allow field would only contain permissions explicitly allowed (not prompt) by apparmor rules. So if we had AppArmor rules something like this defined in a profile:

allow   /path/to/foo    r
deny    /path/to/foo    w
prompt  /path/to/foo    x

and an application somehow made a magic syscall which requested rwx at the same time, my understanding is we'd get a request which looks like this (please excuse the hand waving):

{
    path:  "/path/to/foo",
    allow: r,
    deny:  w | x,
}

But the reality is that most requests from the kernel are for only a single permission, and the allow and deny fields are (from what I can see on 24.04 with AppArmor 4.0.2 vendored in snapd), always the same.

Here are some logs. The "received prompt request from kernel" messages are from the listener, and the "received from kernel requests channel" is the interfaces requests manager after it sees a new request from the listener (perhaps that log should instead say "received from listener requests channel")

Aug 15 19:28:21 aa-testing snapd[5093]: logger.go:93: DEBUG: received prompt request from the kernel: {MsgNotificationOp:{MsgNotification:{MsgHeader:{Length:115 Version:3} NotificationType:APPARMOR_NOTIF_OP Signalled:0 NoCache:0 ID:4 Error:-13} Allow:16 Deny:16 Pid:5965 Label:snap.firefox.firefox Class:AA_CLASS_FILE Op:0} SUID:1000 OUID:1000 Name:/home/ubuntu/Downloads/th-3816934196.jpeg}
Aug 15 19:28:21 aa-testing snapd[5093]: logger.go:93: DEBUG: received from kernel requests channel: &{5965 snap.firefox.firefox 1000 /home/ubuntu/Downloads/th-3816934196.jpeg AA_CLASS_FILE create 0xc0006ff740 0}
# here we are about to send back a manual "deny" reply from the client
Aug 15 19:28:51 aa-testing snapd[5093]: logger.go:93: DEBUG: sending request response back to the kernel: {MsgNotification:{MsgHeader:{Length:0 Version:0} NotificationType:APPARMOR_NOTIF_RESP Signalled:0 NoCache:1 ID:4 Error:0} Error:-13 Allow:16 Deny:16}
# note here that because we set `allow` to be originally allowed permissions and
# `deny` to be originally denied permissions, both end up as 0x16. The kernel
# treats this as a denial, as we intended, since deny takes precedence. But we
# don't really want the permission in the allow field anyway.
...
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received prompt request from the kernel: {MsgNotificationOp:{MsgNotification:{MsgHeader:{Length:115 Version:3} NotificationType:APPARMOR_NOTIF_OP Signalled:0 NoCache:0 ID:7 Error:-13} Allow:16 Deny:16 Pid:5965 Label:snap.firefox.firefox Class:AA_CLASS_FILE Op:0} SUID:1000 OUID:1000 Name:/home/ubuntu/Downloads/th-1120162960.jpeg}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received from kernel requests channel: &{5965 snap.firefox.firefox 1000 /home/ubuntu/Downloads/th-1120162960.jpeg AA_CLASS_FILE create 0xc0006591a0 0}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: request denied by existing rule: &{PID:5965 Label:snap.firefox.firefox SubjectUID:1000 Path:/home/ubuntu/Downloads/th-1120162960.jpeg Class:AA_CLASS_FILE Permission:create replyChan:0xc0006591a0 replied:0}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: sending request response back to the kernel: {MsgNotification:{MsgHeader:{Length:0 Version:0} NotificationType:APPARMOR_NOTIF_RESP Signalled:0 NoCache:1 ID:7 Error:0} Error:0 Allow:16 Deny:0}
...
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received prompt request from the kernel: {MsgNotificationOp:{MsgNotification:{MsgHeader:{Length:115 Version:3} NotificationType:APPARMOR_NOTIF_OP Signalled:0 NoCache:0 ID:8 Error:-13} Allow:18 Deny:18 Pid:5965 Label:snap.firefox.firefox Class:AA_CLASS_FILE Op:0} SUID:1000 OUID:1000 Name:/home/ubuntu/Downloads/th-1120162960.jpeg}
# note here that both write and create were requested.
# and both are in both the allow and deny fields.
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received from kernel requests channel: &{5965 snap.firefox.firefox 1000 /home/ubuntu/Downloads/th-1120162960.jpeg AA_CLASS_FILE write|create 0xc0006593e0 0}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: request denied by existing rule: &{PID:5965 Label:snap.firefox.firefox SubjectUID:1000 Path:/home/ubuntu/Downloads/th-1120162960.jpeg Class:AA_CLASS_FILE Permission:write|create replyChan:0xc0006593e0 replied:0}
# we end up with Allow:0b10010 because both write and create were in the allow field,
# and we populate the allow field with originally (originally allowed | newly allowed)
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: sending request response back to the kernel: {MsgNotification:{MsgHeader:{Length:0 Version:0} NotificationType:APPARMOR_NOTIF_RESP Signalled:0 NoCache:1 ID:8 Error:0} Error:0 Allow:18 Deny:0}
...
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received prompt request from the kernel: {MsgNotificationOp:{MsgNotification:{MsgHeader:{Length:115 Version:3} NotificationType:APPARMOR_NOTIF_OP Signalled:0 NoCache:0 ID:9 Error:-13} Allow:4096 Deny:4096 Pid:6292 Label:snap.firefox.firefox Class:AA_CLASS_FILE Op:0} SUID:1000 OUID:1000 Name:/home/ubuntu/Downloads/th-1120162960.jpeg}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: received from kernel requests channel: &{6292 snap.firefox.firefox 1000 /home/ubuntu/Downloads/th-1120162960.jpeg AA_CLASS_FILE change-mode 0xc000659620 0}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: request denied by existing rule: &{PID:6292 Label:snap.firefox.firefox SubjectUID:1000 Path:/home/ubuntu/Downloads/th-1120162960.jpeg Class:AA_CLASS_FILE Permission:change-mode replyChan:0xc000659620 replied:0}
Aug 15 19:29:09 aa-testing snapd[5093]: logger.go:93: DEBUG: sending request response back to the kernel: {MsgNotification:{MsgHeader:{Length:0 Version:0} NotificationType:APPARMOR_NOTIF_RESP Signalled:0 NoCache:1 ID:9 Error:0} Error:0 Allow:4096 Deny:0}

@olivercalder
Copy link
Member Author

So the question is: is it intentional that the messages from the kernel have the (same) requested permissions in both the allow and deny fields? Or is this a bug?

@olivercalder
Copy link
Member Author

I think why this may be happening is that the operation is matched by both an allow rule and a prompt rule (which would have formerly been an allow rule, without prompt prefixes) in the AppArmor profile. But since deny has priority over allow, we get a prompt. But technically, there is still an allow rule, so we get the permission in both the allow and deny fields.

@olivercalder olivercalder marked this pull request as ready for review August 15, 2024 20:52
@olivercalder olivercalder added the Needs Samuele review Needs a review from Samuele before it can land label Aug 15, 2024
@olivercalder olivercalder added this to the 2.65 milestone Aug 15, 2024
@olivercalder
Copy link
Member Author

The previous commit should address the bug. I'll add some more robust tests to make sure the response message construction does exactly what we intend with respect to allowed and denied permissions.

@olivercalder
Copy link
Member Author

I'll also explore replacing the listener.Response with a clearer response mechanism which just sends permissions the user wants to explicitly allow, and the listener then explicitly denies any which were requested but not explicitly allowed.

@sminez
Copy link

sminez commented Aug 16, 2024

Is this a change from the kernel side that is breaking existing code in snapd or a bug introduced on the snapd as part of the recent merges? This was definitely working previously.

@olivercalder olivercalder force-pushed the prompting-fix-reply-deny-rule branch 3 times, most recently from 876e0b0 to 37eaded Compare August 16, 2024 17:52
@github-actions github-actions bot removed the Needs Documentation -auto- Label automatically added which indicates the change needs documentation label Aug 16, 2024
@olivercalder
Copy link
Member Author

olivercalder commented Aug 16, 2024

Is this a change from the kernel side that is breaking existing code in snapd or a bug introduced on the snapd as part of the recent merges? This was definitely working previously.

Before the recent merges, snapd (prompting branch) was being very simplistic about allowing and denying permissions: if the user denied any permission (via reply or rule), send a message to the kernel denying all originally-requested permissions, and if the user allowed all permissions via rules or via an explicit allow reply, send a message to the kernel allowing all originally-requested permissions.

This approach was fine, but that meant that if there was an unrecognized file permission, snapd could implicitly allow it without the user being prompted for it. So we needed to only allow permissions which were explicitly allowed by snapd. This was addressed in to #14196, which was itself based on the earlier requestprompts PR #13981. The idea was that if a request was allowed, we should send back the following permissions as approved: (reqAllow | (reqDeny & explicitlyAllowed)). We can see the problem is already here, if reqAllow has permissions matching reqDeny, then we allow every permission anyway, at least if we set the allow field to true in the response to the listener.

But this bug didn't start manifesting in tests until we adjusted the handling of partially allowed requests as part of the interfaces requests manager PR #14316. If a new request has some permissions which are allowed by existing rules and at least one permission which is denied by existing rules, then we immediately send back a reply. That PR changed the behavior so that instead of sending back a denial of every permission (even the ones which were allowed), we instead send back an "allow" response containing only the permissions which were explicitly allowed, with the idea that the listener sorts out the permissions to the kernel so that only the allowed permissions are allowed and the rest are denied. Furthermore, we were relying on the kernel auto-denying any permissions which were not included in the allow or deny fields: https://github.com/canonical/snapd/pull/14316/files#diff-7a19d15563de3674607eeb9469c2abf6ec8ee481d1aef78a712fb7db86fec0c2R448 (some unfortunate foreshadowing). But the problem was that all the permissions were included in the Allow field as well, so we were now allowing everything, even on intended denials from existing rules, whenever we were abusing the Allow: true listener responses to partially allow permissions.

Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did a pass, it seems correct


// If the same permission appears in both the allow and deny fields,
// treat it as initially denied.
apparmorAllowed := msg.Allow & ^msg.Deny
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think golang has directly a &^ operator as well

resp.Allow = apparmorAllowed | (explicitlyAllowed & msg.Deny)
// Deny permissions which were initially denied and not explicitly allowed
// by the user.
resp.Deny = msg.Deny & ^explicitlyAllowed
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

if !allow {
// Remaining permissions were denied, so allow permissions which were
// previously allowed by prompt rules
allowedPerms = make([]string, 0, len(constraints.originalPermissions)-len(constraints.remainingPermissions))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not for here but we should take a note to find another name for remainingPermissions because it doesn't clearly convey what they are, they are the permission we are prompting about (because no rules gave them already)

Copy link
Collaborator

@pedronis pedronis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Contributor

@bboozzoo bboozzoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, left a little suggestion for a followup

// replyChan is a channel for writing the response.
replyChan chan *Response
// replyChan is a channel for sending the explicitly allowed permissions.
replyChan chan any
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it makes me slightly uneasy to erase all type and pass around any here, as if we're plugging a leaky interface and then make it harder to follow the code. Since we already expose types from notify package, we may as well introduce a generic notify.Permissions interface with a single method such that any doesn't match anymore:

// in apparmor/notiry
type Permissions interface {
    Kind() string // eg. returns "file"
    String() string // eg. returns "file:rwxm" 
}

In this case we'd have:

func AbstractPermissionsToAppArmorPermissions(iface string, permissions []string) (notify.Permissions, error) {
    ...
}

and then the listener package can do type assertion to a concrete type. FWIW, I think this can be a followup

}

// If the same permission appears in both the allow and deny fields,
// treat it as initially denied.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it may be worth expanding the comment a little bit to mention that this is unexpected but it's also what the kernel gave us

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to wait to chat with JJ before adding that comment, in case it actually is expected and it's only unexpected by me :) but yes I agree

If an operation was matched by both `allow` and `prompt` rules in the
apparmor profile, the message from the kernel may contain overlapping
permissions in the allow and deny fields. It is important that any
such permissions are not auto-allowed, and that we do what the user
specifies, either via existing request rules or a direct prompt reply.

Signed-off-by: Oliver Calder <[email protected]>
When a prompt is denied, either because of a direct reply or because a
rule was added which denies one of its remaining permissions, the prompt
may have some originally-requested permissions which were previously
allowed due to previous rules. We want to reply to the kernel that those
previously-allowed permissions were allowed, but the newly-denied
permissions and any permissions which were not previously allowed by a
rule should be denied.

The old `listener.Response` type had a field for whether the request
should be allowed or denied, as well as the permissions for which that
decision should apply. Because of the cases described above, users of
this type often needed to send an "allow" response with the previously-
allowed permissions, even if the reply which triggered the response was
actually a denial. Furthermore, in the deny case, the permissions
included in the response were ignored, and all originally-requested
permissions were denied.

This is clearly confusing.

A much simpler approach is to reply directly with the permission(s)
which should be allowed. In strictly "deny" cases where no permissions
were previously allowed, the allowed permission sent as the response
should be the empty permission for the corresponding AppArmor permission
type -- in the case of a file, this is `notify.FilePermission(0)`. In
the more common cases where there may be some previously-allowed
permissions, any such permissions can be sent as the allowed permission,
and any permissions which are not included in the allowed permission are
denied by the listener.

Signed-off-by: Oliver Calder <[email protected]>

s/a/n/listener: accept nil allowed permission as allowing no permissions

Signed-off-by: Oliver Calder <[email protected]>

s/a/n/listener: test response behavior with different allowed permission

Signed-off-by: Oliver Calder <[email protected]>

s/a/n/listener: use the &^ operator instead of & ^ separately

Signed-off-by: Oliver Calder <[email protected]>
@pedronis pedronis merged commit 7e88105 into canonical:master Aug 19, 2024
51 of 53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Samuele review Needs a review from Samuele before it can land
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants