PyFu

Python Type Hinting and Annotations

Core Python Concepts

Python is a dynamically typed language, which means variables and function arguments do not require explicit types, the interpreter figures them out at runtime.

While this makes the language very flexible and expressive, it can also make it harder to catch certain bugs early, especially in large codebases or collaborative environments.

To address this, Python introduced type hinting (PEP 484) starting with Python 3.5, allowing developers to optionally add type annotations to their functions, variables, and class members.

These annotations don’t affect runtime behavior, but they are valuable for both static analysis tools (such as mypy, pyright, or IDEs like VSCode and PyCharm) and developers themselves.

They help with type checking, improve code readability, and make it easier to understand the code and the expected input output for given components.

For example, the following function uses type annotations to specify that both x and y should be integers, and that the function will return an integer:

def add(x: int, y: int) -> int:
    return x + y

As mentioned earlier, these annotations assist static analysis tools in detecting type-related issues early in the development process.

They also enhance code readability by clearly documenting the expected input and return types, making the code easier to understand and maintain for other developers.

Another example of a class with type annotations that improve clarity and assist with static analysis:

class PyFuUser:
    def __init__(self, name: str, age: int):
        self.name: str = name
        self.age: int = age

    def greet(self) -> str:
        return f"Hello, my name is {self.name} and I'm {self.age} years old."

In this example, the constructor __init__ uses type annotations to indicate that the name parameter should be a string and age an integer. The instance variables self.name and self.age are also explicitly annotated to clarify their expected types.

Additionally, the greet() method includes a return type annotation, specifying that it returns a string. These annotations improve code readability and enable static analysis tools to catch type mismatches and other related issues during development.

Why annotations matter to an attacker

Type hints are advertised as “purely for tooling, no runtime effect,” and at the language level that is true: the interpreter does not enforce them. But two facts make annotations a live attack surface rather than inert documentation: they are introspectable at runtime, and modern frameworks drive real behavior off them.

Every annotated object carries an __annotations__ mapping that any code can read while the program runs:

>>> def add(x: int, y: int) -> int: ...
>>> add.__annotations__
{'x': <class 'int'>, 'y': <class 'int'>, 'return': <class 'int'>}
>>> PyFuUser.__annotations__
{}
>>> PyFuUser.__init__.__annotations__
{'name': <class 'str'>, 'age': <class 'int'>}

During recon this is a free schema dump. Annotations on request models, ORM classes, and internal service objects tell you exactly which fields exist, which types they expect, and (for frameworks that auto-coerce) what an attacker is allowed to send. The hints that were meant for the IDE are the same hints that describe the parser’s trust boundary.

get_type_hints() evaluates strings

Annotations are not always live objects. Two common situations store them as strings that are only turned back into objects on demand:

  1. Forward references. A type written as a string literal, x: "SomeClass", used when the referenced type is not defined yet or to break an import cycle. Stored verbatim, resolved later.
  2. PEP 563 / from __future__ import annotations. This switches every annotation in the module to lazy string form, so even x: int is stored as the string "int". It is the direction modern Python has been moving, which means the string form is increasingly the norm, not the exception.

The resolver is typing.get_type_hints(), and the way it turns a string back into a type is eval() against the owning module’s globals. That is the whole footgun: an annotation is just source code that runs the first time something asks for the resolved hints.

The string form does not run at class-definition time. It runs when the hints are resolved:

import typing

# A forward-reference annotation written as a string. Looks like an ordinary
# "the type isn't defined yet" pattern. Here the string is attacker-influenced.
class Config:
    secret: "__import__('os').system('echo PWNED uid=$(id -u)') or str"

# Definition ran nothing. The payload is sitting inert in __annotations__:
print(Config.__annotations__['secret'])
# __import__('os').system('echo PWNED uid=$(id -u)') or str

typing.get_type_hints(Config)   # <-- eval()s the string here -> the command runs
# PWNED uid=1000
# {'secret': <class 'str'>}

Tested on CPython 3.10 and 3.12. get_type_hints eval’s the string, which executes os.system(...) for its side effect and then returns str (the ... or str tail just hands the resolver a real type so it does not error). The command runs regardless of whether the surrounding type-checking later succeeds.

With from __future__ import annotations the trap is wider, because then you do not even need a string literal. Any annotation expression is captured as a string and deferred:

from __future__ import annotations   # every annotation becomes a lazy string
import typing

class Config:
    token: __import__('os').system('id')   # NOT executed here; stored as text

typing.get_type_hints(Config)              # resolves -> eval -> `id` runs

The eval happens in the defining module’s namespace, so the payload has whatever that module imported already in scope. A module that does import os (almost all of them) hands an annotation-borne payload os for free; from there it is the usual __import__/subprocess/object-graph pivot covered in Walking the Python Object Graph with subclasses().

The reason this matters in practice is that get_type_hints() is called by a lot of code you do not write, often on classes you do not control:

  • Dataclasses, attrs, pydantic, msgspec and similar libraries resolve hints to build their field models.
  • FastAPI resolves the hints on path-operation functions and dependencies to wire up request parsing.
  • Serialization, schema-generation, and documentation tools (anything that introspects “what fields does this class have, and of what type”) walk classes and resolve their annotations.

So the dangerous pattern is any code path where an annotation string is attacker-influenced, generated from user input, templated, or pulled from an untrusted schema, and then handed to one of those resolvers. get_type_hints() is eval wearing a typing-shaped costume. Treat every annotation that can reach it with the same suspicion you would give a raw eval sink, and never build classes or annotations dynamically from untrusted strings. The deeper mechanics of eval/exec as sinks are in Insecure Dynamic Code Evaluation and Execution in Python.

Annotations as the validation boundary in Pydantic and FastAPI

FastAPI and Pydantic are the most important consumers of annotations from a security standpoint. In these frameworks, the annotation on a model field is the validation rule and the coercion rule. field: int does not merely document intent: it tells Pydantic to accept the request, attempt to coerce the incoming value to int, and reject what it cannot. That makes the annotation the actual trust boundary, and several attack patterns follow directly from it:

  • Type coercion surprises. Pydantic will happily coerce "1"1, and in lax modes will coerce values across types in ways an endpoint author did not anticipate (e.g. a string where a bool check was assumed). Coercion that changes the meaning of a value is a classic logic-flaw source.
  • Over-broad annotations. A field typed dict, Any, or a permissive union accepts far more than the developer pictured. Any disables validation entirely for that field.
  • Mass assignment. If a model used for input shares fields with a model used for internal state (role, is_admin, balance), an annotation that exposes those fields lets a client set them. The fix is separate input/output models: but the annotation is where the exposure lives.

When you assess a FastAPI app, read the model annotations first: they are a precise, machine-checked description of what the application will and will not accept, and the gaps between “what the type allows” and “what the logic assumes” are where the bugs are. See FastAPI Pydantic Data Models and Business Logic Vulnerabilities in FastAPI Applications for the exploitation side.

Annotations are not security-relevant because the interpreter enforces them. It doesn’t. They are security-relevant precisely because something else enforces them, and that something is often the only validation standing between a request and the application’s internal state.

Why annotations matter from an offensive security perspective

I stopped thinking of type hints as inert documentation the moment I realized two things hold at once: annotations are introspectable at runtime, and they can be stored as strings that something later feeds to eval. That combination turns a feature meant for IDEs into both a recon channel and a code-execution sink. On the recon side, __annotations__ is a free schema dump of every request model, ORM class, and internal object, telling me which fields exist and which types a framework will coerce. On the execution side, get_type_hints() resolves string annotations by eval-ing them in the defining module’s namespace, so any annotation string I can influence is source code that runs the first time a resolver touches it. get_type_hints() is eval wearing a typing-shaped costume.

When I audit annotations as an attack surface, these are the tells:

  • from __future__ import annotations or PEP 563 in effect. Every annotation in the module becomes a lazy string, widening the trap so even a plain expression is captured and deferred to eval.
  • String / forward-reference annotations that are attacker-influenced. Generated from user input, templated, or pulled from an untrusted schema, then resolved.
  • Any call to typing.get_type_hints(), including the ones you do not write. Dataclasses, attrs, pydantic, msgspec, and FastAPI all resolve hints, so the dangerous eval happens inside library code on classes you may not control.
  • Over-broad or Any annotations on input models. These are the validation boundary in Pydantic and FastAPI; Any disables validation, and a field shared with an internal-state model is a mass-assignment path.
  • The gap between what the type allows and what the logic assumes. Coercion surprises ("1" to 1, lax bool handling) are logic-flaw sources that the annotation alone makes visible.

For defenders the takeaway is that every annotation reachable by a hint resolver should be treated with the same suspicion as a raw eval sink, because for string annotations that is literally what it is.