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
24 changes: 23 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,36 @@ on:
env:
CARGO_TERM_COLOR: always

permissions:
contents: read

jobs:
publish:
name: Publish
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # required to mint the crates.io OIDC token
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # merge-base needs real history

# A GitHub release can be cut from any commit, including one that never
# landed on main. Publishing is restricted to release tags that are
# actually contained in main.
- name: Refuse releases not contained in main
run: |
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main \
|| { echo "::error::release commit $GITHUB_SHA is not contained in main"; exit 1; }

- uses: dtolnay/rust-toolchain@stable
- run: cargo test

- uses: rust-lang/crates-io-auth-action@v1
id: auth

- run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 0.3.2

Security and ownership release.

### Fixed

- **Unbounded recursion in the DFM reader.** The form stream is untrusted input
carved out of the analyzed binary, and neither the `read_object`/`read_children`
cycle nor `read_value`'s list arm bounded recursion — one nesting byte bought one
stack frame, so a crafted DFM could exhaust the stack and abort the process.
Nesting is now capped at `MAX_DFM_DEPTH` (64), far above any legitimate form.

### Changed

- Recorded ATRAPS LLC as copyright holder and added a `NOTICE` file.
- Dropped the deprecated `authors` field and repointed `repository` at the organisation.
- Refreshed dependencies (`cargo update`).
- Publishing now uses crates.io trusted publishing instead of a stored registry token.

## 0.3.1

Maintenance release. Fixes the crates.io repository link and refreshes the
Expand All @@ -9,7 +28,7 @@ dependency lockfile. No API or behavior changes.

- **crates.io `repository` link** pointed at `BinFlip/delphi` instead of
`BinFlip/undelphi`, so the "Repository" link redirected to the wrong project
([#1](https://github.com/BinFlip/undelphi/issues/1)).
([#1](https://github.com/ATRAPSLLC/undelphi/issues/1)).

### Dependencies

Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
[package]
name = "undelphi"
version = "0.3.1"
version = "0.3.2"
edition = "2024"
authors = ["Johann Kempter <admin@binflip.rs>"]
rust-version = "1.88"
description = "Static analysis library for Delphi / C++Builder / Free Pascal compiled binaries — identification and metadata extraction (RTTI, VMTs, DFM forms, packages)"
license = "Apache-2.0"
repository = "https://github.com/BinFlip/undelphi"
repository = "https://github.com/ATRAPSLLC/undelphi"
readme = "README.md"
keywords = ["delphi", "pascal", "parser", "reverse-engineering", "binary-analysis"]
categories = ["parser-implementations"]
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright 2026 Johann Kempter
Copyright 2026 ATRAPS LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
24 changes: 24 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
undelphi
Copyright 2026 ATRAPS LLC

This product includes software developed by ATRAPS LLC.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

----

This project includes the following third-party software:

Third-party dependencies are listed in Cargo.toml and their licenses
can be found in their respective repositories. All dependencies are
compatible with the Apache 2.0 license.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,7 @@ contemporary laptop.
cargo run --release --example dump -- path/to/binary.exe
```

Licensed under the [Apache License, Version 2.0](LICENSE).
## License

Copyright 2026 ATRAPS LLC. Licensed under the
[Apache License, Version 2.0](LICENSE). See also [`NOTICE`](NOTICE).
55 changes: 46 additions & 9 deletions src/dfm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,27 @@ impl<'a> Cursor<'a> {
}
}

/// Deepest object/value nesting the DFM reader will follow.
///
/// The form stream is untrusted input from the analyzed binary, and both the
/// `read_object` ⇄ `read_children` cycle and `read_value`'s list arm recursed
/// with no bound — one nesting byte bought one stack frame. Real Delphi forms
/// nest a handful of levels.
const MAX_DFM_DEPTH: usize = 64;

fn read_object<'a>(cur: &mut Cursor<'a>, version_is_1: bool) -> Option<DfmObject<'a>> {
read_object_at(cur, version_is_1, 0)
}

/// [`read_object`] carrying the nesting depth.
fn read_object_at<'a>(
cur: &mut Cursor<'a>,
version_is_1: bool,
depth: usize,
) -> Option<DfmObject<'a>> {
if depth >= MAX_DFM_DEPTH {
return None;
}
// Optional prefix byte with high nibble == 0xF0.
let mut flags = FilerFlags::default();
let mut child_pos: Option<i32> = None;
Expand Down Expand Up @@ -693,8 +713,8 @@ fn read_object<'a>(cur: &mut Cursor<'a>, version_is_1: bool) -> Option<DfmObject

let object_name = cur.read_short_string()?;

let properties = read_properties(cur)?;
let children = read_children(cur, version_is_1)?;
let properties = read_properties(cur, depth.saturating_add(1))?;
let children = read_children(cur, version_is_1, depth.saturating_add(1))?;

Some(DfmObject {
flavor: if version_is_1 {
Expand All @@ -712,20 +732,30 @@ fn read_object<'a>(cur: &mut Cursor<'a>, version_is_1: bool) -> Option<DfmObject
})
}

fn read_properties<'a>(cur: &mut Cursor<'a>) -> Option<Vec<DfmProperty<'a>>> {
fn read_properties<'a>(cur: &mut Cursor<'a>, depth: usize) -> Option<Vec<DfmProperty<'a>>> {
if depth >= MAX_DFM_DEPTH {
return None;
}
let mut out = Vec::new();
loop {
let name = cur.read_short_string()?;
if name.is_empty() {
break;
}
let value = read_value(cur)?;
let value = read_value_at(cur, depth.saturating_add(1))?;
out.push(DfmProperty { name, value });
}
Some(out)
}

fn read_children<'a>(cur: &mut Cursor<'a>, version_is_1: bool) -> Option<Vec<DfmObject<'a>>> {
fn read_children<'a>(
cur: &mut Cursor<'a>,
version_is_1: bool,
depth: usize,
) -> Option<Vec<DfmObject<'a>>> {
if depth >= MAX_DFM_DEPTH {
return None;
}
let mut out = Vec::new();
loop {
// Peek to detect the empty-class-name terminator. Because children may
Expand All @@ -742,13 +772,20 @@ fn read_children<'a>(cur: &mut Cursor<'a>, version_is_1: bool) -> Option<Vec<Dfm
None => return None, // truncated stream
_ => {}
}
let child = read_object(cur, version_is_1)?;
let child = read_object_at(cur, version_is_1, depth.saturating_add(1))?;
out.push(child);
}
Some(out)
}

fn read_value<'a>(cur: &mut Cursor<'a>) -> Option<DfmValue<'a>> {
/// Reads one property value, carrying the nesting depth.
///
/// Every call site goes through here; the depth-free entry points are
/// [`read_object`] and, below it, [`read_properties`].
fn read_value_at<'a>(cur: &mut Cursor<'a>, depth: usize) -> Option<DfmValue<'a>> {
if depth >= MAX_DFM_DEPTH {
return None;
}
let tag = cur.read_u8()?;
Some(match ValueType::from_u8(tag) {
ValueType::Null => DfmValue::Null,
Expand Down Expand Up @@ -801,7 +838,7 @@ fn read_value<'a>(cur: &mut Cursor<'a>) -> Option<DfmValue<'a>> {
cur.read_u8();
break;
}
items.push(read_value(cur)?);
items.push(read_value_at(cur, depth.saturating_add(1))?);
}
DfmValue::List(items)
}
Expand Down Expand Up @@ -853,7 +890,7 @@ fn read_value<'a>(cur: &mut Cursor<'a>) -> Option<DfmValue<'a>> {
if list_tag != ValueType::List as u8 {
return None;
}
items.push(read_properties(cur)?);
items.push(read_properties(cur, depth.saturating_add(1))?);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pub fn reconstruct<'a>(bin: &DelphiBinary<'a>, class: &Class<'a>) -> Vec<LayoutE
}
let size = match kind {
LayoutKind::VmtSlot => psize,
LayoutKind::NamedField { managed: _, .. } | LayoutKind::ManagedOnly { .. } => {
LayoutKind::NamedField { .. } | LayoutKind::ManagedOnly { .. } => {
// Without type-size lookup we don't know the exact size of a
// field; conservatively treat it as pointer-sized which is
// the most common case for class-typed fields.
Expand Down
Loading