Skip to content

BUG: in-place fixed-width string multiply doesn't do overflow checking #29011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
ngoldbaum opened this issue May 20, 2025 · 19 comments · Fixed by #29060
Closed

BUG: in-place fixed-width string multiply doesn't do overflow checking #29011

ngoldbaum opened this issue May 20, 2025 · 19 comments · Fixed by #29060
Milestone

Comments

@ngoldbaum
Copy link
Member

See https://github.com/numpy/numpy/actions/runs/15138432787/job/42555808050?pr=29007

@charris charris added this to the 2.3.0 release milestone May 20, 2025
@MarcoGorelli
Copy link
Member

I also came across this here: https://github.com/numpy/numpy/actions/runs/15142188944/job/42568906722

I don't have a Mac and don't know where to begin debugging this, @jorenham any ideas?

@jorenham

This comment has been minimized.

@jorenham
Copy link
Member

the segfault happens at line 17 here:
https://github.com/numpy/numpy/actions/runs/15127310682/job/42521616219?pr=29008

@mattip
Copy link
Member

mattip commented May 21, 2025

I wonder if we should move the mypy tests to a different runtime: linux, newer image, ...

@jorenham
Copy link
Member

jorenham commented May 21, 2025

I wonder if we should move the mypy tests to a different runtime: linux, newer image, ...

we're running mypy twice on python 3.11 now (including the mac one), so upgrading one of those makes sense that way I suppose

@charris
Copy link
Member

charris commented May 21, 2025

Here is another, but in masked arrays:https://github.com/numpy/numpy/actions/runs/15168590479/job/42652733379?pr=29018

@seberg
Copy link
Member

seberg commented May 21, 2025

I can reproduce this locally very rarely, before the first success I had installed cython=3.1.1, but that is likely unrelated.

Below is a test.py script that is really just running the same thing outside of pytest, but that makes it slightly easier. I once managed to repro with that via spin python test.py (but not before adding mypy, which means little since happens very rarely).

That gave me the following:

numpy/typing/tests/data/pass/modules.py
python3.11(25181,0x1f9b08840) malloc: Heap corruption detected, free list is damaged at 0x600001211860
*** Incorrect guard value: 476741369967
python3.11(25181,0x1f9b08840) malloc: *** set a breakpoint in malloc_error_break to debug
zsh: abort      spin python test.py

I am trying to reproduce the same in lldb with PYTHONMALLOC=malloc_debug spin lldb -c "exec(open('test.py').read())" and breakpoint set malloc_error_break as the error asked for it, but until now I didn't get any failure :(.

@ngoldbaum I was wondering if you have a sanitizer setup ready that may find something quickly?

EDIT: And yeah, where this triggers seems very random, I saw at least once during polynomial imports also.

test.py
import numpy as np
import os
import re
import importlib
import shutil
from collections import defaultdict

from mypy import api

print(np.__version__)

DATA_DIR = "numpy/typing/tests/data"
PASS_DIR = os.path.join(DATA_DIR, "pass")
FAIL_DIR = os.path.join(DATA_DIR, "fail")
REVEAL_DIR = os.path.join(DATA_DIR, "reveal")
MISC_DIR = os.path.join(DATA_DIR, "misc")
MYPY_INI = os.path.join(DATA_DIR, "mypy.ini")
CACHE_DIR = os.path.join(DATA_DIR, ".mypy_cache")

OUTPUT_MYPY: defaultdict[str, list[str]] = defaultdict(list)

def get_test_cases() -> "Iterator[ParameterSet]":
    for directory in [PASS_DIR]:
        for root, _, files in os.walk(directory):
            for fname in files:
                short_fname, ext = os.path.splitext(fname)
                if ext not in (".pyi", ".py"):
                    continue

                fullpath = os.path.join(root, fname)
                yield fullpath

def _key_func(key: str) -> str:
    """Split at the first occurrence of the ``:`` character.

    Windows drive-letters (*e.g.* ``C:``) are ignored herein.
    """
    drive, tail = os.path.splitdrive(key)
    return os.path.join(drive, tail.split(":", 1)[0])


def _strip_filename(msg: str) -> tuple[int, str]:
    """Strip the filename and line number from a mypy message."""
    _, tail = os.path.splitdrive(msg)
    _, lineno, msg = tail.split(":", 2)
    return int(lineno), msg.strip()


def strip_func(match: re.Match[str]) -> str:
    """`re.sub` helper function for stripping module names."""
    return match.groups()[1]



def run_mypy() -> None:
    """Clears the cache and run mypy before running any of the typing tests.

    The mypy results are cached in `OUTPUT_MYPY` for further use.

    The cache refresh can be skipped using

    NUMPY_TYPING_TEST_CLEAR_CACHE=0 pytest numpy/typing/tests
    """
    if (
        os.path.isdir(CACHE_DIR)
        and bool(os.environ.get("NUMPY_TYPING_TEST_CLEAR_CACHE", True))  # noqa: PLW1508
    ):
        shutil.rmtree(CACHE_DIR)

    split_pattern = re.compile(r"(\s+)?\^(\~+)?")
    for directory in (PASS_DIR, REVEAL_DIR, FAIL_DIR, MISC_DIR):
        # Run mypy
        stdout, stderr, exit_code = api.run([
            "--config-file",
            MYPY_INI,
            "--cache-dir",
            CACHE_DIR,
            directory,
        ])
        if stderr:
            pytest.fail(f"Unexpected mypy standard error\n\n{stderr}", False)
        elif exit_code not in {0, 1}:
            pytest.fail(f"Unexpected mypy exit code: {exit_code}\n\n{stdout}", False)

        str_concat = ""
        filename: str | None = None
        for i in stdout.split("\n"):
            if "note:" in i:
                continue
            if filename is None:
                filename = _key_func(i)

            str_concat += f"{i}\n"
            if split_pattern.match(i) is not None:
                OUTPUT_MYPY[filename].append(str_concat)
                str_concat = ""
                filename = None

run_mypy()
for path in get_test_cases():
    print(path)
    #run_mypy()
    path_without_extension, _ = os.path.splitext(path)
    dirname, filename = path.split(os.sep)[-2:]

    spec = importlib.util.spec_from_file_location(
        f"{dirname}.{filename}", path
    )
    assert spec is not None
    assert spec.loader is not None

    test_module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(test_module)

@seberg
Copy link
Member

seberg commented May 21, 2025

I got a backtrace from lldb after a dozen tries. Can't say it looks enlightening, the issue is a malloc triggered by an array creation. But I still feel the problem is probably much earlier?

Backtrace below (This was without the PYTHONMALLOC env.)

numpy/typing/tests/data/pass/random.py
python3.11(26143,0x1f9b08840) malloc: Heap corruption detected, free list is damaged at 0x600002dde8e0
*** Incorrect guard value: 476741369967
python3.11(26143,0x1f9b08840) malloc: *** set a breakpoint in malloc_error_break to debug
Process 26143 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x000000018ffcf094 libsystem_malloc.dylib`malloc_error_break
libsystem_malloc.dylib`malloc_error_break:
->  0x18ffcf094 <+0>:  pacibsp 
    0x18ffcf098 <+4>:  stp    x29, x30, [sp, #-0x10]!
    0x18ffcf09c <+8>:  mov    x29, sp
    0x18ffcf0a0 <+12>: nop    
Target 0: (python3.11) stopped.
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
  * frame #0: 0x000000018ffcf094 libsystem_malloc.dylib`malloc_error_break
    frame #1: 0x000000018ffadda4 libsystem_malloc.dylib`malloc_vreport + 748
    frame #2: 0x000000018ffd6458 libsystem_malloc.dylib`malloc_zone_error + 100
    frame #3: 0x000000018ffc5774 libsystem_malloc.dylib`nanov2_guard_corruption_detected + 44
    frame #4: 0x000000018ffc5734 libsystem_malloc.dylib`nanov2_allocate_outlined + 460
    frame #5: 0x000000018ffc4940 libsystem_malloc.dylib`nanov2_malloc_type_zero_on_alloc + 488
    frame #6: 0x0000000100e85a34 _multiarray_umath.cpython-311-darwin.so`PyDataMem_UserNEW(size=8, mem_handler=<unavailable>) at alloc.c:401:14 [opt]
    frame #7: 0x0000000100ea0a7c _multiarray_umath.cpython-311-darwin.so`PyArray_NewFromDescr_int(subtype=0x00000001010ac250, descr=0x00000001010a1260, nd=1, dims=<unavailable>, strides=0x0000000000000000, data=0x0000000000000000, flags=0, obj=<unavailable>, base=0x0000000000000000, cflags=<no summary available>) at ctors.c:879:20 [opt]
    frame #8: 0x0000000100ea0dec _multiarray_umath.cpython-311-darwin.so`PyArray_NewFromDescr [inlined] PyArray_NewFromDescrAndBase(subtype=<unavailable>, descr=<unavailable>, nd=<unavailable>, dims=<unavailable>, strides=<unavailable>, data=<unavailable>, flags=<unavailable>, obj=<unavailable>, base=0x0000000000000000) at ctors.c:1022:12 [opt]
    frame #9: 0x0000000100ea0de0 _multiarray_umath.cpython-311-darwin.so`PyArray_NewFromDescr(subtype=<unavailable>, descr=<unavailable>, nd=<unavailable>, dims=<unavailable>, strides=<unavailable>, data=<unavailable>, flags=<unavailable>, obj=<unavailable>) at ctors.c:1007:12 [opt]
    frame #10: 0x0000000100ed6f4c _multiarray_umath.cpython-311-darwin.so`array_subscript(self=<unavailable>, op=<unavailable>) at mapping.c:1569:22 [opt]
    frame #11: 0x0000000100158bc4 python3.11`_PyEval_EvalFrameDefault + 5852
    frame #12: 0x0000000100166f0c python3.11`_PyEval_Vector + 184
    frame #13: 0x0000000100061090 python3.11`PyObject_Vectorcall + 76
    frame #14: 0x0000000100e8f284 _multiarray_umath.cpython-311-darwin.so`dispatcher_vectorcall(self=0x0000000101342eb0, args=<unavailable>, len_args=<unavailable>, kwnames=0x000000011eb40c10) at arrayfunction_override.c:580:18 [opt]
    frame #15: 0x0000000100061090 python3.11`PyObject_Vectorcall + 76
    frame #16: 0x000000014e65a6a0 _generator.cpython-311-darwin.so`__pyx_pf_5numpy_6random_10_generator_9Generator_26choice(__pyx_v_self=0x000000014a6c52a0, __pyx_v_a=<unavailable>, __pyx_v_size=<unavailable>, __pyx_v_replace=0x00000001189d22b0, __pyx_v_p=0x00000001189d2190, __pyx_v_axis=<unavailable>, __pyx_v_shuffle=412950832) at _generator.pyx.c:26695:24 [opt]

@ngoldbaum
Copy link
Member Author

Here's an ASAN report:

=================================================================
==20670==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602002981433 at pc 0x00010613c6bc bp 0x00016b95ac20 sp 0x00016b95a3d0
WRITE of size 3 at 0x602002981433 thread T0
    #0 0x00010613c6b8 in __asan_memcpy+0x4a8 (libclang_rt.asan_osx_dynamic.dylib:arm64+0x506b8)
    #1 0x0001092c3dc0 in Buffer<(ENCODING)0>::buffer_memcpy(Buffer<(ENCODING)0>, unsigned long) string_buffer.h:399
    #2 0x0001092c4eb8 in void string_multiply<(ENCODING)0>(Buffer<(ENCODING)0>, long, Buffer<(ENCODING)0>) string_ufuncs.cpp:184
    #3 0x0001092a28ec in int string_multiply_strint_loop<(ENCODING)0>(PyArrayMethod_Context_tag*, char* const*, long const*, long const*, NpyAuxData_tag*) string_ufuncs.cpp:241
    #4 0x0001092494e8 in execute_ufunc_loop ufunc_object.c:1170
    #5 0x000109242638 in PyUFunc_GenericFunctionInternal ufunc_object.c:2249
    #6 0x00010923cb74 in ufunc_generic_fastcall ufunc_object.c:4541
    #7 0x000109235a30 in ufunc_generic_vectorcall ufunc_object.c:4605
    #8 0x0001054458f8 in object_vacall call.c:819
    #9 0x000105445f38 in PyObject_CallFunctionObjArgs call.c:926
    #10 0x0001090448cc in PyArray_GenericInplaceBinaryFunction number.c:220
    #11 0x000109041344 in array_inplace_multiply number.c:515
    #12 0x00010545e7d4 in wrapperdescr_call descrobject.c:569
    #13 0x000105440acc in _PyObject_MakeTpCall call.c:242
    #14 0x000105728d94 in _PyEval_EvalFrameDefault generated_cases.c.h:1843
    #15 0x000105590e60 in vectorcall_method typeobject.c:2601
    #16 0x00010559fc38 in slot_nb_inplace_multiply typeobject.c:9465
    #17 0x0001054068f8 in PyNumber_InPlaceMultiply abstract.c:1333
    #18 0x0001057307b8 in _PyEval_EvalFrameDefault generated_cases.c.h:132
    #19 0x00010572357c in PyEval_EvalCode ceval.c:604
    #20 0x00010571bc68 in builtin_exec bltinmodule.c.h:556
    #21 0x000105511c54 in cfunction_vectorcall_FASTCALL_KEYWORDS methodobject.c:441
    #22 0x00010573669c in _PyEval_EvalFrameDefault generated_cases.c.h:1355
    #23 0x00010572357c in PyEval_EvalCode ceval.c:604
    #24 0x000105841678 in run_eval_code_obj pythonrun.c:1381
    #25 0x000105840f3c in run_mod pythonrun.c:1466
    #26 0x00010583c1a8 in _PyRun_SimpleFileObject pythonrun.c:517
    #27 0x00010583b764 in _PyRun_AnyFileObject pythonrun.c:77
    #28 0x000105893738 in pymain_run_file main.c:429
    #29 0x000105892150 in Py_RunMain main.c:775
    #30 0x0001058928c8 in pymain_main main.c:805
    #31 0x000105892bcc in Py_BytesMain main.c:829
    #32 0x000182a2ab48  (<unknown module>)

0x602002981433 is located 0 bytes after 3-byte region [0x602002981430,0x602002981433)
allocated by thread T0 here:
    #0 0x00010613f4f0 in malloc+0x70 (libclang_rt.asan_osx_dynamic.dylib:arm64+0x534f0)
    #1 0x000108e34d44 in _npy_alloc_cache alloc.c:139
    #2 0x000108e35294 in default_malloc alloc.c:325
    #3 0x000108e35548 in PyDataMem_UserNEW alloc.c:401
    #4 0x000108eba844 in PyArray_NewFromDescr_int ctors.c:879
    #5 0x000108ebccd0 in PyArray_NewFromDescrAndBase ctors.c:1022
    #6 0x000108ebcc50 in PyArray_NewFromDescr ctors.c:1007
    #7 0x000108ec46ec in PyArray_FromAny_int ctors.c:1679
    #8 0x000108ec6b40 in PyArray_CheckFromAny_int ctors.c:1830
    #9 0x000108ff5ba4 in _array_fromobject_generic multiarraymodule.c:1678
    #10 0x000108fe8908 in array_array multiarraymodule.c:1744
    #11 0x000105511c54 in cfunction_vectorcall_FASTCALL_KEYWORDS methodobject.c:441
    #12 0x00010544220c in PyObject_Vectorcall call.c:327
    #13 0x00010573786c in _PyEval_EvalFrameDefault generated_cases.c.h:1502
    #14 0x000105440670 in _PyObject_VectorcallDictTstate call.c:146
    #15 0x000105443108 in _PyObject_Call_Prepend call.c:504
    #16 0x000105599178 in slot_tp_new typeobject.c:9841
    #17 0x000105584d8c in type_call typeobject.c:1985
    #18 0x000105440acc in _PyObject_MakeTpCall call.c:242
    #19 0x00010573786c in _PyEval_EvalFrameDefault generated_cases.c.h:1502
    #20 0x00010572357c in PyEval_EvalCode ceval.c:604
    #21 0x00010571bc68 in builtin_exec bltinmodule.c.h:556
    #22 0x000105511c54 in cfunction_vectorcall_FASTCALL_KEYWORDS methodobject.c:441
    #23 0x00010573669c in _PyEval_EvalFrameDefault generated_cases.c.h:1355
    #24 0x00010572357c in PyEval_EvalCode ceval.c:604
    #25 0x000105841678 in run_eval_code_obj pythonrun.c:1381
    #26 0x000105840f3c in run_mod pythonrun.c:1466
    #27 0x00010583c1a8 in _PyRun_SimpleFileObject pythonrun.c:517
    #28 0x00010583b764 in _PyRun_AnyFileObject pythonrun.c:77
    #29 0x000105893738 in pymain_run_file main.c:429

SUMMARY: AddressSanitizer: heap-buffer-overflow string_buffer.h:399 in Buffer<(ENCODING)0>::buffer_memcpy(Buffer<(ENCODING)0>, unsigned long)
Shadow bytes around the buggy address:
  0x602002981180: fa fa fd fd fa fa fd fd fa fa fd fa fa fa fd fa
  0x602002981200: fa fa 04 fa fa fa 00 00 fa fa fd fa fa fa fd fa
  0x602002981280: fa fa 00 04 fa fa fd fa fa fa fd fa fa fa fd fd
  0x602002981300: fa fa fd fa fa fa fd fa fa fa fd fa fa fa fd fd
  0x602002981380: fa fa 00 00 fa fa fd fd fa fa fd fa fa fa fd fa
=>0x602002981400: fa fa fd fa fa fa[03]fa fa fa 00 00 fa fa 00 00
  0x602002981480: fa fa fd fa fa fa fd fa fa fa fd fd fa fa fd fa
  0x602002981500: fa fa fd fa fa fa fd fd fa fa fd fa fa fa 00 00
  0x602002981580: fa fa 00 00 fa fa fd fa fa fa fd fa fa fa fd fa
  0x602002981600: fa fa fd fa fa fa fd fa fa fa 00 fa fa fa fd fa
  0x602002981680: fa fa fd fa fa fa 00 00 fa fa fd fa fa fa fd fd
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==20670==ABORTING

So this is an issue in the string ufuncs, of all places!

@ngoldbaum
Copy link
Member Author

Also this happens while the script is processing numpy/typing/tests/data/pass/ma.py.

@ngoldbaum
Copy link
Member Author

Here's the Python traceback from faulthandler:

urrent thread 0x00000001f1c9cc80 (most recent call first):
  File "/Users/goldbaum/Documents/numpy/build-install/usr/lib/python3.13/site-packages/numpy/ma/core.py", line 4461 in __imul__
  File "/Users/goldbaum/Documents/numpy/numpy/typing/tests/data/pass/ma.py", line 152 in <module>
  File "<frozen importlib._bootstrap>", line 488 in _call_with_frames_removed
  File "<frozen importlib._bootstrap_external>", line 1026 in exec_module
  File "/Users/goldbaum/Documents/numpy/../test/test.py", line 115 in <module>

@charris
Copy link
Member

charris commented May 21, 2025

I don't know if it is related, but gcc 15.1.1 warns

../numpy/_core/src/multiarray/stringdtype/casts.cpp:860:12: warning: ‘char* strncat(char*, const char*, size_t)’ specified bound 15 equals source length [-Wstringop-overflow=]
  860 |     strncat(buf, suffix, slen);
      |     ~~~~~~~^~~~~~~~~~~~~~~~~~~

@seberg
Copy link
Member

seberg commented May 22, 2025

The problem is that the ufunc needs to sanity check that the out= has enough space. The way this is hit is because arr = np.array("pirate"); arr *= 2 (in-place multiply).
The ufunc code assumes out= is always prepared correctly, but that is a bit too strong assumption especially in light of in-place behavior.

@ngoldbaum
Copy link
Member Author

ping @lysnikolaou - any chance you have bandwidth to look at this?

@ngoldbaum
Copy link
Member Author

I don't know if it is related, but gcc 15.1.1 warns

That is fixed by #28985.

@ngoldbaum ngoldbaum changed the title TYP/CI: MacOS typing tests are sometimes segfaulting BUG: in-place fixed-width string multiply doesn't do overflow checking May 22, 2025
@ngoldbaum
Copy link
Member Author

Retitled now that we've found the root cause. You can trigger the same issue with this script:

import numpy as np

arr = np.array([b'foo'])

arr *= 2

This doesn't heap-overflow:

import numpy as np

arr = np.array([b'foo'])

arr += b'helloworld'

So maybe we're doing some bound checking elsewhere and just missing it in multiply...

I can add bounds checking to multiply doing something like this:

diff --git a/numpy/_core/src/umath/string_buffer.h b/numpy/_core/src/umath/string_buffer.h
index 554f9ece51..725b789c4e 100644
--- a/numpy/_core/src/umath/string_buffer.h
+++ b/numpy/_core/src/umath/string_buffer.h
@@ -297,6 +297,18 @@ struct Buffer {
         return num_codepoints;
     }
 
+    inline size_t
+    buffer_width()
+    {
+        switch (enc) {
+            case ENCODING::ASCII:
+            case ENCODING::UTF8:
+                return after - buf;
+            case ENCODING::UTF32:
+                return (after - buf) / sizeof(npy_ucs4);
+        }
+    }
+
     inline Buffer<enc>&
     operator+=(npy_int64 rhs)
     {
@@ -396,7 +408,7 @@ struct Buffer {
             case ENCODING::ASCII:
             case ENCODING::UTF8:
                 // for UTF8 we treat n_chars as number of bytes
-                memcpy(other.buf, buf, len);
+                    memcpy(other.buf, buf, len);
                 break;
             case ENCODING::UTF32:
                 memcpy(other.buf, buf, len * sizeof(npy_ucs4));
diff --git a/numpy/_core/src/umath/string_ufuncs.cpp b/numpy/_core/src/umath/string_ufuncs.cpp
index 5b4b67cda6..c6f319f746 100644
--- a/numpy/_core/src/umath/string_ufuncs.cpp
+++ b/numpy/_core/src/umath/string_ufuncs.cpp
@@ -176,13 +176,26 @@ string_multiply(Buffer<enc> buf1, npy_int64 reps, Buffer<enc> out)
     }
 
     if (len1 == 1) {
+        size_t width = out.buffer_width();
+        if (width < reps) {
+            reps = width;
+        }
         out.buffer_memset(*buf1, reps);
         out.buffer_fill_with_zeros_after_index(reps);
     }
     else {
+        size_t filled = 0;
+        size_t width;
         for (npy_int64 i = 0; i < reps; i++) {
+            width = out.buffer_width();
+            if (width < (filled + len1)) {
+                buf1.buffer_memcpy(out, width);
+                out += width;
+                break;
+            }
             buf1.buffer_memcpy(out, len1);
             out += len1;
+            filled += len1;
         }
         out.buffer_fill_with_zeros_after_index(0);
     }

I guess we only need to operate the arithmetic operators? Is there anything problematic besides multiply and add?

@seberg
Copy link
Member

seberg commented May 23, 2025

I guess we only need to operate the arithmetic operators? Is there anything problematic besides multiply and add?

I doubt it, but the thing to look for would be to check if the resolve_descriptor re-uses an existing output dtypes size. add shouldn't do that, so it should be fine. I suspect it really is just multiply that can't (and thus doesn't) infer a safe result dtype.

@jorenham
Copy link
Member

I doubt that many will use in-place string multiplication, as most users will probably be using it in similar ways as builtins.str, which is nice and immutable. So with that in mind, maybe we could get away with just getting rid of *=?

@ngoldbaum
Copy link
Member Author

ngoldbaum commented May 26, 2025

I opened #29060

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
6 participants
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy