0xacb

0xacb

@0xacb.com

Hacker grinding for L1gh7 and Fr33dφm, straight outta the cosmic realm. Co-founder @ethiack.com https://0xacb.com

1.4K followers101 following427 posts

Attackers often exploit new vulnerabilities before defenders can react, sometimes before the vuln even gets a public ID (a CVE). In the first half of 2026, 487 vulnerabilities were listed as exploited. For 137 of them, the exploitation record existed on or before the day the CVE was published.

We built Ethiack Zero to track emerging vulnerabilities from the first public signal (a patch, a GitHub advisory, news, a blog post) and validate actual exploitability before the CVE is officially published. Check it out below👇

Live hardware hacking is coming to @BSidesLisbon. Get ready for the @Ethiack SOHO Smashup on Friday, October 30! Inspired by Pwn2Own, we’re dropping real, aging office equipment right on the floor: • One office router • One printer

The challenge: Hack them live in the lounge area in front of an audience. Get creative with your exploits, AI is fully allowed, and we are accepting any kind of vulnerability. Just visit us in the lounge to schedule your time slot. Goodies await anyone with a successful exploit. See you there 👇

BSides Lisbon 2026 is coming, and @Ethiack is running the CTF! See you on October 29th.

In our KindaRails2Shell research, we reused a signed thumbnail key to process a crafted upload. Active Storage signs the variation_key for the transformation separately from the blob ID. A key from a public thumbnail could be used with the signed ID of our file.

View full thread

The file read exposed secret_key_base. With it, we signed a new variation key containing an instance_eval transformation. In our test, the Vips transformer executed it, turning the file read into RCE.

In our KindaRails2Shell research, we reused a signed thumbnail key to process a crafted upload. Active Storage signs the variation_key for the transformation separately from the blob ID. A key from a public thumbnail could be used with the signed ID of our file.

That made the default representations route send the file to libvips. The returned thumbnail contained bytes from a local path. Resizing would normally change those bytes, so we used a 1×1 image to recover one byte per request.

Rafael Castilho breaks down the discovery and exploitation of a Pre-Auth Remote Code Execution flaw in GeoNetwork. We recently disclosed 4 CVEs on it:

CVE-2026-63219 - Unauthenticated file upload via missing Authorization on formatter Upload Endpoint CVE-2026-55864 - Unauthenticated Server-Side Request Forgery in SLD Tool CVE-2026-57582 - Reflected XSS via unsanitized Javascript Sink CVE-2026-58400 - Remote Code Execution via unsafe Saxon XSLT

Turns out those Unicode dashes are actually useful. Filter blocks "-"? Send full-width "-" instead. Windows converts it back to "-" inside curl.exe, injecting a -o argument that drops a webshell.

Have you looked at localName when testing an HTML filter? A tag name that looks useless as markup can come back from the browser as a transformed string. If an event handler reads that value, a payload the filter never saw as JavaScript may become code later.

In our KindaRails2Shell research, a file labelled image/png can make Rails read /etc/passwd. The trick lies beneath the upload layer. On the direct-upload path, Active Storage can keep the client’s `image/png` label without checking the bytes.

View full thread

HDF5 datasets can reference bytes in another file. When Rails generates a variant, bytes from a local path can come back as image pixels. This chain needs Active Storage with Vips, an untrusted upload path, and a libvips build with MAT support.

In our KindaRails2Shell research, a file labelled image/png can make Rails read /etc/passwd. The trick lies beneath the upload layer. On the direct-upload path, Active Storage can keep the client’s `image/png` label without checking the bytes.

libvips then inspects the file itself. Our crafted file starts with `MATLAB 5.0`, which selects the MAT loader, but a separate version field tells libmatio to read it as MAT 7.3: an HDF5 file.

Some security controls, such as auth checks, rate limiting, WAF rules, and IP allowlisting are often implemented with a gateway. If you're able to discover the origin host and it accepts requests directly, you can bypass all of that by sending your requests straight to the origin.

View full thread

One thing to keep in mind: a MATCH is a similarity score and not proof. Send something the gateway would block and see if the origin serves it. If it doesn't, the origin is filtering by gateway IP, using mTLS, or checking a header the gateway adds.

Some security controls, such as auth checks, rate limiting, WAF rules, and IP allowlisting are often implemented with a gateway. If you're able to discover the origin host and it accepts requests directly, you can bypass all of that by sending your requests straight to the origin.

View full thread

If that doesn't work, there's active recon. There are two ways to do this: via subdomains or via IP addresses with vhosts. To check subdomains, just do your usual subdomain enumeration and then check each subdomain to see if any of them respond the same as the gateway.

To check IP address spaces, you can use hakoriginfinder. It fetches the gateway first, then requests each IP with the Host header set, and scores the responses with Levenshtein distance. prips x.x.x.x/24 | hakoriginfinder -h

api.targetx.comapi.targetx.com

Some security controls, such as auth checks, rate limiting, WAF rules, and IP allowlisting are often implemented with a gateway. If you're able to discover the origin host and it accepts requests directly, you can bypass all of that by sending your requests straight to the origin.

View full thread

First, try searching for it throughout the app. Some common places it pops up: - Error messages - JS source - CORS headers - Cloud storage

Then check records that may outlive the change: - DNS history (SecurityTrails, ViewDNS). If the gateway was put in front of an existing app, the old A record still points at the origin - Certificate Transparency (crt.sh) for hostnames they issued certs for but never linked - Shodan/Censys

Some security controls, such as auth checks, rate limiting, WAF rules, and IP allowlisting are often implemented with a gateway. If you're able to discover the origin host and it accepts requests directly, you can bypass all of that by sending your requests straight to the origin.

Been testing the IDOR trick where you wrap the identifier in an array, in Node JS. You are 123, victim is 124. {"user_id": [124, 123]} The ORM doesn't pick the first element. Sequelize and Mongoose turn it into WHERE id IN (124, 123) and can write both rows.

View full thread

On the read path, findOne returns your own row whatever the order. The issue is on findAll/find, which returns both. Mongo operators like {"$in":[124]} can hit only the victim, but fail every middleware check I tested. Only reachable where the field isn't inspected.

Been testing the IDOR trick where you wrap the identifier in an array, in Node JS. You are 123, victim is 124. {"user_id": [124, 123]} The ORM doesn't pick the first element. Sequelize and Mongoose turn it into WHERE id IN (124, 123) and can write both rows.

View full thread

You don't need a JSON body either. Duplicate params gave me an array in every Express config I tried: user_id=124&user_id=123 -> ["124","123"] They arrive as strings, so includes(123) fails, but some(x => x == 123) still passes and Sequelize still fires: IN ('124','123')

You always write to your own row too. [124] alone would hit only the victim, but the middleware rejects it because your ID isn't there. To get past the check you have to include yourself, and the ORM writes to everything in the list.

Been testing the IDOR trick where you wrap the identifier in an array, in Node JS. You are 123, victim is 124. {"user_id": [124, 123]} The ORM doesn't pick the first element. Sequelize and Mongoose turn it into WHERE id IN (124, 123) and can write both rows.

View full thread

One that surprised me, though it's already known: parseInt([123,124]) === 123 is true. The array becomes the string "123,124" and parseInt stops at the comma. So put your own ID first to get past a parseInt check.

Then it's up to the ORM: Sequelize -> IN (124,123), both rows written Mongoose -> implicit $in, both rows written TypeORM -> throws RangeError Knex -> throws on bind Prisma -> throws, Expected Int, provided (Int, Int)

Been testing the IDOR trick where you wrap the identifier in an array, in Node JS. You are 123, victim is 124. {"user_id": [124, 123]} The ORM doesn't pick the first element. Sequelize and Mongoose turn it into WHERE id IN (124, 123) and can write both rows.

This only works if the middleware checks whether your ID is in the array. These pass: [].concat(id).includes(123) [].concat(id).some(x => x == 123) Strict == and === both reject [124,123]. So does Number().

I was playing with SQL Server and noticed the default collation is case insensitive (SQL_Latin1_General_CP1_CI_AS). admin and ADMIN are the same string to the DB. That part is well known. Less talked about: = also ignores trailing spaces. 'admin' = 'admin␣' is true (␣ is an actual space char)

View full thread

MySQL is case insensitive by default too, but its 8.0+ default doesn't pad: 'admin' = 'admin␣' is false. Older ones like utf8mb4_general_ci still do. Postgres does neither. App assumes one thing, DB does another. This can lead to some weird bugs and bypasses.

Also: MySQL's default is utf8mb4_0900_ai_ci. The ai means accent insensitive, so 'admin' = 'ádmin' is true there. SQL Server defaults to _AS and returns false. Tested on SQL Server 2022 + 2025, MySQL 8.4/9.7/26.7, Postgres 18.6, default configs.

I was playing with SQL Server and noticed the default collation is case insensitive (SQL_Latin1_General_CP1_CI_AS). admin and ADMIN are the same string to the DB. That part is well known. Less talked about: = also ignores trailing spaces. 'admin' = 'admin␣' is true (␣ is an actual space char)

View full thread

Now if someone fixes it like: username.ToLower() == "admin" stops ADMIN, but lets 'admin␣' through. Moving the column to a CS collation also stops ADMIN, and also lets 'admin␣' through. Two different fixes, same half of the bug left open.

Other things I tested: Only the space char. Tab, newline and CR still count Leading spaces still count = and LIKE disagree: 'admin' = 'admin␣' is true, 'admin' LIKE 'admin␣' is false A UNIQUE index under CI rejects ADMIN as a duplicate of admin, so it's a lookup bug not a signup one

I was playing with SQL Server and noticed the default collation is case insensitive (SQL_Latin1_General_CP1_CI_AS). admin and ADMIN are the same string to the DB. That part is well known. Less talked about: = also ignores trailing spaces. 'admin' = 'admin␣' is true (␣ is an actual space char)

I tried CS, BIN, BIN2 and a UTF8 collation. They all pad the space away. Looks like only a VARBINARY cast sees the difference.

So if an app blocks a username in app code: if (username == "admin") deny; <- checks case and spaces WHERE username = @username <- checks neither Send ADMIN, or 'admin␣'. The check doesn't fire and the DB still finds the admin row.

This is how user data can be exposed via prompt injection, explained by @wunderwuzzi23: