Skip to content

Latest commit

 

History

History
executable file
·
1048 lines (765 loc) · 34.4 KB

python-for-hackers-chapter-2-advanced.md

File metadata and controls

executable file
·
1048 lines (765 loc) · 34.4 KB

Installing pip Manually:

To install pip manually, follow these steps:

  1. Download the installation script: get-pip.py
  2. Execute the script to install pip:
    python get-pip.py

This manual installation process is useful in scenarios where pip is not included or needs to be updated independently.


Python Decorators:

Overview:

Python decorators provide a powerful mechanism to modify the behavior of functions without altering their core implementation. They are particularly useful when additional functionalities such as logging, permission verification, or generic checks are required before executing a function.

Implementation:

To create a decorator, a two-tiered function structure is employed:

  1. An outer function that takes a function as an argument.
  2. An inner function that encapsulates the logic to be executed before and after the wrapped function.

Example - Simple Logging Decorator:

from datetime import datetime
import time

def logger(func):
    def wrapper():
        print("-" * 50)
        print("> Execution started {}".format(datetime.today().strftime("%Y-%m-%d %H:%M:%S")))
        func()
        print("> Execution completed {}".format(datetime.today().strftime("%Y-%m-%d %H:%M:%S")))
        print("-" * 50)
    return wrapper

@logger 
def demo_function():
    print("Executing task!")
    time.sleep(2)
    print("Task completed!")

demo_function()

logger(demo_function())

Decorator with Arguments:

Decorators can also accept arguments, allowing for more flexibility. For example, a sleep time can be passed to control the duration of the task execution.

def logger_args(func):
    def wrapper(*args, **kwargs):
        print("-" * 50)
        print("> Execution started {}".format(datetime.today().strftime("%Y-%m-%d %H:%M:%S")))
        func(*args, **kwargs)
        print("> Execution completed {}".format(datetime.today().strftime("%Y-%m-%d %H:%M:%S")))
        print("-" * 50)
    return wrapper

@logger_args
def demo_function_args(sleep_time):
    print("Executing task!")
    time.sleep(sleep_time)
    print("Task completed!")

demo_function_args(2)

Usefulness in Hacking Scripts:

In hacking scripts, decorators can serve various purposes, such as slowing down brute-force attempts or validating token status during automated attacks. They enhance code modularity and readability while providing a mechanism for introducing reusable pre and post-processing steps.


Python Generators:

Overview:

Python generators provide an elegant and memory-efficient way to create iterators, allowing the generation of a sequence of items one at a time. Unlike traditional functions, generators use the yield statement instead of return, enabling the function to pause and retain its state, facilitating the resumption of execution.

Generator Function:

To create a generator, define a function with the yield statement. Unlike a normal function where return terminates execution, yield pauses the function, saving the state and local variable information for future calls. It's important to note that a generator function cannot include a return statement, as it would terminate execution instead of pausing.

Example:

def gen_demo():
    n = 1
    yield n

    n += 1
    yield n

    n += 1
    yield n

test = gen_demo()
print(test)
print(next(test))  # Prints 1
print(next(test))  # Prints 2
print(next(test))  # Prints 3

# Further calls will raise "StopIteration" error
test2 = gen_demo()
for a in test2:
    print(a)

We can create generator function with loops.

def xor_static_key(a):
    key = 0x5
    for i in a:
        yield chr(ord(i) ^ key)

for i in xor_static_key("test"):
    print(i)

Here we used a generator function to perform static xor of the word "test" with the key of 5.

Generator Expressions:

Similar to lambda functions, generator expressions allow the creation of anonymous generators. The syntax involves using () instead of [] brackets.

xor_static_key2 = (chr(ord(i) ^ 0x5) for i in "test")
print(xor_static_key2)
print(next(xor_static_key2))
print(next(xor_static_key2))

for i in xor_static_key2:
    print(i)

Memory Efficiency:

Generators are particularly useful for memory-intensive tasks and processing large files. Due to their lazy execution, generators only produce items when explicitly requested, avoiding the need to compute the entire sequence upfront. This lazy evaluation makes generators suitable for scenarios where memory optimization is crucial.


Serialization in Python:

Overview:

Serialization is the process of translating data structures or objects into a format that can be stored or transmitted, later reconstructed by deserializing the serialized object. This process is essential for preserving the state of something for future use or transmitting data across a network.

Serialization with Pickle:

One way to perform serialization in Python is by using the pickle library. This method is useful for creating a binary format with a consistent structure that Python understands. The serialized data can be later deserialized to reconstruct the original object.

Example:

import pickle

hackers = {"neut": 1, "geohot": 100, "neo": 1000}

# Displaying original data
for key, value in hackers.items():
    print(key, value)

# Serialized version in binary format
serialized = pickle.dumps(hackers)
print(serialized)

# Deserializing the data
hackers_v2 = pickle.loads(serialized)
print(hackers_v2)

# Displaying deserialized data
for key, value in hackers_v2.items():
    print(key, value)

Serialization to File:

Serialized objects can be stored in a file, facilitating the preservation of saved states, such as a game's progress or the current location in a sequence of items during a brute-force attempt.

Example:

# Storing serialized object in a file
with open("hackers.pickle", "wb") as handle:
    pickle.dump(hackers, handle)

# Loading serialized object from the file
with open("hackers.pickle", "rb") as handle:
    hackers_v3 = pickle.load(handle)

print(hackers_v3)

Important Considerations:

  • When serializing data, only attributes of objects are stored, not the objects themselves.
  • Care must be taken during deserialization to avoid potential code injection attacks.
  • Not all data structures are easily pickled; for instance, dictionaries are straightforward, while generators and lambda functions pose challenges due to their operational nature.

Closures in Python:

Overview:

Closures in Python involve the concept of nested functions and local variables. A closure is a function object that remembers values in enclosing scopes, even when the variables are out of scope or removed. Understanding closures requires knowledge of how nested functions and local variables work.

Example:

def print_out(a):
    print("Outer: {}".format(a))
    
    def print_in():
        print("\tInner: {}".format(a))
        
    print_in()

print_out("testing")

Closure and Return:

Consider what happens if instead of calling the inner function directly, we return it from the outer function.

def print_out(a):
    print("Outer: {}".format(a))
    
    def print_in():
        print("\tInner: {}".format(a))
        
    return print_in

test = print_out("testing")
test()

In this example, the returned function is assigned to the variable test. When test is executed as a function, it remembers the value that was originally supplied to the print_out function. This binding of data to code in Python is called a closure.

Closure Characteristics:

  • A closure is a function object.
  • It remembers values in enclosing scopes, even if those variables go out of scope or are deleted.
  • The outer function does not need to exist for the nested function to execute, as the data is already attached to the code.

Example with Deletion:

del print_out
test()
print(print_out("testing"))  # This would result in an error, as the outer function has been deleted.

Even after deleting the outer function, the bound function test can still be called without errors, demonstrating the independence of the closure.

Usefulness of Closures:

Closures are useful when a nested function needs to reference a value in an outer scope. They can be applied to avoid global variables, implement callbacks, or when only a few methods need to be implemented in a Python class. When using closures, ensure that the nested function refers to a value in the outer function, and the outer function returns the nested function.


Object-Oriented Programming (OOP):

Overview:

In the realm of scripting, procedural programming has been the norm—defining data and functions separately. However, as applications grow, maintaining such scripts becomes challenging. Object-Oriented Programming (OOP) offers a structured approach to overcome these limitations, grouping variables and methods into objects and organizing software into reusable blueprints called classes.

OOP Basics:

  • OOP organizes software using classes, reusable templates that group variables (attributes) and methods.
  • Instances of classes are created through instantiation, generating class objects with specific attributes and methods.
  • Each instance is independent, allowing customization and separation of data.

Example - Person Class:

Consider creating a "Person" class:

class Person:
    def __init__(self, name):
        self.name = name

    def say_name(self):
        print(f"My name is {self.name}")

# Instantiate objects
bob = Person("Bob")
alice = Person("Alice")

# Utilize accessible methods and attributes for each instance
bob.say_name()
alice.say_name()

Benefits of OOP:

  1. Code Organization:

    • Grouping related information together makes code shorter and easier to maintain.
    • Reduces complexity, leading to improved readability and maintainability.
  2. Time Efficiency:

    • As applications grow in size and scope, OOP saves time and effort.
  3. Key Concepts:

    • Inheritance: Leverage existing structures.
    • Polymorphism: Enable class-specific behavior.
    • Encapsulation: Secure and protect attributes and methods.
    • Overloading: Extendible and modular.

Conclusion: Object-Oriented Programming provides a powerful paradigm for designing software systems. It enhances maintainability, readability, and scalability, making it an invaluable approach as applications become more complex.


Classes, Objects, and Methods:

Initialization (init) Method:

In a class, the __init__ method is used to initialize an instance of the object. It takes the self argument, a reference to the object itself. This constructor method is automatically invoked whenever a new object of the class is instantiated.

Example - Person Class:

class Person:
    def __init__(self, name, age):
        self.name = name  # instance attributes
        self.age = age

# Create instances of the Person class
bob = Person("Bob", 30)
alice = Person("Alice", 20)
mallory = Person("Mallory", 50)

# Access information about each object
print(bob.name)
print(bob.age)

# Object Manipulation Functions:
print(hasattr(bob, "age"))
print(hasattr(bob, "death"))

print(getattr(bob, "age"))
setattr(bob, "death", 100)
print(getattr(bob, "death"))

delattr(bob, "death")  # Delete attribute
# print(getattr(bob, "death"))  # Raises AttributeError after deletion

Additional Functions in Objects:

You can add additional functions to objects. For instance, a Person2 class with functions to print details and manipulate age.

class Person2:
    def __init__(self, name, age):
        self.name = name  # instance attributes
        self.age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.age))

    def birthday(self):
        self.age += 1

# Object usage
bob = Person2("Bob", 30)
bob.print_name()
bob.print_age()
bob.birthday()
bob.print_age()

Class Attributes:

In addition to per-instance variables, there are class attributes shared by all objects of the class. Changes to class attribute values affect all instances.

class Person3:
    wants_to_hack = True

# Usage
bob = Person3("Bob", 30)
alice = Person3("Alice", 20)
print(Person3.wants_to_hack)
print(bob.wants_to_hack)
Person3.wants_to_hack = "No way!"
print(bob.wants_to_hack)

Deleting Attributes and Special Attributes:

Attributes, objects, or entire classes can be deleted using the del keyword. There are special built-in class attributes associated with all Python classes.

delattr(bob, "name")
# del Person  # Deleting class itself (commented for demonstration)
# print(alice.name)  # Still accessible, even after deleting the class

# Special Class Attributes:
print(Person.__dict__)  # Dictionary containing class attributes
print(Person.__doc__)   # Class documentation string
print(Person.__name__)  # Class name
print(Person.__module__)  # Module in which the class is defined

Modifying Class Attributes( __doc__ ):

class Person:
    "Person base class"
    def __init__(self, name):
        self.name = name

Inheritance

Inheritance is a way of creating a new class by using details of an already existing class without needing to make any changes to the existing class. If classes are similar or have similar functions, you don't need to copy and paste or recreate the entire new code or function; you can derive them from an existing class.

When using inheritance, the new class is referred to as the derived or child class, and the existing class is referred to as the base or parent class.

For example, let's create a parent class Person. We will then create a child class, Hacker, which inherits from the base class Person.

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.age))

    def birthday(self):
        self.age += 1

class Hacker(Person):
    "Child class Hacker (docstring for Hacker)"
    def __init__(self, name, age, cves):
        super().__init__(name, age)  # Importance of super() to initialize the parent class
        self.cves = cves

    def print_name(self):
        print("My name is {} and I have {} CVEs".format(self.name, self.cves))

    def total_cves(self):
        return self.cves

Using Inherited Classes:

Create instances of both the parent and child classes:

bob = Person("Bob", 30)
alice = Hacker("Alice", 21, 5)

Access and manipulate information:

bob.print_name()
alice.print_name()

print(bob.age)
print(alice.age)

bob.birthday()
alice.birthday()
print(bob.age)
print(alice.age)

Access child-specific functions:

print(alice.total_cves())
# print(bob.total_cves()) --> This will throw an error: AttributeError: 'Person' object has no attribute 'total_cves'

Checking Class Relationships:

Useful functions to check class relationships:

print(issubclass(Hacker, Person))  # Returns true if the given class is a subclass of the parent class.
print(issubclass(Person, Hacker))

print(isinstance(bob, Person))  # Returns true if the object is an instance of the class or its subclass.
print(isinstance(bob, Hacker))
print(isinstance(alice, Person))
print(isinstance(alice, Hacker))

Importance of super().__init__(name, age):

In the child class Hacker, the super().__init__(name, age) line is crucial for initializing the parent class (Person). It calls the constructor of the parent class, allowing the child class to inherit and set up the attributes defined in the parent class.

Without this line, the child class might not properly initialize the attributes from the parent class, leading to unexpected behavior and potential errors. super() allows seamless integration of the child and parent class attributes during initialization.

Note that inheritance is a powerful OOP idea that enables code reuse and the creation of more modular classes, avoiding the need to rewrite or copy-paste code.


Encapsulation

Using classes, we can restrict direct access to methods and variables. This idea of restricting access is encapsulation in OOP.

Encapsulation can prevent accidental modification of data by creating private variables and methods that are more difficult to directly change.

For now, we have only used public declarations of public variables and methods, which means we can access and change everything from outside of the class because by default, all methods and variables of a Python class are public.

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.__age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.__age))

    def birthday(self):
        self.__age += 1

bob = Person("age", 30)
print(bob.__age)  # This will throw AttributeError

In order to protect the variable in Python, we can add a double underscore in front of the variable name. Now if we try to access it directly, it will throw an AttributeError. To access this variable, OOP typically uses getter and setter methods.

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.__age = age

    def get_age(self):
        return self.__age

    def set_age(self, age):
        self.__age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.__age))

    def birthday(self):
        self.__age += 1

bob = Person("age", 30)
print(bob.get_age())
bob.set_age(31)
print(bob.get_age())
bob.birthday()
print(bob.get_age())

print(bob.__dict__)  # This reveals the internal structure, including private variables

When using encapsulation with Python, we should be using these getter and setter methods to change and access any private data. However, it's crucial to note that in Python, encapsulation is used to prevent accidental modification to data within a class instance. Still, it shouldn't be relied upon for full security.

For example, a determined individual could use the __dict__ method to see all the variables in the object, revealing private variables and potentially modifying them.

print(bob.__dict__)  # This reveals all variables, even private ones
bob._Person__age = 50  # Directly changing the private variable (not recommended)
print(bob.get_age())

Note that encapsulation is very useful for preventing accidental modifications to data, but it should not be relied upon for security.


Polymorphism

Polymorphism in object-oriented programming (OOP) is the ability to use a common interface for multiple different types. It allows us to use the same function or method even if we pass in different types to that function.

We have already encountered examples of polymorphism in Python:

print(len("string"))
print(len(['l', 'i', 's', 't']))

Here, the len function is polymorphic, as it can work with different types (strings, lists, etc.).

Now, let's explore polymorphism in the context of classes:

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.age))

    def birthday(self):
        self.age += 1

class Hacker(Person):
    "Child class Hacker (docstring for Hacker)"
    def __init__(self, name, age, cves):
        super().__init__(name, age)
        self.cves = cves

    def print_name(self):
        print("My name is {} and I have {} CVEs".format(self.name, self.cves))

    def total_cves(self):
        return self.cves

bob = Person("bob", 30)
alice = Hacker("alice", 25, 10)
people = [bob, alice]

# Polymorphism in action
for person in people:
    person.print_name()
    print(type(person))

print("\n")

def obj_dump(object):
    object.print_name()
    print(object.age)
    object.birthday()
    print(object.age)
    print(object.__class__)
    print(object.__class__.__name__)

obj_dump(bob)
print("\n")
obj_dump(alice)

Output:

My name is bob
<class '__main__.Person'>
My name is alice and I have 10 CVEs
<class '__main__.Hacker'>


My name is bob
30
31
<class '__main__.Person'>
Person


My name is alice and I have 10 CVEs
25
26
<class '__main__.Hacker'>
Hacker

In this example, we see that polymorphism allows us to use the print_name function on different types (instances of Person and Hacker). The function works seamlessly, and the type of the object is determined dynamically at runtime.

Polymorphism in Python enables us to create functions that can take any object as a parameter, making the code more flexible and adaptable to different scenarios. It plays a crucial role in enabling different types or methods to be used in various situations based on specific use cases of those class instances.


Operator Overloading

In Python, operator overloading allows changing the meaning of operators depending on the operands used. We have already seen this with the plus operator, which behaves differently when applied to numbers and strings.

print(1 + 1)
print("1" + "1")

Here, we observe the difference between addition and concatenation. Let's explore how operator overloading works with classes.

Python functions that begin with double underscores (e.g., __init__) are special reserved functions, and there are several built-in Python functions that can be overloaded with our own classes.

For instance, consider what happens by default when we print an instance of the Person class:

bob = Person("bob", 30)
print(bob)

By default, it prints the object's location in memory. However, we can define a __str__ method to control how objects are printed:

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.age))

    def birthday(self):
        self.age += 1

    def __str__(self):
        return "My name is {} and I am {} years old".format(self.name, self.age)

bob = Person("bob", 30)
print(bob)

Now, when we print bob, it displays the string we defined in the __str__ method.

Operator overloading also extends to mathematical operators, including addition or subtraction. After implementation, these overloaded methods control what happens when we compare different objects of the same class.

For example, attempting to add two instances of the Person class initially throws a TypeError. To define how this addition should work, we can overload the __add__ method:

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.age))

    def birthday(self):
        self.age += 1

    def __str__(self):
        return "My name is {} and I am {} years old".format(self.name, self.age)

    def __add__(self, other):
        return self.age + other.age

    def __lt__(self, other):
        return self.age < other.age

bob = Person("bob", 30)
alice = Person("alice", 35)
print(bob + alice)
print(alice + bob)
print(bob < alice)
print(alice < bob)

Here, the __add__ method allows us to sum the ages of different people, and the __lt__ method enables the comparison of ages.

Operator overloading is powerful in Python, enabling the use of built-in operators and methods for classes without the need to define differently named functions for similar purposes. It enhances code readability and conciseness.


Class Decorators

In Python, we have covered classes and decorators separately, and now we are going to combine these components by adding decorators to a class.

class Person:
    "Person base class (docstring for Person)"
    wants_to_hack = True

    def __init__(self, name, age):
        self.name = name
        self.__age = age

    def get_age(self):
        return self.__age

    def set_age(self, age):
        self.__age = age

    @property
    def age(self):
        return self.__age

    @age.setter
    def age(self, age):
        self.__age = age

    @age.deleter
    def age(self):
        del self.__age

    @classmethod
    def wants_to(cls):
        return cls.wants_to_hack

    @classmethod
    def bob_factory(cls):
        return cls("bob", 30)

    @staticmethod
    def static_print():
        print("I am the same!")

    def print_name(self):
        print(f"My name is {self.name}")

    def print_age(self):
        print("My age is {}".format(self.__age))

    def birthday(self):
        self.__age += 1

bob = Person("age", 30)

Explanation:

Property Decorator

  • Definition: The property decorators can be used to declare a method as a property object of the class.
  • Usage: In the class Person, @property is used for the age method. This makes age a property, allowing us to access it like an attribute without invoking the method.
  • Benefit: It provides a way to encapsulate the access to a class attribute, allowing for more controlled attribute access.
print(bob.age)  # This will throw AttributeError without using @property decorator.

Now, we can access the value of the age property, and we can't change it directly without using the setter function.

bob.age = 50
print(bob.age)

# Uncommenting the lines below would result in an error, demonstrating the encapsulation.
# del bob.__age
# print(bob.age)

Class Method Decorator

  • Definition: The @classmethod decorator is a method bound to the class rather than its object. It can only access class method attributes, not wider per-instance attributes.
  • Usage: In the Person class, the wants_to and bob_factory methods are decorated with @classmethod.
  • Benefit: It allows the creation of class-level methods that can be called on the class itself.
print(Person.wants_to())

We can use class methods as a type of factory to create instances of the class.

bob1 = Person.bob_factory()
bob2 = Person.bob_factory()

bob1.print_name()
bob2.print_name()

Static Method Decorator

  • Definition: The @staticmethod decorator lets us define a static method in a class. Static methods can't access class attributes or per-instance attributes.
  • Usage: In the Person class, the static_print method is decorated with @staticmethod.
  • Benefit: It allows the creation of methods that don't need a class instance and can be called by the class or by an instance of the class.
Person.static_print()
bob1.static_print()
bob2.static_print()

We can see the same output for both the class and the instance.

Note:

  • Static methods can be useful when we don't want the subclass to change or override some specific method implementation.
  • Combining decorators and classes enables programmers to create and control more complex classes and object instances by centralizing and reusing code and structures.

The Windows API

Introduction

The Windows API

  • Application Programming Interface (API)

    • A mechanism to interface with the Windows OS.
    • Mostly described by the Microsoft Developer Network (MSDN).
    • Some functions are not officially documented.
  • Components of the Windows API

    • Comprised of functions, structures, and constants.
    • Includes Windows-defined data types.
  • Dynamic Nature

    • The Windows API is not static; it can change and expand between releases of Windows.
    • The official implementation is on your Windows machine, in DLLs (Dynamic Link Libraries).
      • Example DLLs: kernel32.dll, user32.dll, ntdll.dll in the Windows system directory.
  • Python Ctypes Module

    • Enables wrapping Python around C.
    • Allows "Speaking C," facilitating interfacing with the Windows API.

Hackers Focused API Calls

  • Generic Windows API Functions

    • We'll cover some generic Windows API functions.
  • Focus on Hacker-Related API Calls

    • Exploring API calls interesting to hackers.
    • Specific functions: OpenProcess, CreateRemoteThread, WriteProcessMemory from within Python.

C Data Types and Structures:

C primer

This is python-centric notes; however, when interfacing with the Windows API, we need to have an understanding of C.

  • C is a lower-level programming language.
  • C is compiled (and faster), while Python is interpreted (and slower).
  • C requires you to specify the type of data.
  • C has fewer built-in standard functions.
  • C is comparatively more challenging than Python because Python abstracts away more complex components, such as memory management.

In C, we have a concept called pointers and structures.

Pointer and Structs

  • When you create a variable, that variable has a memory address.
  • Pointers are variables that store addresses, not values.
  • Structures (structs) are collections of grouped variables.
  • A structure groups variables under a single type, similar to Python objects.

The ctypes module, included in the library, is a foreign function library for Python.

ctypes provides C-compatible data types, allowing us to call functions in DLLs or shared libraries directly from Python.

So, let's start by working with ctypes data types by importing ctypes.

from ctypes import *

As we know, when declaring variables in Python, we don't need to specify their types. This makes Python scripting quicker and easier but can also cause problems. For example, if we try to concatenate a string and a number, Python can't guess what we're trying to do. In other languages, such as C, we need to specify the type before using variables.

Let's start by creating variables of a ctypes boolean type:

b0 = c_bool(0)
b1 = c_bool(1)

print(b0)
print(type(b0))
print(b0.value)

print(b1)
print(type(b1))
print(b1.value)

We're not limited to booleans; we can also work with unsigned integers, for example:

i0 = c_uint(-1)
print(i0.value)

We can also create strings, which are null-terminated char pointers:

c0 = c_char_p(b"test1")
print(c0.value)

When changing the values of pointer-type instances, we are actually changing the memory location the variable is pointing to, not the actual content of that memory block:

p0 = create_string_buffer(5)
print(p0)
print(p0.raw)
print(p0.value)

p0.value = b"a"
print(p0)
print(p0.raw)
print(p0.value)

Here, we can see the address remains the same, but the value has changed.

In case the function you are working with expects a unicode buffer, you need to use a similar create unicode buffer function.

We only demonstrated a few of the standard data types here. In the official ctypes documentation, you can find an extensive list of C data types available in Python:

Pointer instances are created by calling the pointer function on a ctypes type:

i = c_int(42)
pi = pointer(i)

print(i)
print(pi)
print(pi.contents)

These pointers are useful because sometimes a C function will expect a pointer to a specific data type as an input parameter, known as passing by reference. Passing by reference can be used if the function wants to write to that location or if the data being passed is too large to be passed by its value. To help with interfacing these functions, we can use ctypes' byref or pointer functions to pass parameters by reference:

p0 = create_string_buffer(5)
p0.value = b"a"

pt = byref(p0)
print(pt)

Now that we have this reference, we can also use cast. The cast returns a new instance of the char pointer type, which points to pt and can be used to store the actual data stored at that address. For example:

print(cast(pt, c_char_p).value)
print(cast(pt, POINTER(c_int)).contents)
print(ord('a'))

Now that we have an understanding of some data types, let's move on to structures. Structures are derived from the structure-based class defined within the ctypes module. Each subclass must define a field attribute, and this attribute is a list of tuples containing the field's name and the field's type. For example, we can recreate a "Person" class from the OOP concept covered earlier as a ctypes structure:

class PERSON(Structure):
    """docstring for PERSON"""
    _fields_ = [("name", c_char_p),
                ("age", c_int)]

bob = PERSON(b"bob", 30)
print(bob.name)
print(bob.age)

alice = PERSON(b"alice", 20)
print(alice.name)
print(alice.age)

We already know how to work with lists in Python, but when using a list or an array of ctypes, we need to set up or manage space a little differently. We first need to multiply a data type by the number of elements we want to have in the array. For example, we might want to have a person array:

person_array_t = PERSON * 3
print(person_array_t)

person_array = person_array_t()
person_array[0] = PERSON(b"bob", 30)
person_array[1] = PERSON(b"alice", 20)
person_array[2] = PERSON(b"mallory", 50)

# If we try to add another person to the array, it will throw an error (IndexError: invalid index) because there is not enough space for the 4th person unless we change the type.
# For example:
# person_array[3] = PERSON(b"OHNO", 50)

for person in person_array:
    print(person)
    print(person.age)
    print(person.age)

ctypes is a really useful and powerful library that enables us to use and interface with lower-level C data types directly from within Python. ctypes might be daunting if you haven't worked with a lower-level language before, but the documentation is verbose, and ctypes will enable you to easily work and interface with native code from within Python.