Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
608 changes: 608 additions & 0 deletions docs/research/MESHCORE_PARSER_BOUNDS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/research/meshcore-parser-bounds/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
63 changes: 63 additions & 0 deletions docs/research/meshcore-parser-bounds/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# MeshCore parser-bounds harness

The executable half of [`../MESHCORE_PARSER_BOUNDS.md`](../MESHCORE_PARSER_BOUNDS.md).
Read that first — this directory is the evidence, not the argument.

**This is not part of any Attadipa build.** No CMake target references it and
none should. It lives under `docs/` so that it cannot be swept into one by a
recursive glob, and it is kept because the alternative to a runnable harness is
a table of results nobody can re-derive when the upstream revision moves.

## What it does

Compiles four upstream MeshCore translation units — `src/Packet.cpp`,
`src/Dispatcher.cpp`, `src/helpers/AdvertDataHelpers.cpp`, `src/Utils.cpp` —
**unmodified**, at whichever revision you name, and feeds their parsers inputs
that are exactly as long as they claim to be.

Each input ends flush against a `PROT_NONE` guard page and the build is under
AddressSanitizer, so a read at `src[len]` is caught either way. Both mechanisms
are needed: ASan names the source line, and ASan alone **does not report a read
at offset 0 of a `malloc(0)`**, which silently passed two real findings before
the guard page was added.

## The shims are not implementations

`shim/` holds `Arduino.h`, `Stream.h`, `SHA256.h` and `AES.h`. They exist so the
upstream files link on a desktop. **`SHA256` returns zeros and `AES128` copies
its block through.** Neither is a cipher, neither may be used as one, and both
say so in their own headers. Nothing measured here depends on what they compute —
only on how many bytes the code around them moves, which is a property of the
loops and not of the block functions they call.

## Running it

```bash
git clone --filter=blob:none https://github.com/meshcore-dev/MeshCore /tmp/meshcore-src
git -C /tmp/meshcore-src fetch origin 05da523ebd32980a1c28b11f2928d351796b9737
git -C /tmp/meshcore-src fetch origin f80d805ee8b20f77ff5b3ca6bc3a9021989aafd2

./build.sh base d92964352441e53b93e8667b802e04f6e072b39e # the pin
./build.sh pr3267 05da523ebd32980a1c28b11f2928d351796b9737 # PR #3267 head
./build.sh pr3270 f80d805ee8b20f77ff5b3ca6bc3a9021989aafd2 # PR #3270 head
./run.sh

./build-extras.sh
./build/path_arith # P3, exhaustive
./build/decrypt_bounds 180 # P4, faults; 176 is clean
```

`MESHCORE_SRC` overrides the clone location. Output goes to `build/`, which is
ignored. Needs `clang++` with AddressSanitizer; measured on clang 18.1.3 under
Ubuntu 24.04 on 2026-08-23.

## Checking a new revision

That is the point of keeping it. `./build.sh <tag> <ref> && ./run.sh` answers
"does this revision still over-read" in one command, and
[`../MESHCORE_PARSER_BOUNDS.md`](../MESHCORE_PARSER_BOUNDS.md) §5 makes running
it the entry condition for pinning MeshCore into a local provider.

Two findings are **not** in `run.sh`'s matrix and have to be checked separately —
P3 through `path_arith` and P4 through `decrypt_bounds`. A green `run.sh` is not
a clean revision.
38 changes: 38 additions & 0 deletions docs/research/meshcore-parser-bounds/build-extras.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
#
# The two experiments that are not part of the ten-case corpus:
#
# ./build/path_arith finding P3 — every (len, path_len) pair that
# reaches src/Mesh.cpp:161-172, and which of them
# underflow extra_len. Self-contained: an
# extraction of the index arithmetic, not a build
# of the upstream translation unit, and the file
# says so at the top.
#
# ./build/decrypt_bounds N finding P4 — the real src/Utils.cpp compiled
# against a stub block cipher, writing into a
# 184-byte destination backed by a guard page.
# N is src_len; 176 is clean, 177..180 are not.

set -euo pipefail

MESHCORE_SRC="${MESHCORE_SRC:-/tmp/meshcore-src}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
out="$here/build"
tree="$out/tree-base"

mkdir -p "$out"

clang++ -std=c++17 -g -O0 "$here/path_arith.cpp" -o "$out/path_arith"
echo "built build/path_arith"

if [ ! -d "$tree/src" ]; then
echo "build/tree-base is missing — run ./build.sh base <pinned-sha> first" >&2
exit 66
fi

clang++ -std=c++17 -g -O0 -fsanitize=address -fno-omit-frame-pointer \
-I"$tree/src" -I"$here/shim" \
"$here/decrypt_bounds.cpp" "$here/shim/shim.cpp" "$tree/src/Utils.cpp" \
-o "$out/decrypt_bounds"
echo "built build/decrypt_bounds"
64 changes: 64 additions & 0 deletions docs/research/meshcore-parser-bounds/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
#
# Build the parser-bounds harness against one MeshCore revision.
#
# ./build.sh <tag> <git-ref>
# ./build.sh base d92964352441e53b93e8667b802e04f6e072b39e
#
# <tag> names the output; <git-ref> is anything the upstream clone can resolve.
# The sources are exported with `git archive`, so the clone is never modified and
# two revisions can be built side by side.
#
# Nothing here is part of an Attadipa build. See ../MESHCORE_PARSER_BOUNDS.md.

set -euo pipefail

MESHCORE_SRC="${MESHCORE_SRC:-/tmp/meshcore-src}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
out="$here/build"

if [ $# -ne 2 ]; then
echo "usage: $0 <tag> <git-ref>" >&2
exit 64
fi
tag=$1
ref=$2

if [ ! -d "$MESHCORE_SRC/.git" ]; then
cat >&2 <<EOF
No MeshCore clone at $MESHCORE_SRC.

git clone --filter=blob:none https://github.com/meshcore-dev/MeshCore $MESHCORE_SRC

A blobless clone is enough; set MESHCORE_SRC to use one elsewhere. A shallow
clone is not enough — this needs to resolve several revisions, and a pull
request head has to be fetched by SHA before it can be built:

git -C $MESHCORE_SRC fetch origin <sha>
EOF
exit 66
fi

if ! git -C "$MESHCORE_SRC" rev-parse --verify --quiet "$ref^{commit}" >/dev/null; then
echo "$MESHCORE_SRC cannot resolve '$ref' — fetch it first:" >&2
echo " git -C $MESHCORE_SRC fetch origin $ref" >&2
exit 66
fi

tree="$out/tree-$tag"
rm -rf "$tree"
mkdir -p "$tree"
git -C "$MESHCORE_SRC" archive "$ref" src | tar -x -C "$tree"

# Four upstream translation units, unmodified, plus the harness and the shim.
# -O0 so the sanitizer's line numbers name the statement rather than whatever a
# pass hoisted it into; the findings are about bounds, not about codegen.
clang++ -std=c++17 -g -O0 -fsanitize=address -fno-omit-frame-pointer \
-I"$tree/src" -I"$here/shim" \
"$here/harness.cpp" "$here/shim/shim.cpp" \
"$tree/src/Packet.cpp" \
"$tree/src/Dispatcher.cpp" \
"$tree/src/helpers/AdvertDataHelpers.cpp" \
-o "$out/harness-$tag"

echo "built build/harness-$tag from $(git -C "$MESHCORE_SRC" rev-parse "$ref")"
39 changes: 39 additions & 0 deletions docs/research/meshcore-parser-bounds/decrypt_bounds.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Utils::decrypt output bound — Attadipa issue #142, finding P4.
//
// Real upstream translation unit (src/Utils.cpp, unmodified) against a stub
// block cipher. The cipher is irrelevant: what is under test is how far the
// loop at src/Utils.cpp:76-79 walks 'dest' for a src_len that is not a multiple
// of the block size, which is exactly the shape Mesh::onRecvPacket hands it.
//
// dest is 184 bytes — sizeof(uint8_t data[MAX_PACKET_PAYLOAD]) in
// Mesh::onRecvPacket — placed flush against a PROT_NONE page.

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>
#include <Utils.h>

int main(int argc, char** argv)
{
const int src_len = argc > 1 ? atoi(argv[1]) : 180;

const size_t page = (size_t)sysconf(_SC_PAGESIZE);
uint8_t* m = (uint8_t*)mmap(nullptr, page * 2, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
mprotect(m + page, page, PROT_NONE);
uint8_t* dest = m + page - MAX_PACKET_PAYLOAD; // 184 bytes, then the wall

static uint8_t src[512];
static uint8_t key[CIPHER_KEY_SIZE] = {0};
memset(src, 0xAA, sizeof(src));

std::printf("dest = 184 bytes (uint8_t data[MAX_PACKET_PAYLOAD]), src_len = %d\n", src_len);
std::fflush(stdout);

int n = mesh::Utils::decrypt(key, dest, src, src_len);

std::printf("decrypt() returned %d — wrote dest[0..%d]\n", n, n - 1);
return 0;
}
Loading
Loading