[BUG] Potential incorrect owner query before ownership acceptance
sisyphusSmiling opened this issue · 6 comments
Description
In contracts/HybridCustody.cdc:707, the
giveOwnership()
function sets theacctOwner
to the recipient to indicate they own this child account. However, there is a possibility that the recipient does not claim the published capability from the child's account. Consequently, thegetOwner()
function in line 690 would still show the account owner is the recipient, which is incorrect.
Recommendation
We recommend only setting the account owner to the recipient once they have called the
addOwnedAccount()
function.
After thinking about this for a bit, I think there might be a larger issue here that could simply be one of expectation.
I would expect that when someone calls ChildAccount.giveOwnership()
, they're granting ultimate authority over the underlying account, which is access to a ChildAccount
in the construction. And in so doing, I expect that their access is either eliminated or demoted in comparison to the access they just granted. But this isn't the case.
Taking a closer look at giveOwnership()
:
pub fun giveOwnership(to: Address) {
// Revoke all keys and reduce AuthAccount access to a single derived Capability path
self.seal()
let acct = self.borrowAccount()
// Derive a path for the new owner's ChildAccount Capability
let identifier = HybridCustody.getOwnerIdentifier(to)
// Link the ChildAccount Capability
let cap = acct.link<&{OwnedAccount, ChildAccountPublic, ChildAccountPrivate, MetadataViews.Resolver}>(PrivatePath(identifier: identifier)!, target: HybridCustody.ChildStoragePath)
?? panic("failed to link child account capability")
// Publish to the new owner
acct.inbox.publish(cap, name: identifier, recipient: to)
self.acctOwner = to
self.relinquishedOwnership = false
emit OwnershipGranted(id: self.uuid, child: self.acct.address, owner: to)
}
At the end of this, the caller of giveOwnership()
still has the same access as the new "owner" as their Capability still exists. While this isn't necessarily a problem for an app granting ownership to a user for the first time, this could be an attack vector if users have a naïve notion of "owner".
For example, I could be the current "owner" of a ChildAccount
and call giveOwnership(to: 0xALICE)
. Alice claims this ownership, adding the published ChildAccount
Capability to her Manager.ownedAccounts
, even noting that ChildAccount.acctOwner
is listed as her Address. However, both she and I have the same access on the account.
Here are some ideas around how to address this issue:
- Rename "ownership" to something that denotes a level of access, not level of authority. This makes is more apparent that the access can be shared. Some options:
giveOwnerAccess()
giveSudoAccess()
grantRootAccess()
- Allow for multiple owner Addresses -
acctOwners: [Address]
. This makes it clear that it's possible for multiple actors to have the same level of access. - Disallow reassignment of owners. Once
giveOwnership()
is called, it can never never be called again based on assignment ofacctOwner
. This is likely most consistent with the original intention of the design.
Thanks for the write-up @sisyphusSmiling, great catch, it looks like we're neglecting to unlink other owner capabilities when sealing a ChildAccount, we should add that.
We could certainly permit multiple owners, though I worry that could encourage a level of trust that isn't actually safe. Anyone who "owns" an account can unlink or link any other capability, meaning they could harm the other owner if they wanted to. Perhaps that's something we'd decide to live with, but at the same time, you an achieve multiple owners as the current one by publishing your own ownership capability.
I would caution against allowing an action to only take place once. With the exception of calling seal
(or whatever we name it in the end), everything should be recoverable in some shape or form, otherwise developers run the risk of permanently bricking their account in ways they didn't anticipate. And besides, I could always just remake a new ChildAccount resource as the owner of the current one and get around any restrictions there
My takeaway from this is:
- We need to iterate over all private paths and unlink any ownership capabilities when an account is sealed
- It sounds like there's interest in expressing joint ownership which we can explore
We could certainly permit multiple owners, though I worry that could encourage a level of trust that isn't actually safe.
Initially, I agree with this. I worry though that by attempting to enforce a single owner, we'll run into the typical anti-patter of attempting to prohibit what we can't prevent. As you said, you could simply publish your own ownership Capability or create another ChildAccount
resource.
I would caution against allowing an action to only take place once.
Point taken. A negative consequence of this could be janky workarounds on the part of the developers that mangle the whole thing anyway.
We need to iterate over all private paths and unlink any ownership capabilities when an account is sealed
Thinking about this, it looks like we'd need to avoid unlinking the Capabilities ProxyAccount
s maintain or giveOwnership()
will break all parent accounts' access. So, we'd unlink any ChildAccount
Capabilities except for those at HybridCustody.ChildPrivatePath
within the giveOwnership()
function.
A sneaky actor could just link their owner Capability at the canonical path, but this might be remediated by CapCons. This deserves a bit more thought, but I think the general guidance should be that users shouldn't trust hybrid custody accounts with highly sensitive assets regardless of the path forward.
Maybe I'm just a tad confused, here. Would we not be unlinking the following path upon giving ownership to another account?
https://github.com/onflow/hybrid-custody/blob/main/contracts/HybridCustody.cdc#L730
Thinking about this, it looks like we'd need to avoid unlinking the Capabilities ProxyAccounts maintain or giveOwnership() will break all parent accounts' access.
Correct, so we only want to remove capabilities which target the child account resource. I guess we could cycle all ProxyAccount resources and recreate their child account capabilities as well, but we might not be able to do all of that work within the constraints of compute limits in a transaction.
A sneaky actor could just link their owner Capability at the canonical path, but this might be remediated by CapCons
Very true. I think capcons solve this but we should double check. Persistent Capabilities seem to pop up for this contract quite often as an attack vector. I tend to agree with the sentiment of "this is a useful feature, but do not blindly trust".
I think the most common version of this is "Does Alice trust Application X to actually give ownership to them?" I'd say the answer to that is generally going to be yes if it's a trusted source. That assumption would absolutely break down under other circumstances, though
. Maybe I'm just a tad confused, here. Would we not be unlinking the following path upon giving ownership to another account?
You're right, we could simply unlink the Capability for the current owner if one is assigned. I was thinking about things from the perspective of a post-condition - the only ChildAccount
Capabilities that should exist are those at HybridCustody. ChildPrivatePath
and that derived for the new owner, which would entail unlinking anything else. That said, this approach would be overkill if we're assuming honest actors.
I guess we could cycle all ProxyAccount resources and recreate their child account capabilities as well, but we might not be able to do all of that work within the constraints of compute limits in a transaction.
Agreed
That assumption would absolutely break down under other circumstances, though
Point taken. I think the best path forward here is to simply unlink the Capability at the path derived for the current owner and address necessary trust assumptions in documentation. Does that plan sound good to you?
SGTM!