Skip to content

Instantly share code, notes, and snippets.

@avafloww
Created July 30, 2026 23:48
Show Gist options
  • Select an option

  • Save avafloww/7b1559a5aa6b80f40f1c897955ab0541 to your computer and use it in GitHub Desktop.

Select an option

Save avafloww/7b1559a5aa6b80f40f1c897955ab0541 to your computer and use it in GitHub Desktop.
MMD4Mecanim malware removal patch

MMD4Mecanim silently deletes VRChat SDK files

MMD4Mecanim ships an editor routine that deletes files belonging to the VRChat SDK from any Unity project that contains both packages. It runs automatically, it does not ask, it does not check whether you are actually using MMD content with VRChat, and it reports what it did only after the files are already gone.

This repository contains a technical writeup and a script that disables that routine on your own local copy of the assembly.

No part of MMD4Mecanim is redistributed here. The script edits an assembly you already have.


The behaviour

The routine lives in Assets/MMD4Mecanim/Editor/MMD4MecanimEditor.dll, in a class named MMD4MecanimEditorValidator. Decompiled:

[InitializeOnLoad]
public class MMD4MecanimEditorValidator
{
    static MMD4MecanimEditorValidator()
    {
        bool flag = false;
        List<string> list = new List<string>();
        string[] allAssetPaths = AssetDatabase.GetAllAssetPaths();
        foreach (string text in allAssetPaths)
        {
            if (text.Contains("/VRChat/") || text.Contains("\\VRChat\\"))
            {
                flag = true;
                if (text.Contains("/Editor/") || text.Contains("\\Editor\\"))
                {
                    list.Add(text);
                }
            }
        }
        if (_WeakFix(list))
        {
            AssetDatabase.Refresh();
        }
        if (flag)
        {
            // ... four Debug.LogError calls, shown below
        }
    }

    private static bool _WeakFix(List<string> targetList)
    {
        foreach (string target in targetList)
        {
            try
            {
                File.Delete(target);
            }
            catch (Exception)
            {
            }
        }
        return targetList.Count > 0;
    }
}

The messages it prints afterwards:

Use to VRChat is prohibited. Please refer to the Readme for details.
A part of VRChat extensions was deleted. If you need to restore it, please reimport package in other project.
Most models are prohibited to redistribution (or uploading).
If you've uploaded some models, please delete some models.

Why this is worth patching out

The trigger is a substring match on file paths, nothing more. There is no check for whether an MMD model exists in the project, whether one has been assigned to an avatar, or whether anything is being uploaded anywhere. Having both packages installed in one Unity project is the entire condition. Using the same project for VTubing and for VRChat avatars is enough to trigger it.

It is broader than "the VRChat SDK". Any asset path containing a VRChat directory segment and an Editor directory segment is deleted. A folder of your own at Assets/MyStuff/VRChat/Editor/ is caught by the same sweep. The code never looks at what the files are, who authored them, or whether they relate to MMD at all.

It re-runs constantly. [InitializeOnLoad] static constructors execute on editor startup and after every script recompilation. This is not a one-shot check at import time. Reinstalling the SDK gets you a working SDK until the next domain reload, which deletes it again. Without knowing the cause, this presents as an SDK that mysteriously refuses to stay installed.

It fails silently and destroys data. _WeakFix wraps File.Delete in a catch that swallows every exception, so partial failures are invisible. Deletion happens before the console messages are emitted, so by the time you read "a part of VRChat extensions was deleted", it has already happened. There is no prompt, no dry run, and no undo.

The damage is not confined to what it deleted. Deleting a subset of an SDK's editor scripts leaves the remainder uncompilable. A typical first symptom is a wall of unrelated compile errors:

Packages\com.vrchat.base\Editor\VRCSDK\PerceptualPostProcessor.cs(47,14):
    error CS0103: The name 'VRCPackageSettings' does not exist in the current context

Because the project no longer compiles, other editor tooling stops working too, which makes the root cause harder to find.

A license term prohibiting a combination of software is one thing. Enforcing it by deleting another vendor's files off a user's disk, without consent or warning, is a different thing.

What the patch does

The script edits the two string literals the path matcher compares against, in the assembly's user-string heap:

"/VRChat/"  ->  "/VRCh*t/"
"\VRChat\"  ->  "\VRCh*t\"

* is not a legal character in a Windows filename, so the comparison can never match a real asset path again. flag is never set and the deletion list is always empty, so:

  • nothing is deleted,
  • _WeakFix returns false, so no spurious AssetDatabase.Refresh() is triggered,
  • the four console errors stop as well, since they are gated on the same match.

Nothing else changes. MMD4Mecanim's model import, material handling, physics and morph functionality are untouched.

Why this approach

The edit is length-preserving and touches only string content — no IL, no method bodies, no metadata tables, no offsets. The assembly stays structurally valid by construction.

It is also located by content search rather than by hardcoded file offset, so it is not tied to one specific build. The script refuses to modify anything it does not recognise rather than writing to a guessed offset.

On the build verified here it changes exactly two bytes:

0x023323: 61 -> 2a   ('a' -> '*')
0x023335: 61 -> 2a   ('a' -> '*')
Alternative: neutralising the static constructor instead

The static constructor can also be disabled directly by writing ret (0x2A) as the first byte of its IL body. On the build verified here that is file offset 0x44c8, changing 0x16 (ldc.i4.0) to 0x2A. Its method body uses a fat header with no exception-handling clauses, so a leading ret is valid IL.

This works and is a single byte, but it depends on a hardcoded offset into one specific build, which makes it a poor thing to hand to other people. The string edit above is preferred for that reason.

Usage

Close the Unity Editor first. Unity locks loaded editor assemblies, and it will re-run the deletion routine on its next domain reload.

Check whether your copy is affected:

.\Patch-MMD4Mecanim.ps1 -Verify

Apply the patch:

.\Patch-MMD4Mecanim.ps1

Run from anywhere inside your Unity project and the assembly is located automatically, or pass -Path to point at it explicitly:

.\Patch-MMD4Mecanim.ps1 -Path "C:\MyProject\Assets\MMD4Mecanim\Editor\MMD4MecanimEditor.dll"

Preview the edits without writing anything:

.\Patch-MMD4Mecanim.ps1 -WhatIf

Undo it:

.\Patch-MMD4Mecanim.ps1 -Restore

The script backs the original assembly up before its first write, to <ProjectRoot>/MMD4MecanimEditor.dll.prepatch-backup — outside Assets/, so Unity does not import it as a stray asset. It is idempotent, verifies the result by re-reading from disk, and rolls back automatically if verification fails.

If PowerShell blocks the script, either unblock the downloaded file or run it for the current process only:

Unblock-File .\Patch-MMD4Mecanim.ps1
powershell -ExecutionPolicy Bypass -File .\Patch-MMD4Mecanim.ps1

Verifying this yourself

Do not take any of the above on faith. The claims are checkable with a decompiler:

dotnet tool install --global ilspycmd --version 8.2.0.7535
ilspycmd -t MMD4MecanimEditorValidator MMD4MecanimEditor.dll

An unpatched assembly shows the matcher comparing against "/VRChat/" and "\VRChat\". A patched one shows "/VRCh*t/" and "\VRCh*t\". Decompiling before and after the patch and diffing the output is a good way to confirm that nothing else was altered.

Repairing an already-damaged project

The patch stops further deletions; it does not restore what was already removed.

Deleted files are unrecoverable from within Unity, so reinstall the affected packages. For VCC/VPM installs, remove and re-add the package, or delete the package directory and let the resolver restore it. Patch the assembly first — otherwise the next domain reload deletes the freshly restored files again.

To find the damage, look for emptied directories under the SDK:

Get-ChildItem -Path .\Packages -Recurse -Directory | Where-Object { -not (Get-ChildItem $_.FullName -File) }

Scope and limitations

  • Verified against MMD4MecanimEditor.dll, SHA-256 39e1196a95bb6ac933079c0ff3fcd22cfb67d18f528ec685cac88ec3b204f1ee, 183,296 bytes.
  • Other builds are handled by content search, but if a build searches for paths some other way the script will report UNKNOWN and refuse to modify it rather than guessing. Please report those so the script can be updated.
  • The runtime assemblies (Scripts/MMD4Mecanim.dll, Scripts/Internal/MMD4Mecanim.dll) were checked and contain no equivalent routine. Only the editor assembly needs patching.
  • The other three File.Delete call sites in the editor assembly are legitimate (.meta rewriting, a compile lock file, import dependency cleanup) and are left alone.
  • The patch is applied to a file inside Assets/, which is typically not tracked in version control. Updating or reimporting MMD4Mecanim silently reverts it. Re-run -Verify after any update.

A note on scope

This does not bypass any copy protection, license check, or activation mechanism, and it does not enable any prohibited use. The routine it disables does not protect MMD4Mecanim from anything — it only deletes third-party files from the user's disk. The patch stops that and changes nothing else.

Whatever license terms apply to MMD4Mecanim and to any MMD models you use continue to apply, and this patch has no bearing on them. If you do not intend to comply with those terms, the fix is to not use the software — not to patch it.

#Requires -Version 5.1
<#
.SYNOPSIS
Disables the routine in MMD4Mecanim that silently deletes VRChat SDK files.
.DESCRIPTION
MMD4Mecanim ships an [InitializeOnLoad] class, MMD4MecanimEditorValidator, whose
static constructor runs on every Unity domain reload. It enumerates every asset
path in the project and File.Delete()s any path containing both a "VRChat" and an
"Editor" directory segment. It never checks whether MMD content is actually being
used with VRChat -- having both packages in one project is the entire trigger.
This script neutralises that routine by editing the two string literals the path
matcher compares against, inside the assembly's user-string heap:
"/VRChat/" -> "/VRCh*t/"
"\VRChat\" -> "\VRCh*t\"
'*' is not a legal character in a Windows filename, so the comparison can never
match a real asset path again. The deletion list stays empty, nothing is deleted,
and the accompanying console errors stop as well (they are gated on the same match).
The edit is length-preserving and touches no IL, no metadata tables, and no
method bodies, so the assembly stays structurally valid. It is located by content
search rather than by hardcoded file offset, so it is not tied to one build.
This script modifies YOUR OWN local copy of the assembly. It does not redistribute
any part of MMD4Mecanim.
.PARAMETER Path
Path to MMD4MecanimEditor.dll. If omitted, the script searches for it under the
nearest Unity project it can find from the current directory.
.PARAMETER Verify
Report the patch status of the assembly and exit without modifying anything.
.PARAMETER Restore
Restore the assembly from the backup this script created.
.PARAMETER Force
Proceed even if the assembly does not look like a build this script recognises.
Only use this after inspecting the file yourself.
.EXAMPLE
.\Patch-MMD4Mecanim.ps1
Find the assembly automatically and patch it.
.EXAMPLE
.\Patch-MMD4Mecanim.ps1 -Path "C:\MyProject\Assets\MMD4Mecanim\Editor\MMD4MecanimEditor.dll"
.EXAMPLE
.\Patch-MMD4Mecanim.ps1 -Verify
.EXAMPLE
.\Patch-MMD4Mecanim.ps1 -Restore
.NOTES
Close the Unity Editor before running this. Unity holds a lock on loaded editor
assemblies, and it will re-run the deletion routine on its next domain reload.
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Position = 0)]
[string] $Path,
[switch] $Verify,
[switch] $Restore,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
# ISO-8859-1 maps every byte value to the char of the same value, which lets us use
# fast ordinal string search over raw bytes without any lossy round-tripping.
$script:ByteChars = [System.Text.Encoding]::GetEncoding(28591)
# Present in every affected build; used to confirm we are looking at the right code
# before touching anything.
$script:MarkerText = 'A part of VRChat extensions was deleted.'
$script:Patterns = @(
[pscustomobject]@{ Label = 'forward-slash'; Original = '/VRChat/'; Patched = '/VRCh*t/' }
[pscustomobject]@{ Label = 'back-slash'; Original = '\VRChat\'; Patched = '\VRCh*t\' }
)
function Get-Utf16Bytes {
param([string] $Text)
return [System.Text.Encoding]::Unicode.GetBytes($Text)
}
function Find-ByteOffset {
<# Returns every offset at which $Needle occurs in $Bytes. #>
param(
[byte[]] $Bytes,
[byte[]] $Needle
)
# Note: these locals must not be named $haystack/$needle -- PowerShell variable
# names are case-insensitive, so $needle would alias the [byte[]] $Needle
# parameter and fail to coerce a string back into a byte array.
$haystackText = $script:ByteChars.GetString($Bytes)
$needleText = $script:ByteChars.GetString($Needle)
$found = New-Object System.Collections.Generic.List[int]
$at = 0
while ($at -le ($haystackText.Length - $needleText.Length)) {
$idx = $haystackText.IndexOf($needleText, $at, [System.StringComparison]::Ordinal)
if ($idx -lt 0) { break }
$found.Add($idx) | Out-Null
$at = $idx + 1
}
return $found
}
function Get-Sha256 {
param([string] $File)
return (Get-FileHash -Path $File -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Resolve-TargetAssembly {
<# Locate MMD4MecanimEditor.dll by walking up to a Unity project root. #>
$dir = (Get-Location).Path
while ($dir) {
$assets = Join-Path $dir 'Assets'
if (Test-Path -LiteralPath $assets -PathType Container) {
$hits = @(Get-ChildItem -LiteralPath $assets -Recurse -File -Filter 'MMD4MecanimEditor.dll' -ErrorAction SilentlyContinue)
if ($hits.Count -eq 1) { return $hits[0].FullName }
if ($hits.Count -gt 1) {
Write-Host 'Found more than one MMD4MecanimEditor.dll:' -ForegroundColor Yellow
foreach ($h in $hits) { Write-Host " $($h.FullName)" }
throw 'Multiple candidates found. Re-run with -Path to pick one.'
}
}
$parent = Split-Path -Parent $dir
if ($parent -eq $dir) { break }
$dir = $parent
}
throw 'Could not find MMD4MecanimEditor.dll. Run this from inside your Unity project, or pass -Path.'
}
function Get-BackupPath {
<#
Keep backups outside Assets/ where possible so Unity does not import them
as stray assets and generate .meta files for them.
#>
param([string] $Assembly)
$dir = Split-Path -Parent $Assembly
while ($dir) {
if ((Split-Path -Leaf $dir) -eq 'Assets') {
$projectRoot = Split-Path -Parent $dir
if ($projectRoot) {
return (Join-Path $projectRoot 'MMD4MecanimEditor.dll.prepatch-backup')
}
}
$parent = Split-Path -Parent $dir
if ($parent -eq $dir) { break }
$dir = $parent
}
return "$Assembly.prepatch-backup"
}
function Get-PatchState {
<# Classify the assembly as Vulnerable / Patched / Unknown. #>
param([byte[]] $Bytes)
$hasMarker = (Find-ByteOffset -Bytes $Bytes -Needle (Get-Utf16Bytes $script:MarkerText)).Count -gt 0
$original = 0
$patched = 0
foreach ($p in $script:Patterns) {
$original += (Find-ByteOffset -Bytes $Bytes -Needle (Get-Utf16Bytes $p.Original)).Count
$patched += (Find-ByteOffset -Bytes $Bytes -Needle (Get-Utf16Bytes $p.Patched)).Count
}
$state = 'Unknown'
if ($original -eq $script:Patterns.Count -and $patched -eq 0) { $state = 'Vulnerable' }
elseif ($patched -eq $script:Patterns.Count -and $original -eq 0) { $state = 'Patched' }
return [pscustomobject]@{
State = $state
HasMarker = $hasMarker
OriginalMatches = $original
PatchedMatches = $patched
}
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if (-not $Path) { $Path = Resolve-TargetAssembly }
$Path = (Resolve-Path -LiteralPath $Path).Path
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Not a file: $Path"
}
$backup = Get-BackupPath -Assembly $Path
Write-Host ''
Write-Host 'MMD4Mecanim VRChat-deletion patch' -ForegroundColor Cyan
Write-Host " assembly : $Path"
Write-Host " sha256 : $(Get-Sha256 $Path)"
Write-Host " backup : $backup"
Write-Host ''
# --- Restore -------------------------------------------------------------
if ($Restore) {
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) {
throw "No backup found at: $backup"
}
if ($PSCmdlet.ShouldProcess($Path, 'Restore from backup')) {
Copy-Item -LiteralPath $backup -Destination $Path -Force
Write-Host 'Restored the original assembly.' -ForegroundColor Green
Write-Host " sha256 : $(Get-Sha256 $Path)"
Write-Host ''
Write-Warning 'The deletion routine is active again. It will run on Unity''s next domain reload.'
}
return
}
$bytes = [System.IO.File]::ReadAllBytes($Path)
$status = Get-PatchState -Bytes $bytes
# --- Verify --------------------------------------------------------------
if ($Verify) {
switch ($status.State) {
'Patched' {
Write-Host 'Status: PATCHED -- the path matcher can no longer match a real asset path.' -ForegroundColor Green
}
'Vulnerable' {
Write-Host 'Status: VULNERABLE -- this assembly will delete VRChat SDK files.' -ForegroundColor Red
Write-Host 'Run this script without -Verify to patch it.'
}
default {
Write-Host 'Status: UNKNOWN -- this does not match a build this script recognises.' -ForegroundColor Yellow
Write-Host " marker string present : $($status.HasMarker)"
Write-Host " unpatched literals : $($status.OriginalMatches) (expected $($script:Patterns.Count))"
Write-Host " patched literals : $($status.PatchedMatches)"
}
}
Write-Host ''
return
}
# --- Patch ---------------------------------------------------------------
if ($status.State -eq 'Patched') {
Write-Host 'Already patched. Nothing to do.' -ForegroundColor Green
Write-Host ''
return
}
if ($status.State -ne 'Vulnerable') {
Write-Host 'This assembly does not match a build this script recognises.' -ForegroundColor Yellow
Write-Host " marker string present : $($status.HasMarker)"
Write-Host " unpatched literals : $($status.OriginalMatches) (expected $($script:Patterns.Count))"
Write-Host " patched literals : $($status.PatchedMatches)"
Write-Host ''
if (-not $Force) {
Write-Host 'Refusing to modify it. Inspect the file first, then re-run with -Force if you are sure.'
Write-Host 'Please also report this build so the script can be updated.'
Write-Host ''
return
}
Write-Warning 'Proceeding anyway because -Force was supplied.'
}
if (-not $status.HasMarker -and -not $Force) {
Write-Host 'The expected marker string was not found; this may not be MMD4Mecanim.' -ForegroundColor Yellow
Write-Host 'Refusing to modify it. Re-run with -Force to override.'
Write-Host ''
return
}
# Collect the edits before applying any of them, so a surprise aborts cleanly.
$edits = New-Object System.Collections.Generic.List[object]
foreach ($p in $script:Patterns) {
$originalBytes = Get-Utf16Bytes $p.Original
$patchedBytes = Get-Utf16Bytes $p.Patched
if ($originalBytes.Length -ne $patchedBytes.Length) {
throw "Internal error: replacement for '$($p.Original)' changes length."
}
foreach ($offset in (Find-ByteOffset -Bytes $bytes -Needle $originalBytes)) {
$edits.Add([pscustomobject]@{
Offset = $offset
Label = $p.Label
From = $p.Original
To = $p.Patched
Payload = $patchedBytes
}) | Out-Null
}
}
if ($edits.Count -eq 0) {
throw 'No matcher literals found to patch. Nothing was modified.'
}
Write-Host 'Planned edits:'
foreach ($e in $edits) {
Write-Host (" 0x{0:x6} {1,-14} '{2}' -> '{3}'" -f $e.Offset, $e.Label, $e.From, $e.To)
}
Write-Host ''
if (-not $PSCmdlet.ShouldProcess($Path, "Apply $($edits.Count) edit(s)")) { return }
if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) {
Copy-Item -LiteralPath $Path -Destination $backup -Force
Write-Host "Backed up the original to: $backup" -ForegroundColor Green
} else {
Write-Host "Keeping the existing backup at: $backup" -ForegroundColor Yellow
}
foreach ($e in $edits) {
[Array]::Copy($e.Payload, 0, $bytes, $e.Offset, $e.Payload.Length)
}
try {
[System.IO.File]::WriteAllBytes($Path, $bytes)
} catch [System.IO.IOException] {
Write-Host ''
Write-Host 'Could not write to the assembly.' -ForegroundColor Red
Write-Host 'Close the Unity Editor (it locks loaded editor assemblies) and run this again.'
throw
}
# Re-read from disk and confirm rather than trusting the in-memory buffer.
$after = Get-PatchState -Bytes ([System.IO.File]::ReadAllBytes($Path))
Write-Host ''
if ($after.State -eq 'Patched') {
Write-Host 'Patched successfully.' -ForegroundColor Green
Write-Host " sha256 : $(Get-Sha256 $Path)"
Write-Host ''
Write-Host 'MMD4Mecanim will no longer delete VRChat SDK files. Its import functionality is unaffected.'
Write-Host 'If your SDK was already damaged, reinstall it now -- it is safe to do so.'
} else {
Write-Host 'Verification after writing FAILED. Restoring the backup.' -ForegroundColor Red
Copy-Item -LiteralPath $backup -Destination $Path -Force
throw 'Patch verification failed; the original assembly has been restored.'
}
Write-Host ''
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment