Skip to main content
  1. Posts/

Python Object-Oriented Programming: Advanced Concepts and Techniques

··2250 words·11 mins·
Table of Contents
Python - This article is part of a series.
Part 2: This Article

Python’s object-oriented model is quietly one of the reasons the language became the default for security tooling. Decorators, ABCs, composition, and properties aren’t academic ceremony; they’re what let a codebase stay readable when it grows past a single-file script into a real tool with tests, plugins, and multiple contributors. This post walks through the advanced OOP features you’ll actually reach for, using worked examples pulled from security work: scanner hierarchies, analyzers, detectors, and instrumentation.

Assumes you’re comfortable with Python’s syntax and basic classes and inheritance already.

Decorators
#

Decorators modify or extend the behavior of a function or method without touching its source. They’re how Python handles cross-cutting concerns like logging, caching, retry logic, and access control without scattering that logic through every function that needs it.

Function decorators
#

A function decorator is a function that takes another function as input and returns a new function wrapping it. A canonical example is timing:

import time

def timing_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} took {end_time - start_time:.5f} seconds to execute.")
        return result

    return wrapper

@timing_decorator
def slow_function():
    time.sleep(1)
    return "I am a slow function."

print(slow_function())

Here, timing_decorator wraps slow_function in additional timing logic without either function knowing about the other.

Method decorators
#

Method decorators work the same way as function decorators; the wrapped callable just happens to be a class method:

import random
import time

class PortScanner:
    @timing_decorator
    def scan_ports(self, host, port_range):
        open_ports = []
        for port in port_range:
            time.sleep(0.1)  # simulate scan latency
            if random.choice([True, False]):
                open_ports.append(port)
        return open_ports

scanner = PortScanner()
open_ports = scanner.scan_ports("192.168.1.1", range(80, 90))
print(f"Open ports: {open_ports}")

The same timing_decorator from earlier applies to scan_ports unmodified. This is a common pattern in security tools: instrument every method that touches the network with timing, retry, or rate-limit logic, without duplicating that logic in every method body.

Class decorators
#

A class decorator takes a class and returns a modified version of it. Here’s one that wraps every method on a class in a shared lock, so no two calls into the object can run concurrently:

import threading

def synchronized_class(cls):
    lock = threading.Lock()

    class SynchronizedClass(cls):
        def __getattribute__(self, name):
            attr = super().__getattribute__(name)
            if callable(attr):
                def wrapper(*args, **kwargs):
                    with lock:
                        return attr(*args, **kwargs)
                return wrapper
            return attr

    return SynchronizedClass

@synchronized_class
class SynchronizedPortScanner(PortScanner):
    pass

sync_scanner = SynchronizedPortScanner()
open_ports = sync_scanner.scan_ports("192.168.1.1", range(80, 90))
print(f"Open ports: {open_ports}")

Note the single lock allocated once per class, in the outer decorator scope. A common mistake here is defining the lock inside the per-attribute wrapper function, which creates a fresh lock on every call and defeats the synchronization entirely.

Inheritance and polymorphism
#

Inheritance lets one class extend another; polymorphism lets code work with any object that provides the right interface, regardless of its concrete type. Together they’re the mechanism that makes “many kinds of scanners” or “many kinds of detectors” a single unified system.

Inheritance
#

A simple hierarchy: a base NetworkScanner class, and a PortScanner subclass that specializes it:

class NetworkScanner:
    def __init__(self, target):
        self.target = target

    def scan(self):
        print(f"Scanning target: {self.target}")

class PortScanner(NetworkScanner):
    def __init__(self, target, port_range):
        super().__init__(target)
        self.port_range = port_range

    def scan(self):
        super().scan()
        print(f"Scanning ports: {self.port_range}")

port_scanner = PortScanner("192.168.1.1", range(80, 90))
port_scanner.scan()

PortScanner inherits NetworkScanner’s constructor via super().__init__(target), then adds its own state and overrides scan() to do something more specific while still calling the parent’s implementation.

Polymorphism
#

Python leans on duck typing rather than declared interfaces: any object with a scan() method can be passed to code that expects to call scan() on it, regardless of what class it came from:

class VulnerabilityScanner(NetworkScanner):
    def __init__(self, target, vulnerabilities):
        super().__init__(target)
        self.vulnerabilities = vulnerabilities

    def scan(self):
        super().scan()
        print(f"Scanning for vulnerabilities: {self.vulnerabilities}")

def start_scanner(scanner):
    scanner.scan()

port_scanner = PortScanner("192.168.1.1", range(80, 90))
vuln_scanner = VulnerabilityScanner("192.168.1.1", ["CVE-2023-1234", "CVE-2023-5678"])

start_scanner(port_scanner)
start_scanner(vuln_scanner)

start_scanner accepts anything with a scan() method. Note that start_scanner is a module-level function, not a method on VulnerabilityScanner, which is a common bug when refactoring polymorphism examples.

Abstract Base Classes
#

Abstract Base Classes (ABCs) formalize the “shared interface” pattern by making it explicit. An ABC can’t be instantiated directly, and any concrete subclass has to implement its abstract methods or it stays abstract too.

import abc

class Scanner(abc.ABC):
    def __init__(self, target):
        self.target = target

    @abc.abstractmethod
    def scan(self):
        pass

class NetworkScanner(Scanner):
    def scan(self):
        print(f"Scanning target: {self.target}")

Any class that inherits from Scanner must implement scan() or it can’t be instantiated. NetworkScanner provides an implementation, so it works:

# Trying to instantiate the abstract class 'Scanner' will raise a TypeError:
# scanner = Scanner("192.168.1.1")
# TypeError: Can't instantiate abstract class Scanner with abstract methods scan

# Instantiating a concrete subclass works:
network_scanner = NetworkScanner("192.168.1.1")
network_scanner.scan()

ABCs earn their weight when you’re building a plugin architecture (every detector plugin must implement a defined interface, or the loader rejects it at import time rather than mysteriously at runtime).

Composition and aggregation
#

Composition and aggregation are the two most common ways one class holds instances of others. The distinction is about ownership: with composition, the container owns its parts and they live and die with it; with aggregation, the container holds references to parts that exist independently.

Composition
#

A PenetrationTester class that constructs and owns its scanner instances:

class PenetrationTester:
    def __init__(self, target):
        self.target = target
        self.network_scanner = NetworkScanner(target)
        self.port_scanner = PortScanner(target, range(80, 90))
        self.vulnerability_scanner = VulnerabilityScanner(target, ["CVE-2023-1234", "CVE-2023-5678"])

    def perform_scan(self):
        print(f"Performing scan on target: {self.target}")
        self.network_scanner.scan()
        self.port_scanner.scan()
        self.vulnerability_scanner.scan()

pen_tester = PenetrationTester("192.168.1.1")
pen_tester.perform_scan()

The scanners come into existence with the PenetrationTester and go away with it. Nothing outside holds references to them.

Aggregation
#

Same shape, but the scanners are constructed elsewhere and injected:

class PenetrationTester:
    def __init__(self, target, network_scanner, port_scanner, vulnerability_scanner):
        self.target = target
        self.network_scanner = network_scanner
        self.port_scanner = port_scanner
        self.vulnerability_scanner = vulnerability_scanner

    def perform_scan(self):
        print(f"Performing scan on target: {self.target}")
        self.network_scanner.scan()
        self.port_scanner.scan()
        self.vulnerability_scanner.scan()

network_scanner = NetworkScanner("192.168.1.1")
port_scanner = PortScanner("192.168.1.1", range(80, 90))
vulnerability_scanner = VulnerabilityScanner("192.168.1.1", ["CVE-2023-1234", "CVE-2023-5678"])

pen_tester = PenetrationTester("192.168.1.1", network_scanner, port_scanner, vulnerability_scanner)
pen_tester.perform_scan()

The scanners can outlive the PenetrationTester, be shared with other objects, or be swapped out for test doubles. That last point is why aggregation is almost always the better default in code you plan to test: injected dependencies are trivially mockable, composed ones aren’t.

Advanced uses of properties
#

Properties in Python let you attach getter, setter, and deleter logic to what looks like an ordinary attribute. Uses include input validation, computed values, and enforcing invariants.

Validation with property setters
#

A Host class that validates its IP address and hostname on assignment:

import ipaddress
import re

class Host:
    def __init__(self, ip_address, hostname):
        self.ip_address = ip_address
        self.hostname = hostname

    @property
    def ip_address(self):
        return self._ip_address

    @ip_address.setter
    def ip_address(self, value):
        try:
            ipaddress.ip_address(value)
        except ValueError:
            raise ValueError("Invalid IP address")
        self._ip_address = value

    @property
    def hostname(self):
        return self._hostname

    @hostname.setter
    def hostname(self, value):
        if not re.match(r"^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$", value):
            raise ValueError("Invalid hostname")
        self._hostname = value

host = Host("192.168.1.1", "example.com")
# Invalid values raise ValueError on assignment:
# host.ip_address = "256.0.0.1"   # ValueError: Invalid IP address
# host.hostname = "!example"      # ValueError: Invalid hostname

The validation runs on every assignment, including the initial one from __init__, so there’s no way to end up with a Host in an invalid state via ordinary attribute access.

Computed properties
#

Properties that derive their value from other attributes rather than storing anything themselves:

class ScanResult:
    def __init__(self, total_ports, open_ports):
        self.total_ports = total_ports
        self.open_ports = open_ports

    @property
    def open_ports_percentage(self):
        return (len(self.open_ports) / self.total_ports) * 100

    @property
    def summary(self):
        return f"{len(self.open_ports)} out of {self.total_ports} ports open ({self.open_ports_percentage:.2f}%)."

scan_result = ScanResult(1000, [80, 443, 8080])
print(scan_result.summary)

open_ports_percentage and summary are computed fresh every time they’re accessed. That’s the point: they can never disagree with total_ports and open_ports.

Enforcing invariants with properties
#

Property setters can enforce conditions that must always hold true for the object’s state:

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

rect = Rectangle(10, 5)

# Invalid values raise ValueError on assignment:
# rect.width = -1  # ValueError: Width must be positive
# rect.height = 0  # ValueError: Height must be positive

The invariant (width and height must be positive) is enforced at every point of mutation, not just at construction time.

Practical code examples
#

Three worked examples that exercise the concepts above in code you’d actually want to build for security engineering: an analyzer for suspicious network connections, a plugin architecture for a detection framework, and a stateful log correlator.

C2 traffic detector (composition + properties)
#

A common defensive task is scoring outbound connections against indicators of C2 (command-and-control) traffic: unusual destinations, beaconing intervals, uncommon protocols. A composed class that pulls in an IOC feed, a beaconing analyzer, and a scoring aggregator:

class IOCFeed:
    """Hydrated from your threat intel platform of choice."""
    def __init__(self, known_bad_ips):
        self._known_bad = set(known_bad_ips)

    def is_known_bad(self, ip):
        return ip in self._known_bad


class BeaconingAnalyzer:
    """Flags near-uniform inter-connection intervals as suspicious."""
    def __init__(self, tolerance_seconds=5):
        self.tolerance = tolerance_seconds

    def looks_like_beacon(self, timestamps):
        if len(timestamps) < 4:
            return False
        intervals = [b - a for a, b in zip(timestamps, timestamps[1:])]
        avg = sum(intervals) / len(intervals)
        return all(abs(i - avg) <= self.tolerance for i in intervals)


class C2TrafficDetector:
    """Composes an IOC feed and a beaconing analyzer."""
    def __init__(self, ioc_feed, beaconing_analyzer):
        self.ioc_feed = ioc_feed
        self.beaconing_analyzer = beaconing_analyzer
        self._observations = []

    def observe(self, ip, timestamp):
        self._observations.append((ip, timestamp))

    @property
    def score(self):
        if not self._observations:
            return 0
        by_ip = {}
        for ip, ts in self._observations:
            by_ip.setdefault(ip, []).append(ts)

        score = 0
        for ip, timestamps in by_ip.items():
            if self.ioc_feed.is_known_bad(ip):
                score += 50
            if self.beaconing_analyzer.looks_like_beacon(sorted(timestamps)):
                score += 25
        return score


detector = C2TrafficDetector(
    ioc_feed=IOCFeed(known_bad_ips={"203.0.113.7"}),
    beaconing_analyzer=BeaconingAnalyzer(tolerance_seconds=3),
)
for ts in [0, 60, 120, 180]:
    detector.observe("198.51.100.5", ts)
detector.observe("203.0.113.7", 200)
print(f"Suspicion score: {detector.score}")

The design uses aggregation deliberately: IOCFeed and BeaconingAnalyzer are constructed externally and injected, so unit tests can swap in fakes without a live IOC feed. score is a computed property because it should reflect the current observations, not a stale snapshot.

Detection plugin framework (ABCs + inheritance)
#

A plugin architecture where every detector plugin conforms to a shared interface enforced by an ABC:

import abc

class Detector(abc.ABC):
    """Base interface every detection plugin must implement."""
    def __init__(self, name):
        self.name = name

    @abc.abstractmethod
    def evaluate(self, event):
        """Return True if the event matches this detector's rule."""
        pass

class KnownBadHashDetector(Detector):
    def __init__(self, hashes):
        super().__init__(name="known-bad-hash")
        self.hashes = set(hashes)

    def evaluate(self, event):
        return event.get("sha256") in self.hashes

class SuspiciousParentDetector(Detector):
    def __init__(self, parent_child_pairs):
        super().__init__(name="suspicious-parent")
        self.pairs = set(parent_child_pairs)

    def evaluate(self, event):
        parent = event.get("parent_process")
        child = event.get("process")
        return (parent, child) in self.pairs


class DetectionEngine:
    def __init__(self, detectors):
        self.detectors = detectors

    def process(self, event):
        matches = [d.name for d in self.detectors if d.evaluate(event)]
        if matches:
            print(f"Alert on event {event.get('id')}: {matches}")


engine = DetectionEngine(detectors=[
    KnownBadHashDetector(hashes={"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}),
    SuspiciousParentDetector(parent_child_pairs={("winword.exe", "powershell.exe")}),
])

engine.process({
    "id": 1,
    "parent_process": "winword.exe",
    "process": "powershell.exe",
    "sha256": "abc123",
})

Adding a new detection type is just writing a new Detector subclass; the engine doesn’t need to know it exists. The ABC guarantees at import time that every plugin implements evaluate, rather than crashing at runtime the first time an event flows through.

SSH auth-log correlator (decorators + composition)
#

A stateful log processor that watches authentication events and flags credential-stuffing patterns (many failures against the same account from a single source):

import time
from collections import defaultdict

def counted(func):
    """Track how many times a method fires. Useful for load metrics on a detector."""
    def wrapper(self, *args, **kwargs):
        self._call_counts[func.__name__] = self._call_counts.get(func.__name__, 0) + 1
        return func(self, *args, **kwargs)
    return wrapper


class AuthLogCorrelator:
    def __init__(self, failure_threshold=10, window_seconds=60):
        self.failure_threshold = failure_threshold
        self.window_seconds = window_seconds
        self._failures = defaultdict(list)  # (source_ip, username) -> [timestamps]
        self._call_counts = {}

    @counted
    def observe_failure(self, source_ip, username, timestamp):
        key = (source_ip, username)
        self._failures[key].append(timestamp)
        # trim entries outside the sliding window
        cutoff = timestamp - self.window_seconds
        self._failures[key] = [t for t in self._failures[key] if t >= cutoff]
        if len(self._failures[key]) >= self.failure_threshold:
            print(f"Alert: {len(self._failures[key])} failures for "
                  f"{username} from {source_ip} in the last {self.window_seconds}s")

    @counted
    def observe_success(self, source_ip, username, timestamp):
        # a successful auth after many failures is a strong indicator
        key = (source_ip, username)
        prior_failures = len(self._failures.get(key, []))
        if prior_failures >= self.failure_threshold:
            print(f"Alert: successful auth for {username} from {source_ip} "
                  f"after {prior_failures} recent failures")
        self._failures.pop(key, None)

    @property
    def call_counts(self):
        return dict(self._call_counts)


correlator = AuthLogCorrelator(failure_threshold=5, window_seconds=60)
now = time.time()
for i in range(6):
    correlator.observe_failure("203.0.113.7", "root", now + i)
correlator.observe_success("203.0.113.7", "root", now + 7)
print(f"Method call counts: {correlator.call_counts}")

The @counted decorator adds instrumentation to every wrapped method without any of them knowing about it, exactly the cross-cutting-concern use case decorators were designed for. call_counts is a computed property that returns a defensive copy of the internal dict, so external code can’t mutate the correlator’s state by accident.

Conclusion
#

Decorators, inheritance, ABCs, composition, aggregation, and properties aren’t just Python language features; they’re the vocabulary that lets a security tool’s codebase grow past a single script into something you’d actually want to maintain. Detection frameworks, analyzers, correlators, and any tool with a plugin architecture all lean on these patterns as their structural backbone.

The specific worked examples above map to concrete security engineering tasks (C2 detection, plugin dispatch, log correlation), but the underlying patterns are what to keep in mind: use ABCs when you need to enforce an interface across multiple implementations, use composition or aggregation for containment (aggregate when you want testability), use properties for validation and computed values so invariants get enforced at the point of assignment rather than checked scattered through the codebase, and use decorators for the cross-cutting stuff that would otherwise get duplicated in every method.

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.
Python - This article is part of a series.
Part 2: This Article