UV is the hot new tool among Python developers. It addresses a ton of issues in the Python ecosystem, from packaging, project management, tool installation, and virtual environment management. A lot of the tutorials out there are for developers. In most of the roles I’ve worked in information security, I’ve been much more a user or Python than a developer. This post is all about how to use UV to install and run Python applications and scripts.

I released a video showing a lot of this in action, and it’ll remain the more detailed explanation of this tool:

This post is meant to be something that quickly shows those commands, as well as other tricks I learn over time.

UV

What Is It?

uv claims to be:

It comes from Astral, a “Next-Gen Python Tooling” company. This post is going to show how I now use it to replace pipx for installing tools as well as venv for running Python scripts. It’s faster, easier, and can even do things like “install” scripts to make them easily runnable in the future.

Installation

Installing uv is recommended to install using a shell script downloaded from their servers. You can also pip install uv, but I understand that may come with some drawbacks.

Packages / Tools

Overview

Anywhere I used to use pipx I’m now using uv tool. Other than that replacement, the syntax is the same.

Installation Examples

Install package from PyPI (the Python package index):

oxdf@hacky$ uv tool install impacket
Resolved 21 packages in 537ms
Prepared 1 package in 522ms
Installed 21 packages in 20ms
 + blinker==1.9.0
 + cffi==1.17.1
 + charset-normalizer==3.4.1
 + click==8.1.8
 + cryptography==42.0.8
 + dnspython==2.7.0
 + flask==3.1.0
 + impacket==0.12.0
 + itsdangerous==2.2.0
 + jinja2==3.1.6
 + ldap3==2.9.1
 + ldapdomaindump==0.10.0
 + markupsafe==3.0.2
 + pyasn1==0.6.1
 + pyasn1-modules==0.4.2
 + pycparser==2.22
 + pycryptodomex==3.22.0
 + pyopenssl==24.0.0
 + setuptools==80.1.0
 + six==1.17.0
 + werkzeug==3.1.3
Installed 62 executables: DumpNTLMInfo.py, Get-GPPPassword.py, GetADComputers.py, GetADUsers.py, GetLAPSPassword.py, GetNPUsers.py, GetUserSPNs.py, addcomputer.py, atexec.py, changepasswd.py, dacledit.py, dcomexec.py, describeTicket.py, dpapi.py, esentutl.py, exchanger.py, findDelegation.py, getArch.py, getPac.py, getST.py, getTGT.py, goldenPac.py, karmaSMB.py, keylistattack.py, kintercept.py, lookupsid.py, machine_role.py, mimikatz.py, mqtt_check.py, mssqlclient.py, mssqlinstance.py, net.py, netview.py, ntfs-read.py, ntlmrelayx.py, owneredit.py, ping.py, ping6.py, psexec.py, raiseChild.py, rbcd.py, rdp_check.py, reg.py, registry-read.py, rpcdump.py, rpcmap.py, sambaPipe.py, samrdump.py, secretsdump.py, services.py, smbclient.py, smbexec.py, smbserver.py, sniff.py, sniffer.py, split.py, ticketConverter.py, ticketer.py, tstool.py, wmiexec.py, wmipersist.py, wmiquery.py

All of the Impacket tools are now available in my path.

Install from GitHub:

$ uv tool install git+https://github.com/Pennyw0rth/NetExec

Install a specific branch from a GitHub repo:

$ uv tool install git+https://github.com/dirkjanm/BloodHound.py.git@bloodhound-c

Install from a local directory (where it is set up as a package with the legacy setup.py or setup.cfg, or the modern pyproject.toml file):

$ uv tool install .

Updating

uv tool list will show what packages are installed and what binaries are now runnable through those packages:

oxdf@hacky$ uv tool list
black v25.1.0
- black
- blackd
bloodhound-ce v1.8.0
- bloodhound-ce-python
bloodyad v2.1.12
- bloodyAD
certipy-ad v4.8.2
- certipy
flask-unsign v1.2.1
- flask-unsign
git-dumper v1.0.8
- git-dumper
impacket v0.12.0
- DumpNTLMInfo.py
- Get-GPPPassword.py
- GetADComputers.py
- GetADUsers.py
- GetLAPSPassword.py
- GetNPUsers.py
- GetUserSPNs.py
- addcomputer.py
- atexec.py
- changepasswd.py
- dacledit.py
- dcomexec.py
- describeTicket.py
- dpapi.py
- esentutl.py
- exchanger.py
- findDelegation.py
- getArch.py
- getPac.py
- getST.py
- getTGT.py
- goldenPac.py
- karmaSMB.py
- keylistattack.py
- kintercept.py
- lookupsid.py
- machine_role.py
- mimikatz.py
- mqtt_check.py
- mssqlclient.py
- mssqlinstance.py
- net.py
- netview.py
- ntfs-read.py
- ntlmrelayx.py
- owneredit.py
- ping.py
- ping6.py
- psexec.py
- raiseChild.py
- rbcd.py
- rdp_check.py
- reg.py
- registry-read.py
- rpcdump.py
- rpcmap.py
- sambaPipe.py
- samrdump.py
- secretsdump.py
- services.py
- smbclient.py
- smbexec.py
- smbserver.py
- sniff.py
- sniffer.py
- split.py
- ticketConverter.py
- ticketer.py
- tstool.py
- wmiexec.py
- wmipersist.py
- wmiquery.py
ldapdomaindump v0.10.0
- ldapdomaindump
- ldd2bloodhound
- ldd2pretty
netexec v1.4.0+22.6a2874e8
- NetExec
- netexec
- nxc
- nxcdb
sshuttle v1.3.1
- sshuttle

uv tool upgrade --all or uv tool upgrade <package> will update to later version of these packages.

Injection

Sometimes it’s possible that the metadata for a package doesn’t accurately reflect what is needed to run the tool. As of April 2025, certipy-ad didn’t show that it needs the setuptools package (because that used to be globally installed by default), which means after installing it with uv tool install certipy-ad, it would still error out:

oxdf@hacky$ certipy  
Traceback (most recent call last):            
  File "/home/oxdf/.local/bin/certipy", line 4, in <module> 
    from certipy.entry import main
  File "/home/oxdf/.local/share/uv/tools/certipy-ad/lib/python3.12/site-packages/certipy/entry.py", line 6, in <module>
    from certipy import version
  File "/home/oxdf/.local/share/uv/tools/certipy-ad/lib/python3.12/site-packages/certipy/version.py", line 1, in <module>
    import pkg_resources
ModuleNotFoundError: No module named 'pkg_resources'

pkg_resources is part of setuptools, and this issue has been open on Certipy since June 2024.

To fix this, I can run install it with the --with setuptools option:

oxdf@hacky$ uv tool install --with setuptools certipy-ad
Resolved 33 packages in 8ms
Installed 1 package in 14ms
 + setuptools==79.0.0
Installed 1 executable: certipy

Scripts

Running

Overview

A more interesting challenge is running Python scripts that aren’t set up with a package, but may just have a requirements.txt file listing their dependencies outside the Python standard library.

Once Python stopped allowing global package installs, for each script I would have to create a virtual environment, activate that environment, install the dependencies and then run the script. For each time I would later want to run, I’d have to find and activate that venv.

With uv, I’ll add the dependencies to the script with in-line metadata, and then run it.

Add Meta

The first step is to add the requirements to the script using PEP 723 - inline script metadata.

uv add --script <target python script> <package name> will add a single dependency:

oxdf@hacky$ uv add --script targetedKerberoast.py impacket
Updated `targetedKerberoast.py`
oxdf@hacky$ head targetedKerberoast.py 
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "impacket",
# ]
# ///

# -*- coding: utf-8 -*-
# File name          : targetedKerberoast.py

-r <requirements.txt file> will add an entire file:

oxdf@hacky$ uv add --script targetedKerberoast.py -r requirements.txt 
Updated `targetedKerberoast.py`
oxdf@hacky$ head targetedKerberoast.py 
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "impacket",
#     "ldap3",
#     "pyasn1",
#     "pycryptodome",
#     "rich",
# ]

Run

Now run the script with uv run <script>:

oxdf@hacky$ uv run targetedKerberoast.py 
Installed 26 packages in 21ms
usage: targetedKerberoast.py [-h] [-v] [-q] [-D TARGET_DOMAIN] [-U USERS_FILE] [--request-user username] [-o OUTPUT_FILE] [-f {hashcat,john}] [--use-ldaps] [--only-abuse]
                             [--no-abuse] [--dc-host DC_HOST] [--dc-ip ip address] [-d DOMAIN] [-u USER] [-k] [--no-pass | -p PASSWORD | -H [LMHASH:]NTHASH | --aes-key hex key]
targetedKerberoast.py: error: need to set credentials

On the first run, it’ll install the dependencies into a virtual env managed by uv (in 21ms!) and then run. Future runs won’t need the install!

Shebang

I can modify the shebang so that the script will just run with uv run if called directly:

oxdf@hacky$ head targetedKerberoast.py 
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "impacket",
#     "ldap3",
#     "pyasn1",
#     "pycryptodome",
#     "rich",
# ]

Now it runs like a binary:

oxdf@hacky$ chmod +x targetedKerberoast.py 
oxdf@hacky$ ./targetedKerberoast.py 
usage: targetedKerberoast.py [-h] [-v] [-q] [-D TARGET_DOMAIN] [-U USERS_FILE] [--request-user username] [-o OUTPUT_FILE] [-f {hashcat,john}] [--use-ldaps] [--only-abuse]
                             [--no-abuse] [--dc-host DC_HOST] [--dc-ip ip address] [-d DOMAIN] [-u USER] [-k] [--no-pass | -p PASSWORD | -H [LMHASH:]NTHASH | --aes-key hex key]
targetedKerberoast.py: error: need to set credentials

I can even copy this file to somewhere in my path (I like ~/.local/bin) and have it run as a command as if installed (assuming it isn’t going local imports).

Local Package Example

Background

An interesting example is the spring_heapdumper.py script, which is an example for the pyhprof package.

To run spring_heapdumper.py, I’ll need pyhprof installed in the correct virtual environment. But it isn’t on PyPI to just add as a requirement.

image-20250502071850452

Local Run

I’ll clone the repo and try the process above:

oxdf@hacky$ git clone https://github.com/wdahlenburg/pyhprof
Cloning into 'pyhprof'...
remote: Enumerating objects: 142, done.
remote: Counting objects: 100% (142/142), done.
remote: Compressing objects: 100% (53/53), done.
remote: Total 142 (delta 88), reused 141 (delta 88), pack-reused 0 (from 0)
Receiving objects: 100% (142/142), 43.33 KiB | 1.88 MiB/s, done.
Resolving deltas: 100% (88/88), done.
oxdf@hacky$ cd pyhprof/
oxdf@hacky$ uv add --script spring_heapdumper.py -r requirements.txt 
Updated `spring_heapdumper.py`
oxdf@hacky$ uv run spring_heapdumper.py 
usage: spring_heapdumper.py [-h] -f FILENAME [-t1] [-t2]
spring_heapdumper.py: error: the following arguments are required: -f/--filename

It works fine, because when it tries to import from pyhprof.stuff, Python finds that in the current directory:

oxdf@hacky$ head -15 spring_heapdumper.py 
#!/bin/python3
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "hexdump",
#     "trufflehogregexes",
# ]
# ///

from pyhprof.parsers import HProfParser
from pyhprof.references import ReferenceBuilder
import pyhprof
import argparse
import sys

Install

I’ll update the shebang (#!/usr/bin/env -S uv run --script), make it executable (chmod +x spring_heapdumper.py), and copy it to a location in my path (cp spring_heapdumper.py ~/.local/bin/). Now running it fails (as long as the cloned repo isn’t the current directory, which is the point of installing):

oxdf@hacky$ spring_heapdumper.py 
Installed 2 packages in 3ms
Traceback (most recent call last):
  File "/home/oxdf/.local/bin/spring_heapdumper.py", line 10, in <module>
    from pyhprof.parsers import HProfParser
ModuleNotFoundError: No module named 'pyhprof'

There are a couple ways to fix this. To me, the cleanest is to add the GitHub package as a requirement:

oxdf@hacky$ uv add --script spring_heapdumper.py 'pyhprof@git+https://github.com/wdahlenburg/pyhprof'
Updated `spring_heapdumper.py`

This adds both the package and the location of the package to the metadata at the top of the script:

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "hexdump",
#     "pyhprof",
#     "trufflehogregexes",
# ]
#
# [tool.uv.sources]
# pyhprof = { git = "https://github.com/wdahlenburg/pyhprof" }
#
# ///
...[snip]...

After coping this over the previous copy in ~/.local/bin, I can run it from anywhere:

oxdf@hacky$ spring_heapdumper.py 
Installed 1 package in 2ms
usage: spring_heapdumper.py [-h] -f FILENAME [-t1] [-t2]
spring_heapdumper.py: error: the following arguments are required: -f/--filename