CWE-749: Exposed Dangerous Method or Function
Overview
An application exposes a method or function that was never meant to be reachable by external or untrusted callers - an admin operation, a debug hook, or an internal helper - through an API, RPC interface, or scripting bridge with no access check restricting who can invoke it. Either the method was never intended for outside access at all, or it was meant for a narrow set of trusted callers and that restriction was never enforced.
Relationship to Other CWEs
- CWE-749 (this page) - the general weakness of exposing privileged functionality without an access check.
- CWE-618 (Exposed Unsafe ActiveX Method) - the same weakness applied to ActiveX controls marked safe for scripting.
OWASP Classification
A01:2025 - Broken Access Control
Risk
Critical: An attacker can invoke a privileged operation directly, without passing the authentication and authorization checks the normal path applies.
Remediation Steps
Core Principle: Do not expose privileged functionality to callers who have not been authenticated and authorized for that specific operation; the interface should refuse to route the call, not rely on the caller never finding it.
Trace the Data Path
- Source: The interface surface that accepts the call - an HTTP route, an RPC/API method, a scripting bridge, an IOCTL, or a reflection-based dispatcher.
- Sink: The dangerous operation itself - deleting data, running a command, changing configuration, granting privileges, or reading/writing files.
- Data Flow / Missing Controls: Look for methods reachable from the public interface with no authentication check, no authorization check, or a check that exists but can be skipped (wrong HTTP verb, alternate route, direct RPC call bypassing a UI-layer check).
Remove or Restrict Exposure (Primary Defense)
- Delete debug, diagnostic, and admin-only endpoints before shipping to production; do not rely on "nobody will guess the URL." If an endpoint must exist in non-production environments, gate it behind an environment check plus its own authentication, not an environment check alone - and write the check so the unset case is the safe one.
if env == "production": disable()leaves the endpoint enabled on any deployment where the variable is missing, empty, or spelledProduction. Enable from an explicit allowlist instead -if env in {"local", "dev", "test"}: enable()- so an unrecognised or absent value disables it and the mistake is a missing debug tool rather than an exposed one. - Make internal helper methods actually internal: use the language's strongest available visibility modifier (private, package-private, unexported) so the method cannot be called from outside its intended boundary, rather than leaving it public with a comment saying "internal use only."
- Never expose a raw dynamic-dispatch or reflection-based invocation method that lets a caller name any method or class to run (
invoke(methodName, args)style APIs). That turns every method in the reachable object graph into attack surface, including ones added later without anyone reconsidering exposure.
Require Authentication and Authorization on Every Remaining Exposed Method (Primary Defense)
For any dangerous operation that must remain reachable from outside:
- Require authentication on every call - no anonymous access to privileged operations.
- Require an authorization check tied to the specific action, not just "is this user logged in" - verify the caller holds the role or permission that operation demands, and check it on the server for every call rather than trusting a client-side gate.
- Deny by default: if the authorization check errors or cannot be evaluated, refuse the call rather than allowing it through.
- Log invocation of sensitive operations, including the caller's identity, so misuse is detectable after the fact.
Constrain What a Caller Can Do (Defense in Depth)
- Where an operation must be callable by name (e.g. a plugin or scripting bridge), map the name to the operation through an explicit registry rather than resolving it against the object - a list of permitted names still leaves a dynamic dispatcher to resolve them, so the two can drift apart while both look right.
- Scope dangerous operations narrowly. A method that deletes one record the caller is authorized to touch is a smaller risk than one that accepts an arbitrary target and deletes anything.
- Apply least privilege to the account or role the operation runs as, so that even a successful call is limited in blast radius.
Test the Fix
- Call the previously exposed method with no credentials and confirm it is rejected (401/403), not merely hidden from a menu or documentation.
- Call it with valid credentials that lack the required role/permission and confirm it is still rejected.
- Confirm the method is unreachable through alternate routes (different HTTP verb, direct RPC call, reflection) that might bypass a UI-layer check.
- Re-scan or re-enumerate the API surface to confirm no other methods share the same exposure pattern.
Common Vulnerable Patterns
// VULNERABLE - pseudo-code
// dangerous method reachable with no authentication or authorization check
function handle_request(method_name, args):
// any caller, including unauthenticated ones, can invoke any method
return dispatch(method_name, args)
// Attack: POST /api/invoke {"method": "deleteAllUsers", "args": []}
// Result: attacker triggers a privileged operation with no credentials at all
Why this is vulnerable: dispatch resolves whatever name it is given, so the set of reachable operations is not the API's documented surface but every method the dispatcher can find - including administrative ones, internal helpers, and anything a dependency contributed. Nobody decided to expose deleteAllUsers; it became reachable by being present.
The exposed surface then grows on its own. A method added for an internal caller is externally invocable from the moment it is written, with no change to this function and nothing in the new method's own file to suggest it is now part of the public API - so review of the added code cannot catch it, because the exposure is elsewhere. Denylisting the dangerous names does not hold either, since it has to anticipate every method anyone will add.
What bounds this is an explicit registry mapping permitted external names to the operations they invoke, so an unrecognised name is rejected rather than resolved, and adding something to the external surface is a deliberate edit. Authorization then belongs on each registered operation, because being callable and being permitted are separate questions.
Secure Patterns
// SECURE - pseudo-code
// a registry, not a name allowlist: each entry names the operation directly,
// so nothing is resolved from a caller-supplied string
OPERATIONS = {
"getProfile": profileService.get,
"updateProfile": profileService.update,
"changePassword": accountService.changePassword,
}
function handle_request(request):
caller = require_authentication(request) // reject anonymous callers
operation = OPERATIONS.lookup(request.operationName)
if operation is null:
deny(403, "operation not permitted")
require_authorization(caller, request.operationName) // this caller, this operation
audit_log(caller, request.operationName)
return operation(request.args)
Why this works: The caller's string selects an entry in a table rather than naming something to be resolved, so the reachable surface is exactly the three operations written here, and adding an internal method elsewhere in the codebase cannot extend it. An unrecognised name has nothing to resolve against and is denied. Authentication and authorization are then checked on the server for every call, independent of anything the client claims, so an attacker cannot invoke a registered operation without first proving they are allowed to use it.