Skip to content

fix(remote): make Ctrl-C interrupt blocking getaddrinfo() (#2540)#2728

Open
ChrisJr404 wants to merge 1 commit into
Gallopsled:devfrom
ChrisJr404:feature/remote-resolve-ctrl-c
Open

fix(remote): make Ctrl-C interrupt blocking getaddrinfo() (#2540)#2728
ChrisJr404 wants to merge 1 commit into
Gallopsled:devfrom
ChrisJr404:feature/remote-resolve-ctrl-c

Conversation

@ChrisJr404

Copy link
Copy Markdown
Contributor

Closes #2540.

What

pwnlib.tubes.remote.remote(host, port) calls socket.getaddrinfo() to resolve host. On glibc, getaddrinfo holds the GIL during the libc DNS round-trip and swallows SIGINT, so a hung resolver leaves the script unkillable from Ctrl-C until the kernel-level resolver timeout (often ~30s).

This PR follows the workaround @Arusekk suggested in the issue thread: swap the SIGINT handler to SIG_DFL for the duration of the resolution call, then restore the original handler.

@contextlib.contextmanager
def _interruptible_block():
    if threading.current_thread() is not threading.main_thread():
        yield
        return
    try:
        old = signal.signal(signal.SIGINT, signal.SIG_DFL)
    except ValueError:
        yield
        return
    try:
        yield
    finally:
        signal.signal(signal.SIGINT, old)

remote._connect now wraps the socket.getaddrinfo(...) call in this context.

Why

Quoted from the issue's main reporter (@tesuji):

I would like to use ctrl-C to quit immediately in these cases.

And from @Arusekk:

Maybe we can set SIGINT handler to SIG_DFL as a workaround?

That's exactly what this does, scoped narrowly to the resolver call so it doesn't perturb anything else.

Tests

  • Existing remote() round-trip against a local listen() socket still passes (resolution to a literal IP is instantaneous).
  • _interruptible_block swap+restore verified directly: signal.getsignal(SIGINT) is SIG_DFL inside, equal to the original handler outside.
  • On a non-main thread the context is a no-op (signal handlers can only be changed from the main thread, so we don't try).

Notes

  • Pure-additive: no existing call signature changes. Anyone who wants to keep their custom SIGINT handler around their own getaddrinfo can call it directly without going through remote().
  • The fix only covers pwnlib.tubes.remote.remote._connect; if a similar hang ever shows up in another tube's resolver call it can reuse _interruptible_block (or be lifted into a shared module).

…#2540)

socket.getaddrinfo() holds the GIL during the libc DNS round-trip and on
glibc swallows SIGINT, so a hung resolver leaves remote() unkillable
until the kernel timeout. Per @Arusekk's suggestion in Gallopsled#2540, swap SIGINT
to SIG_DFL only for the duration of the resolution call, then restore
the previous handler. Restricted to the main thread (where signal
handlers are settable); a no-op everywhere else.

Closes Gallopsled#2540
@peace-maker

Copy link
Copy Markdown
Member

Thanks for this! This is a cleaner solution than #2541 if it works as well. @k4lizen can you try this too with your flakey domains please?

@k4lizen

k4lizen commented May 10, 2026

Copy link
Copy Markdown
Contributor

i can confirm it fixes the issue for me

@peace-maker

Copy link
Copy Markdown
Member

This global signal handler doesn't cause trouble in asyncio since we don't await I guess. Thank you for testing @k4lizen and thank you for your resources @ChrisJr404

How could we test this? Configure some DNS server that doesn't exist and abort a query?

@Arusekk

Arusekk commented May 14, 2026

Copy link
Copy Markdown
Member

I can confirm that this works for me, too.

The simplest way to test it is echo nameserver x.y.z.w > /etc/resolv.conf for any x.y.z.w that ignores udp53 packets. You can even try a random routable address, good chance it will hit some CGNAT and get dropped.

Is there any official account of this problem? Any upstream bug report that we can link?

@peace-maker

Copy link
Copy Markdown
Member

Is there any official account of this problem? Any upstream bug report that we can link?

https://bugs.python.org/issue22889

@peace-maker

Copy link
Copy Markdown
Member

This solution kills the python process without triggering the cleanup routines.

from pwn import *
try:
  io = remote('notexisting.lol', 2451)
  io.interactive()
finally:
  print('DONE!!')

Replacing the target nameserver in resolv.conf with some random IP not hosting a dns server stalls the DNS resolution. Pressing Ctrl+C once immediately stops the python process but doesn't print DONE!! nor any other interrupt trace.

The other solution in #2541 using asyncio in a new thread allows to abort the dns resolution somewhat while running the cleanup code. It requires three Ctrl+C presses before the process stops though, so it's not perfect.

But I think allowing graceful shutdown should be desirable, so this solution feels too disruptive. @Arusekk What do you think?

[-] Opening connection to notexisting.lol on port 2451: Failed
DONE!!
Traceback (most recent call last):
  File "/home/peace/tools/pwntools/pwnlib/tubes/remote.py", line 131, in sync_getaddrinfo
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/concurrent/futures/_base.py", line 451, in result
    self._condition.wait(timeout)
  File "/usr/lib/python3.12/threading.py", line 355, in wait
    waiter.acquire()
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/peace/tools/pwntools/testdns.py", line 4, in <module>
    io = remote('notexisting.lol', 2451)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/peace/tools/pwntools/pwnlib/tubes/remote.py", line 84, in __init__
    self.sock   = self._connect(fam, typ)
                  ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/peace/tools/pwntools/pwnlib/tubes/remote.py", line 138, in _connect
    hostnames = sync_getaddrinfo(self.rhost, self.rport, fam, typ, 0, socket.AI_PASSIVE)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/peace/tools/pwntools/pwnlib/tubes/remote.py", line 128, in sync_getaddrinfo
    with concurrent.futures.ThreadPoolExecutor() as executor:
  File "/usr/lib/python3.12/concurrent/futures/_base.py", line 647, in __exit__
    self.shutdown(wait=True)
  File "/usr/lib/python3.12/concurrent/futures/thread.py", line 238, in shutdown
    t.join()
  File "/usr/lib/python3.12/threading.py", line 1147, in join
    self._wait_for_tstate_lock()
  File "/usr/lib/python3.12/threading.py", line 1167, in _wait_for_tstate_lock
    if lock.acquire(block, timeout):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt
^CException ignored in: <module 'threading' from '/usr/lib/python3.12/threading.py'>
Traceback (most recent call last):
  File "/usr/lib/python3.12/threading.py", line 1592, in _shutdown
    atexit_call()
  File "/usr/lib/python3.12/concurrent/futures/thread.py", line 31, in _python_exit
    t.join()
  File "/usr/lib/python3.12/threading.py", line 1147, in join
    self._wait_for_tstate_lock()
  File "/usr/lib/python3.12/threading.py", line 1167, in _wait_for_tstate_lock
    if lock.acquire(block, timeout):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Script stuck (cannot ctrl-C to quit) when DNS resolution hang (using remote())

4 participants