NEAR Smart Contract Auditing: Accounts & Access Control

Category Cybersecurity

This is the third part in a three part series. Feel free to check out the others:

Introduction

In this article, we will examine how NEAR accounts work at a fundamental level, explore the security considerations they present, and analyze the practical impact of these design choices for both developers and users. By understanding these underlying mechanics, we can better appreciate the tradeoffs involved and implement more robust systems on the NEAR network.

The Human Face of Blockchain Accounts

If you have interacted with NEAR, you have likely noticed that accounts look more familiar: alice.near rather than 0x7c5206b1b75b8787420b09d8697e08180cdf896c. This human-readable approach represents NEAR's design philosophy that permeates the entire protocol.

Named vs. Implicit Accounts

NEAR accounts are designed to be user-friendly and accessible but also secure and flexible. To achieve this balance, their account system has two distinct flavours.

Named Accounts

Accounts like alice.near or mycontract.alice.near are hierarchical, readable, and memorable. They follow simple rules:

  • 2-64 characters in length
  • Lowercase alphanumeric characters only
  • Periods (.) separate domains and sub accounts
  • The rightmost segment typically indicates the top-level domain (near or testnet)

Implicit Accounts

These are more traditional in the blockchain world – they are derived directly from cryptographic public keys, resulting in those familiar 64-character hexadecimal strings. While less friendly to humans, they serve an important role in the ecosystem.

This dual approach provides flexibility: users can have friendly, memorable accounts for everyday use, while developers can leverage the mathematical properties of implicit accounts when needed.

Access Management

What makes NEAR's account system particularly interesting from a security perspective is its approach to access keys. Rather than the simplistic "whoever has the private key controls the account" model, NEAR implements a more nuanced system.

Each account can have multiple associated keys, each with different permission levels.

Full Access Keys

These are the master keys to your kingdom – they allow any action to be performed on the account, including deploying contracts, transferring tokens, or even deleting the account entirely. With great power comes great responsibility, and these keys should be guarded accordingly.

Function Call Keys

Function Call Keys can be limited in multiple ways:

  • They can only call specific smart contract methods
  • They can be restricted to specific smart contracts
  • They can have limited gas allowances
  • They cannot transfer NEAR tokens directly

This granular permission system allows for unique security patterns. For example, you might create a Function Call Key that allows a mobile app to make specific transactions on your behalf, without risking your entire account if the key is compromised.

The Hierarchy of Trust

NEAR's account system implements a hierarchical structure that mirrors the domain name system of the internet. An account can create sub accounts beneath it but cannot create accounts at its own level.

For example, alice.near can create myproject.alice.near, but cannot create bob.near. This hierarchical approach:

  1. Creates clear ownership boundaries
  2. Prevents namespace conflicts
  3. Allows for organization-specific subaccount structures
  4. Enables delegated account management

This pattern feels intuitive because we are already familiar with similar hierarchies in email addresses and websites, but it has important implications for how applications and organizations can structure their on-chain presence.

Security Implications and Best Practices

NEAR's flexible account and permission model creates powerful capabilities, but also introduces specific security considerations that developers must carefully address. We will examine the most important security aspects to consider when building on NEAR.

Access Control Fundamentals

The foundation of security in NEAR contracts begins with proper access control. When building smart contracts, it is essential to understand exactly which functions should be accessible to which accounts.

Proper Environment Variable Usage

NEAR provides several environment variables that help establish identity:

  • current_account_id() - References the contract itself (similar to address(this) in Solidity)
  • predecessor_account_id() - Identifies the immediate caller (similar to msg.sender in Solidity)
  • signer_account_id() - Represents the account that signed the original transaction (similar to tx.origin in Solidity)

The key security principle is to rely on predecessor_account_id() for access control, not signer_account_id(). Using signer_account_id() is risky because it makes contracts vulnerable to phishing attacks. A malicious contract could trick a user into signing a transaction, then use cross-contract calls to access protected functions in other contracts while preserving the original signer's identity.

// INSECURE: Vulnerable to phishing attacks via cross-contract calls
pub fn transfer_funds(&mut self, to: AccountId, amount: U128) {
    if env::signer_account_id() == self.owner_id {
        // This check can be bypassed through a chain of contracts
        // Transfer funds...
    }
}

// SECURE: Proper access control
pub fn transfer_funds(&mut self, to: AccountId, amount: U128) {
    if env::predecessor_account_id() == self.owner_id {
        // This check ensures the immediate caller is the owner
        // Transfer funds...
    }
}

Missing Access Control

One of the most common security issues in NEAR contracts is exposing admin-level or internal functions without proper access control. For example, a contract might include a function to pause all operations, but without restricting who can call it.

// VULNERABLE: No access control on critical function
pub fn pause(&mut self) {
    self.status = ContractStatus::Paused;
}

// SECURE: With access control
pub fn pause(&mut self) {
    assert!(
        env::predecessor_account_id() == self.owner, 
        "Not allowed"
    );
    self.status = ContractStatus::Paused;
}

Without these checks, anyone could pause the contract, leading to denial-of-service conditions or other unintended behaviour.

Protecting Callback Functions

Cross-contract calls in NEAR are asynchronous, creating a potential vulnerability between the initial call and its callback execution. As explained in more detail in our previous article, callbacks must be protected to prevent unauthorized access.

The #[private] macro is essential for callback protection:

// VULNERABLE: Unprotected callback
pub fn ft_resolve_transfer(
    &mut self,
    sender_id: AccountId,
    receiver_id: AccountId,
    amount: U128,
) -> U128 {
    // A malicious actor could call this directly with 
    // arbitrary parameters to drain funds
    // Process transfer result...
}

// SECURE: Protected callback with #[private] macro
#[private]
pub fn ft_resolve_transfer(
    &mut self,
    sender_id: AccountId,
    receiver_id: AccountId,
    amount: U128,
) -> U128 {
    // Only the contract itself can call this function
    // Process transfer result...
}

The #[private] macro expands to a check that ensures current_account_id() == predecessor_account_id(), which is intended to guarantee that only the contract itself can call the function.

Important Edge Case: Account Key Bypass

There is a subtle but important edge case to be aware of with the #[private] macro. If the contract account (e.g., alice.near) has access keys (either Full Access or Function Call keys) that can call the specific function, then the account holder could potentially bypass the #[private] protection.

This is possible because:

  • When the account uses its keys to call its own contract's function directly both current_account_id() and predecessor_account_id() will return the same value (alice.near).
  • The #[private] check will pass, even though this is an external call.

Macro Usage and Trait Implementation Risks

NEAR's Rust SDK uses macros like #[near] to generate boilerplate code. However, improper use of these macros can create security vulnerabilities.

A particularly subtle issue involves trait implementations. When marking a trait implementation with #[near], all methods in that trait are exposed for external calls, even if some were meant to be internal only:

pub trait Pausable {
    fn toggle_pause(&mut self);
    fn pause(&mut self);
    fn unpause(&mut self);
    fn when_not_paused(&self);
}

// VULNERABLE: Exposes all trait methods to external calls
#[near]
impl Pausable for StatusMessageContract {
    fn toggle_pause(&mut self) {
        // Anyone can call this function even though it should be restricted
        // ...
    }
    // ...
}

// SECURE: Use internal traits without #[near] or use explicit access control
#[near]
impl StatusMessageContract {
    pub fn pub_toggle_pause(&mut self) {
        assert!(env::predecessor_account_id() == self.owner, "Permission Denied");
        self.toggle_pause()
    }

    fn toggle_pause(&mut self) {
        // Internal implementation that can't be called directly
        // ...
    }
}

Best Practices for Implementation Organization

  1. Use a single #[near] annotated impl block for all publicly callable functions
  2. Use separate non-annotated impl blocks for internal functions and trait implementations
  3. Implement sensitive traits in private functions, then wrap them with public functions that include access control
// Good pattern: Separate trait implementation from contract interface
// Trait implementation without #[near] - not directly callable
impl Pausable for StatusMessageContract {
    fn toggle_pause(&mut self) {
        // Internal implementation
        self.paused = !self.paused;
    }
    // Other trait methods...
}

// Public interface with #[near] - contains access control
#[near]
impl StatusMessageContract {
    pub fn admin_toggle_pause(&mut self) {
        assert!(env::predecessor_account_id() == self.owner, "Permission Denied");
        // Call the trait implementation
        self.toggle_pause();
    }

    // Other public contract methods...
}

You can use cargo-expand to check how the macros expand and ensure they do not expose unintended functionality.

Note #[near] replaces the deprecated macro #[near_bindgen] with some minor differences. Checkout the docs for the exact changes.

The One-Yocto Pattern

One elegant security pattern in NEAR's ecosystem is the "assert_one_yocto()" approach. Since Function Call Keys cannot attach NEAR tokens to transactions, requiring even a tiny token attachment (1 yocto = 10^-24 NEAR) effectively ensures that the action must be authorized by a Full Access Key or another contract.

pub fn withdraw_funds(&mut self) {
    // Require 1 yocto attachment to ensure this is called with a full access key
    assert_one_yocto();
    // Perform sensitive operation...
}

This creates a clear distinction between actions that should require full authority and those that can be delegated to Function Call Keys.

Role-Based Access Control (RBAC)

Relying on a single key for all privileged operations creates centralization risk. If that key is compromised, the entire system is vulnerable. Role-based access control divides privileges among different accounts based on their role.

// RISKY: Single owner model
pub fn blacklist_account(&mut self, account: AccountId) -> String {
    self.assert_owner();
    format!("Account Blacklisted: {account}")
}

// SAFER: Role-based access control
pub fn blacklist_account(&mut self, account: AccountId) -> String {
    self.assert_security_role();
    format!("Account Blacklisted: {account}")
}

pub fn set_price(&mut self, price: u128) -> String {
    self.assert_oracle_role();
    format!("Price set: {price}")
}

pub fn remove_pool(&mut self, pool_id: u128) -> String {
    self.assert_manager_role();
    format!("Pool removed: {pool_id}")
}

For critical applications, consider implementing multi-signature requirements for sensitive operations or transitioning to DAO governance for the most important functions.

NEAR-specific RBAC implementations can be found in libraries like NRML (NEAR Rust Macros Library) or NEAR Contract Tools.

Centralization Risks with Full Access Keys

Smart contracts controlled by Full Access Keys represent a centralisation risk. If that key is compromised, the entire contract can be replaced or drained, since NEAR allows updating contract code after deployment (unlike Ethereum). For truly decentralized applications, consider these approaches:

  • Remove all keys after deployment (entering a "No Access Key" state)
  • Implement time-locks on sensitive operations
  • Require multi-signature approval for code updates
  • Transition to DAO governance

A contract with no access keys can still be interacted with, but no single entity can modify its code or delete it.

The simplest and most reliable approach is to use a DeleteKey action in a transaction after the contract is deployed:

// Delete the specified key
await account.deleteKey(publicKeyToRemove);

Conclusion

NEAR's account system represents a thoughtful balance between security, usability, and flexibility. By implementing human-readable accounts, hierarchical structures, and granular permission systems, NEAR has created an infrastructure that is both powerful for developers and accessible to users.

At Sigma Prime, we are committed to securing and hardening Blockchain networks and protocols of all kinds. If you are building solutions and want to harness our cutting-edge security expertise in this area, get in touch!