From d8905f44c8f37d0b326a294bdb0723c9d799dea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E5=96=B5=E5=96=B5=E5=96=B5=E5=96=B5?= <3299332656@qq.com> Date: Thu, 9 Jul 2026 15:45:51 +0800 Subject: [PATCH 1/3] fix: preserve short utf8 fields in proto parser --- .../main/java/moe/ono/util/FunProtoData.java | 194 +++++++++++++++--- 1 file changed, 168 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/moe/ono/util/FunProtoData.java b/app/src/main/java/moe/ono/util/FunProtoData.java index 339deb7d..97fc6548 100644 --- a/app/src/main/java/moe/ono/util/FunProtoData.java +++ b/app/src/main/java/moe/ono/util/FunProtoData.java @@ -8,6 +8,9 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -17,8 +20,37 @@ public class FunProtoData { + private static final int MAX_ACCIDENTAL_TEXT_BYTES = 64; private final HashMap> values = new HashMap<>(); + private static final class Fixed32Value { + private final int value; + + private Fixed32Value(int value) { + this.value = value; + } + } + + private static final class Fixed64Value { + private final long value; + + private Fixed64Value(long value) { + this.value = value; + } + } + + private static final class ProtoProbe { + private final boolean valid; + private final int fieldCount; + private final boolean hasLengthDelimited; + + private ProtoProbe(boolean valid, int fieldCount, boolean hasLengthDelimited) { + this.valid = valid; + this.fieldCount = fieldCount; + this.hasLengthDelimited = hasLengthDelimited; + } + } + public static byte[] getUnpPackage(byte[] b) { if (b == null) { return null; @@ -67,8 +99,11 @@ private void putValue(int key, Object value){ } public void fromBytes(byte[] b) throws IOException { CodedInputStream in = CodedInputStream.newInstance(b); - while (in.getBytesUntilLimit() > 0) { + while (!in.isAtEnd()) { int tag = in.readTag(); + if (tag == 0) { + break; + } int fieldNumber = tag >>> 3; int wireType = tag & 7; if (wireType == 4 || wireType == 3 || wireType > 5) throw new IOException("Unexpected wireType: " + wireType); @@ -77,31 +112,15 @@ public void fromBytes(byte[] b) throws IOException { putValue(fieldNumber, in.readInt64()); break; case 1: - putValue(fieldNumber, in.readRawVarint64()); + putValue(fieldNumber, new Fixed64Value(in.readFixed64())); break; case 2: { byte[] subBytes = in.readByteArray(); - try { - FunProtoData sub_data = new FunProtoData(); - sub_data.fromBytes(subBytes); - putValue(fieldNumber, sub_data); - } catch (Exception e) { - try { - String decoded = new String(subBytes, StandardCharsets.UTF_8); - byte[] reEncoded = decoded.getBytes(StandardCharsets.UTF_8); - if (arraysEqual(subBytes, reEncoded)) { - putValue(fieldNumber, decoded); - } else { - putValue(fieldNumber, "hex->" + bytesToHex(subBytes)); - } - } catch (Exception e2) { - putValue(fieldNumber, "hex->" + bytesToHex(subBytes)); - } - } + putValue(fieldNumber, decodeLengthDelimited(subBytes)); break; } case 5: - putValue(fieldNumber, in.readFixed32()); + putValue(fieldNumber, new Fixed32Value(in.readFixed32())); break; default: putValue(fieldNumber, "Unknown wireType: " + wireType); @@ -110,12 +129,127 @@ public void fromBytes(byte[] b) throws IOException { } } - private static boolean arraysEqual(byte[] a1, byte[] a2) { - if (a1.length != a2.length) return false; - for (int i = 0; i < a1.length; i++) { - if (a1[i] != a2[i]) return false; + private Object decodeLengthDelimited(byte[] subBytes) { + if (subBytes == null || subBytes.length == 0) { + return ""; + } + + ProtoProbe protoProbe = probeProtoMessage(subBytes); + + // Prefer short user-visible text when protobuf parsing is impossible + // or only succeeds as a trivial primitive field by chance. + if (looksLikeAccidentalTextProto(subBytes, protoProbe)) { + return new String(subBytes, StandardCharsets.UTF_8); + } + + if (protoProbe.valid) { + try { + FunProtoData subData = new FunProtoData(); + subData.fromBytes(subBytes); + return subData; + } catch (Exception ignored) { + // Fall through to the text/hex fallback. + } + } + + if (isStrictUtf8(subBytes)) { + return new String(subBytes, StandardCharsets.UTF_8); + } + + return "hex->" + bytesToHex(subBytes); + } + + private static boolean looksLikeAccidentalTextProto(byte[] bytes, ProtoProbe protoProbe) { + if (!looksLikeReadableUtf8(bytes)) { + return false; + } + if (!protoProbe.valid) { + return true; + } + return bytes.length <= MAX_ACCIDENTAL_TEXT_BYTES + && protoProbe.fieldCount == 1 + && !protoProbe.hasLengthDelimited; + } + + private static boolean looksLikeReadableUtf8(byte[] bytes) { + if (!isStrictUtf8(bytes)) { + return false; + } + + String s = new String(bytes, StandardCharsets.UTF_8); + if (s.isEmpty()) { + return true; + } + + int printable = 0; + int suspiciousControl = 0; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (Character.isISOControl(c) && c != '\n' && c != '\r' && c != '\t') { + suspiciousControl++; + } else { + printable++; + } + } + return suspiciousControl == 0 && printable > 0; + } + + private static boolean isStrictUtf8(byte[] bytes) { + try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + return true; + } catch (CharacterCodingException e) { + return false; + } + } + + private static ProtoProbe probeProtoMessage(byte[] bytes) { + try { + CodedInputStream testIn = CodedInputStream.newInstance(bytes); + int fieldCount = 0; + boolean hasLengthDelimited = false; + while (!testIn.isAtEnd()) { + int tag = testIn.readTag(); + if (tag == 0) { + break; + } + + int fieldNumber = tag >>> 3; + int wireType = tag & 7; + if (fieldNumber <= 0 || wireType == 3 || wireType == 4 || wireType > 5) { + return new ProtoProbe(false, 0, false); + } + + fieldCount++; + switch (wireType) { + case 0: + testIn.readInt64(); + break; + case 1: + testIn.readFixed64(); + break; + case 2: + hasLengthDelimited = true; + int size = testIn.readRawVarint32(); + if (size < 0) { + return new ProtoProbe(false, 0, false); + } + testIn.skipRawBytes(size); + break; + case 5: + testIn.readFixed32(); + break; + default: + return new ProtoProbe(false, 0, false); + } + } + return new ProtoProbe(fieldCount > 0 && testIn.isAtEnd(), fieldCount, hasLengthDelimited); + } catch (Exception e) { + return new ProtoProbe(false, 0, false); } - return true; } private static String bytesToHex(byte[] bytes) { @@ -148,6 +282,10 @@ public JSONObject toJSON()throws Exception{ private Object valueToText(Object value) throws Exception { if (value instanceof FunProtoData data){ return data.toJSON(); + }else if (value instanceof Fixed64Value fixed64Value){ + return fixed64Value.value; + }else if (value instanceof Fixed32Value fixed32Value){ + return fixed32Value.value; }else { return value; } @@ -163,11 +301,15 @@ public byte[] toBytes(){ if (o instanceof Long){ long l = (long) o; out.writeInt64(k_index , l); + }else if (o instanceof Fixed64Value fixed64Value){ + out.writeFixed64(k_index, fixed64Value.value); }else if (o instanceof String s){ - out.writeByteArray(k_index , s.getBytes()); + out.writeByteArray(k_index , s.getBytes(StandardCharsets.UTF_8)); }else if (o instanceof FunProtoData data){ byte[] subBytes = data.toBytes(); out.writeByteArray(k_index , subBytes); + }else if (o instanceof Fixed32Value fixed32Value){ + out.writeFixed32(k_index, fixed32Value.value); }else if (o instanceof Integer){ int i = (int) o; out.writeInt32(k_index, i); From eb3605ca2dc40f6d16bb64e8efd54c9f8456553e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E5=96=B5=E5=96=B5=E5=96=B5=E5=96=B5?= <3299332656@qq.com> Date: Mon, 20 Jul 2026 19:20:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E9=87=8D=E5=86=99=E5=81=87=E7=BE=A4?= =?UTF-8?q?=E4=B8=BB=20protobuf=20=E4=BF=AE=E6=94=B9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 + .../item/entertainment/FakeGroupOwner.kt | 296 ++++++++++--- .../main/java/moe/ono/util/FunProtoData.java | 194 ++------ .../entertainment/FakeGroupOwnerProtoTest.kt | 417 ++++++++++++++++++ 4 files changed, 676 insertions(+), 233 deletions(-) create mode 100644 app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ead601c5..5248fd0f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -229,4 +229,6 @@ dependencies { compileOnly(libs.lombok) annotationProcessor(libs.lombok) + + testImplementation(libs.junit) } diff --git a/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt b/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt index 3db60516..27874729 100644 --- a/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt +++ b/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt @@ -1,19 +1,20 @@ package moe.ono.hooks.item.entertainment import android.content.Intent +import com.google.protobuf.ByteString +import com.google.protobuf.InvalidProtocolBufferException +import com.google.protobuf.UnknownFieldSet import com.tencent.qphone.base.remote.ToServiceMsg import de.robv.android.xposed.XC_MethodHook import moe.ono.hooks._base.BaseSwitchFunctionHookItem import moe.ono.hooks._core.annotation.HookItem import moe.ono.hooks.base.api.QQMsfReqHandler -import moe.ono.hooks.protocol.sendPacket import moe.ono.loader.hookapi.IMsfHandler -import moe.ono.util.FunProtoData -import org.json.JSONArray -import org.json.JSONObject +import moe.ono.util.Logger +import java.nio.ByteBuffer @HookItem(path = "娱乐功能/假群主", description = "* 随便发点消息,概率变成群主头衔\n* 管理员无效") -class FakeGroupOwner: BaseSwitchFunctionHookItem() { +class FakeGroupOwner : BaseSwitchFunctionHookItem() { override fun entry(classLoader: ClassLoader) { QQMsfReqHandler.handler.add(object : IMsfHandler { override fun onHandle( @@ -21,70 +22,235 @@ class FakeGroupOwner: BaseSwitchFunctionHookItem() { intent: Intent, toServiceMsg: ToServiceMsg ) { - try { - if (!this@FakeGroupOwner.isEnabled) return - - val pbData = FunProtoData.getUnpPackage(toServiceMsg.wupBuffer) - val json = FunProtoData().apply { fromBytes(pbData) }.toJSON() - - json.optJSONObject("1")?.optJSONObject("2")?.opt("1") ?: return - - val obj3_1 = json.optJSONObject("3")?.optJSONObject("1") ?: return - val obj2 = obj3_1.opt("2") ?: return - - var isModified = false - - fun createTargetNode() = JSONObject().apply { - put("37", JSONObject().apply { - put("19", JSONObject().apply { put("4", 300) }) - }) - } - - fun updateExistingNode(targetNode: JSONObject) { - val inner37 = targetNode.getJSONObject("37") - val inner19 = inner37.optJSONObject("19") ?: JSONObject().also { inner37.put("19", it) } - inner19.put("4", 300) - } - - if (obj2 is JSONObject) { - if (obj2.has("37")) { - updateExistingNode(obj2) - isModified = true - } else { - obj3_1.put("2", JSONArray().apply { - put(obj2) - put(createTargetNode()) - }) - isModified = true - } - } else if (obj2 is JSONArray) { - var hasNode37 = false - for (i in 0 until obj2.length()) { - val item = obj2.optJSONObject(i) - if (item != null && item.has("37")) { - updateExistingNode(item) - hasNode37 = true - } - } - if (!hasNode37) { - obj2.put(createTargetNode()) - } - isModified = true - } - - if (isModified) { - finish(param, json) - } - } catch (_: Throwable) { } - } + if (!this@FakeGroupOwner.isEnabled) return - private fun finish(param: XC_MethodHook.MethodHookParam, json: JSONObject) { - param.result = null - sendPacket("MessageSvc.PbSendMsg", json.toString()) + try { + val original = toServiceMsg.wupBuffer ?: return + val patched = FakeGroupOwnerProto.patchWupBuffer(original) ?: return + toServiceMsg.putWupBuffer(patched) + } catch (t: Throwable) { + Logger.e("[FakeGroupOwner] Failed to patch MessageSvc.PbSendMsg", t) + } } override val cmd: String get() = "MessageSvc.PbSendMsg" }) } -} \ No newline at end of file +} + +internal object FakeGroupOwnerProto { + private const val LENGTH_HEADER_SIZE = 4 + private const val OWNER_TITLE_VALUE = 300L + + fun patchWupBuffer(buffer: ByteArray): ByteArray? { + val hasLengthHeader = hasLengthHeader(buffer) + val payload = if (hasLengthHeader) { + buffer.copyOfRange(LENGTH_HEADER_SIZE, buffer.size) + } else { + buffer + } + val patchedPayload = patchPayload(payload) ?: return null + + return if (hasLengthHeader) addLengthHeader(patchedPayload) else patchedPayload + } + + fun patchPayload(payload: ByteArray): ByteArray? { + val request = parse(payload) ?: return null + if (!isGroupMessage(request)) return null + + return when ( + val result = patchMessageField(request, 3, transform = ::patchMessageBody) + ) { + is PatchResult.Modified -> result.value.toByteArray() + PatchResult.Malformed, + PatchResult.Unchanged -> null + } + } + + private fun isGroupMessage(request: UnknownFieldSet): Boolean { + return request.getField(1).lengthDelimitedList.any { routingBytes -> + val routingHead = parse(routingBytes) ?: return@any false + routingHead.getField(2).lengthDelimitedList.any { groupBytes -> + parse(groupBytes)?.getField(1)?.varintList?.isNotEmpty() == true + } + } + } + + private fun patchMessageBody(messageBody: UnknownFieldSet): PatchResult { + return patchMessageField(messageBody, 1, transform = ::patchRichText) + } + + private fun patchRichText(richText: UnknownFieldSet): PatchResult { + val elementsField = richText.getField(2) + if (elementsField.lengthDelimitedList.isEmpty()) return PatchResult.Unchanged + + var foundOwnerElement = false + val elements = ArrayList(elementsField.lengthDelimitedList.size + 1) + + for (elementBytes in elementsField.lengthDelimitedList) { + val element = parse(elementBytes) ?: return PatchResult.Malformed + if (!element.hasField(37)) { + elements.add(elementBytes) + continue + } + + foundOwnerElement = true + when (val result = patchOwnerElement(element)) { + is PatchResult.Modified -> elements.add(result.value.toByteString()) + PatchResult.Malformed, + PatchResult.Unchanged -> return PatchResult.Malformed + } + } + + if (!foundOwnerElement) { + elements.add(createOwnerElement().toByteString()) + } + + return PatchResult.Modified(replaceLengthDelimited(richText, 2, elements)) + } + + private fun patchOwnerElement(element: UnknownFieldSet): PatchResult { + return patchMessageField( + element, + 37, + requireValues = true, + transform = ::patchOwnerInfo + ) + } + + private fun patchOwnerInfo(ownerInfo: UnknownFieldSet): PatchResult { + if (!ownerInfo.hasField(19)) { + return PatchResult.Modified( + replaceLengthDelimited( + ownerInfo, + 19, + listOf(setOwnerTitle(UnknownFieldSet.getDefaultInstance()).toByteString()) + ) + ) + } + + return patchMessageField(ownerInfo, 19, requireValues = true) { titleInfo -> + PatchResult.Modified(setOwnerTitle(titleInfo)) + } + } + + private fun setOwnerTitle(titleInfo: UnknownFieldSet): UnknownFieldSet { + val oldField = titleInfo.getField(4) + val newField = UnknownFieldSet.Field.newBuilder().apply { + addVarint(OWNER_TITLE_VALUE) + oldField.fixed32List.forEach(::addFixed32) + oldField.fixed64List.forEach(::addFixed64) + oldField.lengthDelimitedList.forEach(::addLengthDelimited) + oldField.groupList.forEach(::addGroup) + }.build() + + return UnknownFieldSet.newBuilder(titleInfo) + .clearField(4) + .addField(4, newField) + .build() + } + + private fun createOwnerElement(): UnknownFieldSet { + val ownerInfo = UnknownFieldSet.newBuilder() + .addField( + 19, + lengthDelimitedField(setOwnerTitle(UnknownFieldSet.getDefaultInstance())) + ) + .build() + + return UnknownFieldSet.newBuilder() + .addField(37, lengthDelimitedField(ownerInfo)) + .build() + } + + private fun patchMessageField( + parent: UnknownFieldSet, + fieldNumber: Int, + requireValues: Boolean = false, + transform: (UnknownFieldSet) -> PatchResult + ): PatchResult { + val field = parent.getField(fieldNumber) + if (field.lengthDelimitedList.isEmpty()) { + return if (requireValues) PatchResult.Malformed else PatchResult.Unchanged + } + + var modified = false + val values = ArrayList(field.lengthDelimitedList.size) + for (bytes in field.lengthDelimitedList) { + val child = parse(bytes) ?: return PatchResult.Malformed + when (val result = transform(child)) { + is PatchResult.Modified -> { + values.add(result.value.toByteString()) + modified = true + } + PatchResult.Malformed -> return PatchResult.Malformed + PatchResult.Unchanged -> values.add(bytes) + } + } + + return if (modified) { + PatchResult.Modified(replaceLengthDelimited(parent, fieldNumber, values)) + } else { + PatchResult.Unchanged + } + } + + private fun replaceLengthDelimited( + parent: UnknownFieldSet, + fieldNumber: Int, + values: List + ): UnknownFieldSet { + val oldField = parent.getField(fieldNumber) + val newField = UnknownFieldSet.Field.newBuilder().apply { + oldField.varintList.forEach(::addVarint) + oldField.fixed32List.forEach(::addFixed32) + oldField.fixed64List.forEach(::addFixed64) + values.forEach(::addLengthDelimited) + oldField.groupList.forEach(::addGroup) + }.build() + + return UnknownFieldSet.newBuilder(parent) + .clearField(fieldNumber) + .addField(fieldNumber, newField) + .build() + } + + private fun lengthDelimitedField(value: UnknownFieldSet): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder() + .addLengthDelimited(value.toByteString()) + .build() + } + + private fun hasLengthHeader(buffer: ByteArray): Boolean { + if (buffer.size < LENGTH_HEADER_SIZE) return false + return ByteBuffer.wrap(buffer, 0, LENGTH_HEADER_SIZE).int == buffer.size + } + + private fun addLengthHeader(payload: ByteArray): ByteArray { + val size = payload.size + LENGTH_HEADER_SIZE + return ByteBuffer.allocate(size).putInt(size).put(payload).array() + } + + private fun parse(bytes: ByteArray): UnknownFieldSet? { + return try { + UnknownFieldSet.parseFrom(bytes) + } catch (_: InvalidProtocolBufferException) { + null + } + } + + private fun parse(bytes: ByteString): UnknownFieldSet? { + return try { + UnknownFieldSet.parseFrom(bytes) + } catch (_: InvalidProtocolBufferException) { + null + } + } + + private sealed interface PatchResult { + data object Unchanged : PatchResult + data object Malformed : PatchResult + data class Modified(val value: UnknownFieldSet) : PatchResult + } +} diff --git a/app/src/main/java/moe/ono/util/FunProtoData.java b/app/src/main/java/moe/ono/util/FunProtoData.java index 97fc6548..339deb7d 100644 --- a/app/src/main/java/moe/ono/util/FunProtoData.java +++ b/app/src/main/java/moe/ono/util/FunProtoData.java @@ -8,9 +8,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -20,37 +17,8 @@ public class FunProtoData { - private static final int MAX_ACCIDENTAL_TEXT_BYTES = 64; private final HashMap> values = new HashMap<>(); - private static final class Fixed32Value { - private final int value; - - private Fixed32Value(int value) { - this.value = value; - } - } - - private static final class Fixed64Value { - private final long value; - - private Fixed64Value(long value) { - this.value = value; - } - } - - private static final class ProtoProbe { - private final boolean valid; - private final int fieldCount; - private final boolean hasLengthDelimited; - - private ProtoProbe(boolean valid, int fieldCount, boolean hasLengthDelimited) { - this.valid = valid; - this.fieldCount = fieldCount; - this.hasLengthDelimited = hasLengthDelimited; - } - } - public static byte[] getUnpPackage(byte[] b) { if (b == null) { return null; @@ -99,11 +67,8 @@ private void putValue(int key, Object value){ } public void fromBytes(byte[] b) throws IOException { CodedInputStream in = CodedInputStream.newInstance(b); - while (!in.isAtEnd()) { + while (in.getBytesUntilLimit() > 0) { int tag = in.readTag(); - if (tag == 0) { - break; - } int fieldNumber = tag >>> 3; int wireType = tag & 7; if (wireType == 4 || wireType == 3 || wireType > 5) throw new IOException("Unexpected wireType: " + wireType); @@ -112,15 +77,31 @@ public void fromBytes(byte[] b) throws IOException { putValue(fieldNumber, in.readInt64()); break; case 1: - putValue(fieldNumber, new Fixed64Value(in.readFixed64())); + putValue(fieldNumber, in.readRawVarint64()); break; case 2: { byte[] subBytes = in.readByteArray(); - putValue(fieldNumber, decodeLengthDelimited(subBytes)); + try { + FunProtoData sub_data = new FunProtoData(); + sub_data.fromBytes(subBytes); + putValue(fieldNumber, sub_data); + } catch (Exception e) { + try { + String decoded = new String(subBytes, StandardCharsets.UTF_8); + byte[] reEncoded = decoded.getBytes(StandardCharsets.UTF_8); + if (arraysEqual(subBytes, reEncoded)) { + putValue(fieldNumber, decoded); + } else { + putValue(fieldNumber, "hex->" + bytesToHex(subBytes)); + } + } catch (Exception e2) { + putValue(fieldNumber, "hex->" + bytesToHex(subBytes)); + } + } break; } case 5: - putValue(fieldNumber, new Fixed32Value(in.readFixed32())); + putValue(fieldNumber, in.readFixed32()); break; default: putValue(fieldNumber, "Unknown wireType: " + wireType); @@ -129,127 +110,12 @@ public void fromBytes(byte[] b) throws IOException { } } - private Object decodeLengthDelimited(byte[] subBytes) { - if (subBytes == null || subBytes.length == 0) { - return ""; - } - - ProtoProbe protoProbe = probeProtoMessage(subBytes); - - // Prefer short user-visible text when protobuf parsing is impossible - // or only succeeds as a trivial primitive field by chance. - if (looksLikeAccidentalTextProto(subBytes, protoProbe)) { - return new String(subBytes, StandardCharsets.UTF_8); - } - - if (protoProbe.valid) { - try { - FunProtoData subData = new FunProtoData(); - subData.fromBytes(subBytes); - return subData; - } catch (Exception ignored) { - // Fall through to the text/hex fallback. - } - } - - if (isStrictUtf8(subBytes)) { - return new String(subBytes, StandardCharsets.UTF_8); - } - - return "hex->" + bytesToHex(subBytes); - } - - private static boolean looksLikeAccidentalTextProto(byte[] bytes, ProtoProbe protoProbe) { - if (!looksLikeReadableUtf8(bytes)) { - return false; - } - if (!protoProbe.valid) { - return true; - } - return bytes.length <= MAX_ACCIDENTAL_TEXT_BYTES - && protoProbe.fieldCount == 1 - && !protoProbe.hasLengthDelimited; - } - - private static boolean looksLikeReadableUtf8(byte[] bytes) { - if (!isStrictUtf8(bytes)) { - return false; - } - - String s = new String(bytes, StandardCharsets.UTF_8); - if (s.isEmpty()) { - return true; - } - - int printable = 0; - int suspiciousControl = 0; - for (int i = 0; i < s.length(); i++) { - char c = s.charAt(i); - if (Character.isISOControl(c) && c != '\n' && c != '\r' && c != '\t') { - suspiciousControl++; - } else { - printable++; - } - } - return suspiciousControl == 0 && printable > 0; - } - - private static boolean isStrictUtf8(byte[] bytes) { - try { - StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)); - return true; - } catch (CharacterCodingException e) { - return false; - } - } - - private static ProtoProbe probeProtoMessage(byte[] bytes) { - try { - CodedInputStream testIn = CodedInputStream.newInstance(bytes); - int fieldCount = 0; - boolean hasLengthDelimited = false; - while (!testIn.isAtEnd()) { - int tag = testIn.readTag(); - if (tag == 0) { - break; - } - - int fieldNumber = tag >>> 3; - int wireType = tag & 7; - if (fieldNumber <= 0 || wireType == 3 || wireType == 4 || wireType > 5) { - return new ProtoProbe(false, 0, false); - } - - fieldCount++; - switch (wireType) { - case 0: - testIn.readInt64(); - break; - case 1: - testIn.readFixed64(); - break; - case 2: - hasLengthDelimited = true; - int size = testIn.readRawVarint32(); - if (size < 0) { - return new ProtoProbe(false, 0, false); - } - testIn.skipRawBytes(size); - break; - case 5: - testIn.readFixed32(); - break; - default: - return new ProtoProbe(false, 0, false); - } - } - return new ProtoProbe(fieldCount > 0 && testIn.isAtEnd(), fieldCount, hasLengthDelimited); - } catch (Exception e) { - return new ProtoProbe(false, 0, false); + private static boolean arraysEqual(byte[] a1, byte[] a2) { + if (a1.length != a2.length) return false; + for (int i = 0; i < a1.length; i++) { + if (a1[i] != a2[i]) return false; } + return true; } private static String bytesToHex(byte[] bytes) { @@ -282,10 +148,6 @@ public JSONObject toJSON()throws Exception{ private Object valueToText(Object value) throws Exception { if (value instanceof FunProtoData data){ return data.toJSON(); - }else if (value instanceof Fixed64Value fixed64Value){ - return fixed64Value.value; - }else if (value instanceof Fixed32Value fixed32Value){ - return fixed32Value.value; }else { return value; } @@ -301,15 +163,11 @@ public byte[] toBytes(){ if (o instanceof Long){ long l = (long) o; out.writeInt64(k_index , l); - }else if (o instanceof Fixed64Value fixed64Value){ - out.writeFixed64(k_index, fixed64Value.value); }else if (o instanceof String s){ - out.writeByteArray(k_index , s.getBytes(StandardCharsets.UTF_8)); + out.writeByteArray(k_index , s.getBytes()); }else if (o instanceof FunProtoData data){ byte[] subBytes = data.toBytes(); out.writeByteArray(k_index , subBytes); - }else if (o instanceof Fixed32Value fixed32Value){ - out.writeFixed32(k_index, fixed32Value.value); }else if (o instanceof Integer){ int i = (int) o; out.writeInt32(k_index, i); diff --git a/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt b/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt new file mode 100644 index 00000000..ff9ec52e --- /dev/null +++ b/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt @@ -0,0 +1,417 @@ +package moe.ono.hooks.item.entertainment + +import com.google.protobuf.ByteString +import com.google.protobuf.UnknownFieldSet +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.nio.ByteBuffer + +class FakeGroupOwnerProtoTest { + private val opaque = ByteString.copyFrom(byteArrayOf(0, -1, 8, -128, 127)) + private val protobufShapedOpaqueElement = ByteString.copyFrom( + byteArrayOf(0xd8.toByte(), 0x05, 0x81.toByte(), 0x00) + ) + + @Test + fun appendsOwnerElementWithoutChangingExistingPayloads() { + assertNotEquals( + protobufShapedOpaqueElement, + parse(protobufShapedOpaqueElement).toByteString() + ) + val originalElements = listOf("ui。", "95", "9号还是10号?", "🙂") + .map { textElement(it).toByteString() } + listOf(protobufShapedOpaqueElement) + val before = packet(isGroup = true, elements = originalElements) + + val after = parse(requireNotNull(FakeGroupOwnerProto.patchPayload(before.toByteArray()))) + val oldElements = elementBytes(before) + val newElements = elementBytes(after) + + assertTargetPathSiblingsPreserved(before, after) + assertEquals(oldElements.size + 1, newElements.size) + assertEquals(oldElements, newElements.take(oldElements.size)) + val appended = parse(newElements.last()) + assertCanonicalOwnerElement(appended) + } + + @Test + fun updatesEveryExistingOwnerValueAndPreservesMixedWireTypes() { + val before = packet( + isGroup = true, + elements = listOf( + textElement("95").toByteString(), + ownerElement(listOf(listOf(95, 96), listOf(97, 98))).toByteString(), + textElement("ui。").toByteString() + ) + ) + val after = parse(requireNotNull(FakeGroupOwnerProto.patchPayload(before.toByteArray()))) + val oldElements = elementBytes(before) + val newElements = elementBytes(after) + + assertEquals(oldElements.size, newElements.size) + assertEquals(oldElements[0], newElements[0]) + assertEquals(oldElements[2], newElements[2]) + + val oldOwner = parse(oldElements[1]) + val newOwner = parse(newElements[1]) + assertExcept(oldOwner, newOwner, 37) + assertNonLengthDelimitedPreserved(oldOwner.getField(37), newOwner.getField(37)) + + val oldOwnerInfos = children(oldOwner, 37) + val newOwnerInfos = children(newOwner, 37) + assertEquals(oldOwnerInfos.size, newOwnerInfos.size) + oldOwnerInfos.zip(newOwnerInfos).forEach { (oldInfo, newInfo) -> + assertExcept(oldInfo, newInfo, 19) + assertNonLengthDelimitedPreserved(oldInfo.getField(19), newInfo.getField(19)) + + val oldTitles = children(oldInfo, 19) + val newTitles = children(newInfo, 19) + assertEquals(oldTitles.size, newTitles.size) + oldTitles.zip(newTitles).forEach { (oldTitle, newTitle) -> + assertExcept(oldTitle, newTitle, 4) + assertEquals(listOf(300L), newTitle.getField(4).varintList) + assertNonVarintPreserved(oldTitle.getField(4), newTitle.getField(4)) + } + } + } + + @Test + fun addsMissingField19WithoutAppendingAnotherElement() { + val ownerInfo = message(20 to bytes(opaque)) + val element = message( + 37 to mixedLengthDelimited(listOf(ownerInfo.toByteString())), + 80 to bytes(opaque) + ) + val before = packet(isGroup = true, elements = listOf(element.toByteString())) + + val after = parse(requireNotNull(FakeGroupOwnerProto.patchPayload(before.toByteArray()))) + val oldElement = parse(elementBytes(before).single()) + val newElement = parse(elementBytes(after).single()) + + assertExcept(oldElement, newElement, 37) + assertNonLengthDelimitedPreserved(oldElement.getField(37), newElement.getField(37)) + val oldOwnerInfo = child(oldElement, 37) + val newOwnerInfo = child(newElement, 37) + assertExcept(oldOwnerInfo, newOwnerInfo, 19) + assertOwnerValue(newElement) + } + + @Test + fun patchesEveryRepeatedMessageBodyAndRichText() { + val before = packetWithBodies( + isGroup = true, + bodies = listOf( + messageBody( + listOf( + richText(listOf(textElement("ui。").toByteString())), + richText(listOf(textElement("95").toByteString())) + ) + ), + messageBody( + listOf( + richText(listOf(textElement("9号还是10号?").toByteString())) + ) + ) + ) + ) + + val after = parse(requireNotNull(FakeGroupOwnerProto.patchPayload(before.toByteArray()))) + assertExcept(before, after, 3) + assertNonLengthDelimitedPreserved(before.getField(3), after.getField(3)) + val oldBodies = children(before, 3) + val newBodies = children(after, 3) + + assertEquals(oldBodies.size, newBodies.size) + oldBodies.zip(newBodies).forEach { (oldBody, newBody) -> + assertExcept(oldBody, newBody, 1) + assertNonLengthDelimitedPreserved(oldBody.getField(1), newBody.getField(1)) + val oldRichTexts = children(oldBody, 1) + val newRichTexts = children(newBody, 1) + assertEquals(oldRichTexts.size, newRichTexts.size) + + oldRichTexts.zip(newRichTexts).forEach { (oldRichText, newRichText) -> + assertExcept(oldRichText, newRichText, 2) + assertNonLengthDelimitedPreserved( + oldRichText.getField(2), + newRichText.getField(2) + ) + val oldElements = oldRichText.getField(2).lengthDelimitedList + val newElements = newRichText.getField(2).lengthDelimitedList + assertEquals(oldElements.size + 1, newElements.size) + assertEquals(oldElements, newElements.take(oldElements.size)) + assertCanonicalOwnerElement(parse(newElements.last())) + } + } + } + + @Test + fun rejectsMalformedOwnerPathsWithoutPartiallyChangingPacket() { + val invalidMessage = ByteString.copyFrom(byteArrayOf(0)) + val malformed37 = message(37 to bytes(invalidMessage)) + val malformed19 = message( + 37 to objectField(message(19 to bytes(invalidMessage))) + ) + val wrongWire37 = message(37 to varints(1)) + val wrongWire19 = message( + 37 to objectField(message(19 to varints(1))) + ) + val validOwnerInfo = message(19 to objectField(ownerTitle(95))) + val repeatedMalformed37 = message( + 37 to mixedLengthDelimited(listOf(validOwnerInfo.toByteString(), invalidMessage)) + ) + val repeatedMalformed19 = message( + 37 to objectField( + message( + 19 to mixedLengthDelimited( + listOf(ownerTitle(95).toByteString(), invalidMessage) + ) + ) + ) + ) + + listOf( + malformed37, + malformed19, + wrongWire37, + wrongWire19, + repeatedMalformed37, + repeatedMalformed19 + ).forEach { element -> + val payload = packet(true, listOf(element.toByteString())).toByteArray() + assertNull(FakeGroupOwnerProto.patchPayload(payload)) + } + + val malformedElement = packet(true, listOf(invalidMessage)).toByteArray() + assertNull(FakeGroupOwnerProto.patchPayload(malformedElement)) + } + + @Test + fun preservesAndRecalculatesFourByteLengthHeader() { + val payload = packet( + isGroup = true, + elements = listOf(textElement("ui。").toByteString()) + ).toByteArray() + val framed = addLengthHeader(payload) + + val patched = requireNotNull(FakeGroupOwnerProto.patchWupBuffer(framed)) + + assertEquals(patched.size, ByteBuffer.wrap(patched).int) + val after = parse(patched.copyOfRange(4, patched.size)) + assertCanonicalOwnerElement(parse(elementBytes(after).last())) + } + + @Test + fun acceptsUnframedPayloadAndIgnoresPrivateMessages() { + val groupPayload = packet( + isGroup = true, + elements = listOf(textElement("🙂").toByteString()) + ).toByteArray() + val privatePayload = packet( + isGroup = false, + elements = listOf(textElement("ui。").toByteString()) + ).toByteArray() + + val patched = requireNotNull(FakeGroupOwnerProto.patchWupBuffer(groupPayload)) + + assertCanonicalOwnerElement(parse(elementBytes(parse(patched)).last())) + assertNull(FakeGroupOwnerProto.patchWupBuffer(addLengthHeader(privatePayload))) + } + + private fun packet(isGroup: Boolean, elements: List): UnknownFieldSet { + return packetWithBodies(isGroup, listOf(messageBody(listOf(richText(elements))))) + } + + private fun packetWithBodies( + isGroup: Boolean, + bodies: List + ): UnknownFieldSet { + val routingHead = if (isGroup) { + message(2 to objectField(message(1 to varints(123456)))) + } else { + message(1 to objectField(message(2 to bytes(ByteString.copyFromUtf8("peer"))))) + } + + return message( + 1 to objectField(routingHead), + 3 to mixedLengthDelimited(bodies.map(UnknownFieldSet::toByteString)), + 72 to bytes(opaque), + 73 to UnknownFieldSet.Field.newBuilder().addFixed32(0x12345678).build() + ) + } + + private fun messageBody(richTexts: List): UnknownFieldSet { + return message( + 1 to mixedLengthDelimited(richTexts.map(UnknownFieldSet::toByteString)), + 71 to bytes(ByteString.copyFromUtf8("ui。")) + ) + } + + private fun richText(elements: List): UnknownFieldSet { + return message( + 2 to mixedLengthDelimited(elements), + 70 to bytes(opaque) + ) + } + + private fun textElement(text: String): UnknownFieldSet { + return message( + 1 to objectField(message(1 to bytes(ByteString.copyFromUtf8(text)))), + 90 to bytes(opaque) + ) + } + + private fun ownerElement(ownerValues: List>): UnknownFieldSet { + val ownerInfos = ownerValues.map { values -> + message( + 19 to mixedLengthDelimited(values.map { ownerTitle(it).toByteString() }), + 20 to bytes(opaque) + ) + } + return message( + 37 to mixedLengthDelimited(ownerInfos.map(UnknownFieldSet::toByteString)), + 80 to bytes(opaque) + ) + } + + private fun ownerTitle(value: Long): UnknownFieldSet { + return message( + 4 to mixedVarintField(value, value + 1), + 9 to bytes(ByteString.copyFromUtf8("ui。")), + 10 to bytes(opaque) + ) + } + + private fun elementBytes(root: UnknownFieldSet): List { + return child(child(root, 3), 1).getField(2).lengthDelimitedList + } + + private fun assertTargetPathSiblingsPreserved( + before: UnknownFieldSet, + after: UnknownFieldSet + ) { + assertExcept(before, after, 3) + assertNonLengthDelimitedPreserved(before.getField(3), after.getField(3)) + + val beforeBody = child(before, 3) + val afterBody = child(after, 3) + assertExcept(beforeBody, afterBody, 1) + assertNonLengthDelimitedPreserved(beforeBody.getField(1), afterBody.getField(1)) + + val beforeRichText = child(beforeBody, 1) + val afterRichText = child(afterBody, 1) + assertExcept(beforeRichText, afterRichText, 2) + assertNonLengthDelimitedPreserved( + beforeRichText.getField(2), + afterRichText.getField(2) + ) + } + + private fun assertCanonicalOwnerElement(value: UnknownFieldSet) { + assertEquals(setOf(37), value.asMap().keys) + val ownerInfo = child(value, 37) + assertEquals(setOf(19), ownerInfo.asMap().keys) + val title = child(ownerInfo, 19) + assertEquals(setOf(4), title.asMap().keys) + assertEquals(varints(300), title.getField(4)) + } + + private fun assertOwnerValue(value: UnknownFieldSet) { + val ownerInfos = children(value, 37) + assertEquals(1, ownerInfos.size) + val titles = children(ownerInfos.single(), 19) + assertEquals(1, titles.size) + assertEquals(listOf(300L), titles.single().getField(4).varintList) + } + + private fun assertExcept( + before: UnknownFieldSet, + after: UnknownFieldSet, + fieldNumber: Int + ) { + assertEquals( + before.asMap().filterKeys { it != fieldNumber }, + after.asMap().filterKeys { it != fieldNumber } + ) + } + + private fun assertNonLengthDelimitedPreserved( + before: UnknownFieldSet.Field, + after: UnknownFieldSet.Field + ) { + assertEquals(before.varintList, after.varintList) + assertEquals(before.fixed32List, after.fixed32List) + assertEquals(before.fixed64List, after.fixed64List) + assertEquals(before.groupList, after.groupList) + } + + private fun assertNonVarintPreserved( + before: UnknownFieldSet.Field, + after: UnknownFieldSet.Field + ) { + assertEquals(before.fixed32List, after.fixed32List) + assertEquals(before.fixed64List, after.fixed64List) + assertEquals(before.lengthDelimitedList, after.lengthDelimitedList) + assertEquals(before.groupList, after.groupList) + } + + private fun child(parent: UnknownFieldSet, number: Int): UnknownFieldSet { + return children(parent, number).single() + } + + private fun children(parent: UnknownFieldSet, number: Int): List { + return parent.getField(number).lengthDelimitedList.map(::parse) + } + + private fun parse(bytes: ByteArray): UnknownFieldSet = UnknownFieldSet.parseFrom(bytes) + + private fun parse(bytes: ByteString): UnknownFieldSet = UnknownFieldSet.parseFrom(bytes) + + private fun message(vararg fields: Pair): UnknownFieldSet { + return UnknownFieldSet.newBuilder().apply { + fields.forEach { (number, field) -> addField(number, field) } + }.build() + } + + private fun objectField(value: UnknownFieldSet): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder() + .addLengthDelimited(value.toByteString()) + .build() + } + + private fun bytes(value: ByteString): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder().addLengthDelimited(value).build() + } + + private fun varints(vararg values: Long): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder().apply { + values.forEach(::addVarint) + }.build() + } + + private fun mixedLengthDelimited(values: List): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder().apply { + addVarint(7) + addFixed32(0x10203040) + addFixed64(0x0102030405060708L) + values.forEach(::addLengthDelimited) + addGroup(message(99 to varints(9))) + }.build() + } + + private fun mixedVarintField(vararg values: Long): UnknownFieldSet.Field { + return UnknownFieldSet.Field.newBuilder().apply { + values.forEach(::addVarint) + addFixed32(0x10203040) + addFixed64(0x0102030405060708L) + addLengthDelimited(opaque) + addGroup(message(99 to varints(9))) + }.build() + } + + private fun addLengthHeader(payload: ByteArray): ByteArray { + val size = payload.size + 4 + return ByteBuffer.allocate(size).putInt(size).put(payload).array() + } + +} From f22dbab62d3cbbdc0037dc8da38d3b23fafeb76b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E5=96=B5=E5=96=B5=E5=96=B5=E5=96=B5?= <3299332656@qq.com> Date: Tue, 21 Jul 2026 03:37:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E9=80=9A=E8=BF=87=20QQ=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E9=93=BE=E8=B7=AF=E9=87=8D=E7=AD=BE=E5=81=87=E7=BE=A4?= =?UTF-8?q?=E4=B8=BB=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../item/entertainment/FakeGroupOwner.kt | 35 ++++++++++++------- .../entertainment/FakeGroupOwnerProtoTest.kt | 5 ++- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt b/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt index 27874729..898ffdec 100644 --- a/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt +++ b/app/src/main/java/moe/ono/hooks/item/entertainment/FakeGroupOwner.kt @@ -10,6 +10,7 @@ import moe.ono.hooks._base.BaseSwitchFunctionHookItem import moe.ono.hooks._core.annotation.HookItem import moe.ono.hooks.base.api.QQMsfReqHandler import moe.ono.loader.hookapi.IMsfHandler +import moe.ono.service.QQInterfaces import moe.ono.util.Logger import java.nio.ByteBuffer @@ -23,20 +24,37 @@ class FakeGroupOwner : BaseSwitchFunctionHookItem() { toServiceMsg: ToServiceMsg ) { if (!this@FakeGroupOwner.isEnabled) return + if (toServiceMsg.getAttribute(RESEND_MARKER) == true) return try { val original = toServiceMsg.wupBuffer ?: return - val patched = FakeGroupOwnerProto.patchWupBuffer(original) ?: return - toServiceMsg.putWupBuffer(patched) + val patchedPayload = FakeGroupOwnerProto.patchWupBuffer(original) ?: return + val replacement = ToServiceMsg( + toServiceMsg.serviceName, + toServiceMsg.uin, + toServiceMsg.serviceCmd + ).apply { + putWupBuffer(patchedPayload) + addAttribute("req_pb_protocol_flag", true) + addAttribute(RESEND_MARKER, true) + } + + QQInterfaces.app.sendToService(replacement) + param.result = null } catch (t: Throwable) { - Logger.e("[FakeGroupOwner] Failed to patch MessageSvc.PbSendMsg", t) + Logger.e("[FakeGroupOwner] Failed to resend MessageSvc.PbSendMsg", t) } } override val cmd: String - get() = "MessageSvc.PbSendMsg" + get() = COMMAND }) } + + private companion object { + const val COMMAND = "MessageSvc.PbSendMsg" + const val RESEND_MARKER = "ono_fake_group_owner_resend" + } } internal object FakeGroupOwnerProto { @@ -50,9 +68,7 @@ internal object FakeGroupOwnerProto { } else { buffer } - val patchedPayload = patchPayload(payload) ?: return null - - return if (hasLengthHeader) addLengthHeader(patchedPayload) else patchedPayload + return patchPayload(payload) } fun patchPayload(payload: ByteArray): ByteArray? { @@ -227,11 +243,6 @@ internal object FakeGroupOwnerProto { return ByteBuffer.wrap(buffer, 0, LENGTH_HEADER_SIZE).int == buffer.size } - private fun addLengthHeader(payload: ByteArray): ByteArray { - val size = payload.size + LENGTH_HEADER_SIZE - return ByteBuffer.allocate(size).putInt(size).put(payload).array() - } - private fun parse(bytes: ByteArray): UnknownFieldSet? { return try { UnknownFieldSet.parseFrom(bytes) diff --git a/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt b/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt index ff9ec52e..141b5675 100644 --- a/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt +++ b/app/src/test/java/moe/ono/hooks/item/entertainment/FakeGroupOwnerProtoTest.kt @@ -187,7 +187,7 @@ class FakeGroupOwnerProtoTest { } @Test - fun preservesAndRecalculatesFourByteLengthHeader() { + fun stripsFourByteLengthHeaderForResigning() { val payload = packet( isGroup = true, elements = listOf(textElement("ui。").toByteString()) @@ -196,8 +196,7 @@ class FakeGroupOwnerProtoTest { val patched = requireNotNull(FakeGroupOwnerProto.patchWupBuffer(framed)) - assertEquals(patched.size, ByteBuffer.wrap(patched).int) - val after = parse(patched.copyOfRange(4, patched.size)) + val after = parse(patched) assertCanonicalOwnerElement(parse(elementBytes(after).last())) }