android apksigner zip解析
zip文件格式分为zip文件格式由文件数据区、中央目录结构,中央目录结束标志组成。其中中央目录结束节又有一个字段保存了中央目录结构的偏移。
中央目录结束标志解析: 中央目录结束标志数据结构struct EndLocator
{
ui32 signature; //目录结束标记,(固定值0x06054b50)10进制101010256
ui16 elDiskNumber; //当前磁盘编号
ui16 elStartDiskNumber; //中央目录开始位置的磁盘编号
ui16 elEntriesOnDisk; //该磁盘上所记录的核心目录数量
ui16 elEntriesInDirectory; //中央目录结构总数
ui32 elDirectorySize; //中央目录的大小
ui32 elDirectoryOffset; //中央目录开始位置相对于文件头的偏移
ui16 elCommentLen; // 注释长度
char *elComment; // 注释内容
};
java代码解析
public static ApkUtils.ZipSections findZipSections(DataSource apk) throws IOException, ZipFormatException {
// 中央目录结束标志
Pair eocdAndOffsetInFile = ZipUtils.findZipEndOfCentralDirectoryRecord(apk);
if (eocdAndOffsetInFile == null) {
throw new ZipFormatException("ZIP End of Central Directory record not found");
} else {
ByteBuffer eocdBuf = (ByteBuffer)eocdAndOffsetInFile.getFirst();
long eocdOffset = (Long)eocdAndOffsetInFile.getSecond();
eocdBuf.order(ByteOrder.LITTLE_ENDIAN);
// 获取elDirectoryOffset 中央目录开始位置相对于文件头的偏移
long cdStartOffset = ZipUtils.getZipEocdCentralDirectoryOffset(eocdBuf);
if (cdStartOffset > eocdOffset) {
throw new ZipFormatException("ZIP Central Directory start offset out of range: " + cdStartOffset + ". ZIP End of Central Directory offset: " + eocdOffset);
} else {
// 获取 中央目录的大小
long cdSizeBytes = ZipUtils.getZipEocdCentralDirectorySizeBytes(eocdBuf);
long cdEndOffset = cdStartOffset + cdSizeBytes;
if (cdEndOffset > eocdOffset) {
throw new ZipFormatException("ZIP Central Directory overlaps with End of Central Directory. CD end: " + cdEndOffset + ", EoCD start: " + eocdOffset);
} else {
// 获取elEntriesInDirectory 中央目录结构总数
int cdRecordCount = ZipUtils.getZipEocdCentralDirectoryTotalRecordCount(eocdBuf);
//生成ZipSections
//cdStartOffset 中央目录开始位置相对于文件头的偏移
//cdSizeBytes 中央目录的大小
//cdRecordCount 中央目录结构总数
//eocdBuf 中央目录结束区域数据
return new ApkUtils.ZipSections(cdStartOffset, cdSizeBytes, cdRecordCount, eocdOffset, eocdBuf);
}
}
}
}
ZipUtils.findZipEndOfCentralDirectoryRecord(apk) -> findZipEndOfCentralDirectoryRecord -> findZipEndOfCentralDirectoryRecord
private static Pair findZipEndOfCentralDirectoryRecord(DataSource zip, int maxCommentSize) throws IOException {
if (maxCommentSize >= 0 && maxCommentSize <= 65535) {
long fileSize = zip.size();
if (fileSize < 22L) {
return null;
} else {
maxCommentSize = (int)Math.min((long)maxCommentSize, fileSize - 22L);
int maxEocdSize = 22 + maxCommentSize;
long bufOffsetInFile = fileSize - (long)maxEocdSize;
ByteBuffer buf = zip.getByteBuffer(bufOffsetInFile, maxEocdSize);
buf.order(ByteOrder.LITTLE_ENDIAN);
// eocdOffsetInBuf 返回中央目录结束标志数据块开始位置
int eocdOffsetInBuf = findZipEndOfCentralDirectoryRecord(buf);
if (eocdOffsetInBuf == -1) {
return null;
} else {
buf.position(eocdOffsetInBuf);
ByteBuffer eocd = buf.slice();
eocd.order(ByteOrder.LITTLE_ENDIAN);
// pair的key 为中央目录结束标志数据 bytebuffer
return Pair.of(eocd, bufOffsetInFile + (long)eocdOffsetInBuf);
}
}
} else {
throw new IllegalArgumentException("maxCommentSize: " + maxCommentSize);
}
}
private static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents) {
....
for(int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength; ++expectedCommentLength) {
int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
// 获取目录结束标记,(固定值0x06054b50)10进制101010256
if (zipContents.getInt(eocdStartPos) == 101010256) {
int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + 20);
if (actualCommentLength == expectedCommentLength) {
// 返回中央目录结束标志数据块开始位置
return eocdStartPos;
}
}
}
return -1;
}
}
解析后的ZipSections
public static class ZipSections {
//中央目录开始位置相对于文件头的偏移
private final long mCentralDirectoryOffset;
//中央目录的大小
private final long mCentralDirectorySizeBytes;
//中央目录结构总数
private final int mCentralDirectoryRecordCount;
private final long mEocdOffset;
//中央目录结束区域数据
private final ByteBuffer mEocd;
public ZipSections(long centralDirectoryOffset, long centralDirectorySizeBytes, int centralDirectoryRecordCount, long eocdOffset, ByteBuffer eocd) {
this.mCentralDirectoryOffset = centralDirectoryOffset;
this.mCentralDirectorySizeBytes = centralDirectorySizeBytes;
this.mCentralDirectoryRecordCount = centralDirectoryRecordCount;
this.mEocdOffset = eocdOffset;
this.mEocd = eocd;
}
public long getZipCentralDirectoryOffset() {
return this.mCentralDirectoryOffset;
}
public long getZipCentralDirectorySizeBytes() {
return this.mCentralDirectorySizeBytes;
}
public int getZipCentralDirectoryRecordCount() {
return this.mCentralDirectoryRecordCount;
}
public long getZipEndOfCentralDirectoryOffset() {
return this.mEocdOffset;
}
public ByteBuffer getZipEndOfCentralDirectory() {
return this.mEocd;
}
}
ApkSigningBlock
ApkSigningBlock格式
偏移 字节数 描述
@+0 bytes uint64(8字节 long): 这个Block的长度(本字段的长度不计算在内)
@+8 bytes pairs 一组ID-value
@-24 bytes uint64(8字节 long): 这个Block的长度(和第一个字段一样值)
@-16 bytes uint128: magic
区块2中APK Signing Block是由这几部分组成:2个用来标示这个区块长度的8字节 + 这个区块的魔数(APK Sig Block 42)+ 这个区块所承载的数据(ID-value)。
我们重点来看一下这个ID-value,它由一个8字节的长度标示=4字节的ID+它的负载组成。V2的签名信息是以ID(0x7109871a)的ID-value来保存在这个区块中,不知大家有没有注意这是一组ID-value,也就是说它是可以有若干个这样的ID-value来组成。
获取ApkSigningBlock
public static ApkUtils.ApkSigningBlock findApkSigningBlock(DataSource apk, ApkUtils.ZipSections zipSections) throws IOException, ApkSigningBlockNotFoundException {
long centralDirStartOffset = zipSections.getZipCentralDirectoryOffset();
long centralDirEndOffset = centralDirStartOffset + zipSections.getZipCentralDirectorySizeBytes();
long eocdStartOffset = zipSections.getZipEndOfCentralDirectoryOffset();
if (centralDirEndOffset != eocdStartOffset) {
throw new ApkSigningBlockNotFoundException("ZIP Central Directory is not immediately followed by End of Central Directory. CD end: " + centralDirEndOffset + ", EoCD start: " + eocdStartOffset);
} else if (centralDirStartOffset = (long)footer.capacity() && apkSigBlockSizeInFooter <= 2147483639L) {
// + 8L(一组ID-value) 为apkSigBlock的总长度
int totalSize = (int)(apkSigBlockSizeInFooter + 8L);
//apkSinBlock偏移量 = 中央目录开始位置相对于文件头的偏移 - apkSigBlock的总长度
long apkSigBlockOffset = centralDirStartOffset - (long)totalSize;
if (apkSigBlockOffset < 0L) {
throw new ApkSigningBlockNotFoundException("APK Signing Block offset out of range: " + apkSigBlockOffset);
} else {
ByteBuffer apkSigBlock = apk.getByteBuffer(apkSigBlockOffset, 8);
apkSigBlock.order(ByteOrder.LITTLE_ENDIAN);
long apkSigBlockSizeInHeader = apkSigBlock.getLong(0);
if (apkSigBlockSizeInHeader != apkSigBlockSizeInFooter) {
throw new ApkSigningBlockNotFoundException("APK Signing Block sizes in header and footer do not match: " + apkSigBlockSizeInHeader + " vs " + apkSigBlockSizeInFooter);
} else {
// 从apkSigBlockOffset到totalSize为ApkSigningBlock的位置
return new ApkUtils.ApkSigningBlock(apkSigBlockOffset, apk.slice(apkSigBlockOffset, (long)totalSize));
}
}
} else {
throw new ApkSigningBlockNotFoundException("APK Signing Block size out of range: " + apkSigBlockSizeInFooter);
}
} else {
throw new ApkSigningBlockNotFoundException("No APK Signing Block before ZIP Central Directory");
}
}
}
获取的ApkSigningBlock如下
public static class ApkSigningBlock {
// apkSigkBlock开始位置
private final long mStartOffsetInApk;
// apkSigkBlock的数据
private final DataSource mContents;
public ApkSigningBlock(long startOffsetInApk, DataSource contents) {
this.mStartOffsetInApk = startOffsetInApk;
this.mContents = contents;
}
public long getStartOffset() {
return this.mStartOffsetInApk;
}
public DataSource getContents() {
return this.mContents;
}
}
中央数据区域
获取字节数据
// input apk为输入的apk inputZipSections为上面解析的zipSections
ByteBuffer inputCd = getZipCentralDirectory(inputApk, inputZipSections);
private static ByteBuffer getZipCentralDirectory(DataSource apk, ZipSections apkSections) throws IOException, ApkFormatException {
//获取中央数据区 中央目录的大小
long cdSizeBytes = apkSections.getZipCentralDirectorySizeBytes();
if (cdSizeBytes > 2147483647L) {
throw new ApkFormatException("ZIP Central Directory too large: " + cdSizeBytes);
} else {
//中央数据区开始位置
long cdOffset = apkSections.getZipCentralDirectoryOffset();
//获取中央数据区字节数据
ByteBuffer cd = apk.getByteBuffer(cdOffset, (int)cdSizeBytes);
cd.order(ByteOrder.LITTLE_ENDIAN);
return cd;
}
}
解析中央目录
目录结构:
struct DirEntry
{
ui32 signature; // 中央目录文件header标识(0x02014b50)
ui16 deVersionMadeBy; // 压缩所用的pkware版本
ui16 deVersionToExtract; // 解压所需pkware的最低版本
ui16 deFlags; // 通用位标记
ui16 deCompression; // 压缩方法
ui16 deFileTime; // 文件最后修改时间
ui16 deFileDate; // 文件最后修改日期
ui32 deCrc; // CRC-32校验码
ui32 deCompressedSize; // 压缩后的大小
ui32 deUncompressedSize; // 未压缩的大小
ui16 deFileNameLength; // 文件名长度
ui16 deExtraFieldLength; // 扩展域长度
ui16 deFileCommentLength; // 文件注释长度
ui16 deDiskNumberStart; // 文件开始位置的磁盘编号
ui16 deInternalAttributes; // 内部文件属性
ui32 deExternalAttributes; // 外部文件属性
ui32 deHeaderOffset; // 本地文件头的相对位移
char *deFileName; // 目录文件名
char *deExtraField; // 扩展域
char *deFileComment; // 文件注释内容
};
//inputCd 上面返回的中央数据区域数据 inputZipSections为上面解析的zipSections
List inputCdRecords = parseZipCentralDirectory(inputCd, inputZipSections);
private static List parseZipCentralDirectory(ByteBuffer cd, ZipSections apkSections) throws ApkFormatException {
//中央数据区开始位置
long cdOffset = apkSections.getZipCentralDirectoryOffset();
//目录数量
int expectedCdRecordCount = apkSections.getZipCentralDirectoryRecordCount();
List cdRecords = new ArrayList(expectedCdRecordCount);
Set entryNames = new HashSet(expectedCdRecordCount);
for(int i = 0; i < expectedCdRecordCount; ++i) {
int offsetInsideCd = cd.position();
CentralDirectoryRecord cdRecord;
try {
//获取中央数据区单个目录 即解析出上面的中央目录文件格式
cdRecord = CentralDirectoryRecord.getRecord(cd);
} catch (ZipFormatException var11) {
throw new ApkFormatException("Malformed ZIP Central Directory record #" + (i + 1) + " at file offset " + (cdOffset + (long)offsetInsideCd), var11);
}
String entryName = cdRecord.getName();
if (!entryNames.add(entryName)) {
throw new ApkFormatException("Multiple ZIP entries with the same name: " + entryName);
}
cdRecords.add(cdRecord);
}
if (cd.hasRemaining()) {
throw new ApkFormatException("Unused space at the end of ZIP Central Directory: " + cd.remaining() + " bytes starting at file offset " + (cdOffset + (long)cd.position()));
} else {
return cdRecords;
}
}
解析后的中央目录数据结构
public class CentralDirectoryRecord {
public static final Comparator BY_LOCAL_FILE_HEADER_OFFSET_COMPARATOR = new CentralDirectoryRecord.ByLocalFileHeaderOffsetComparator();
private static final int RECORD_SIGNATURE = 33639248;
private static final int HEADER_SIZE_BYTES = 46;
private static final int GP_FLAGS_OFFSET = 8;
private static final int LOCAL_FILE_HEADER_OFFSET_OFFSET = 42;
private static final int NAME_OFFSET = 46;
//数据块
private final ByteBuffer mData;
//deFlag 标志位
private final short mGpFlags;
//deCompression 压缩方法
private final short mCompressionMethod;
//deFileTime 文件最后修改时间
private final int mLastModificationTime;
//deFileDate 文件最后修改时间
private final int mLastModificationDate;
// CRC-32校验码
private final long mCrc32;
//deCompressedSize 压缩后大小
private final long mCompressedSize;
//deUncompressedSize 未压缩大小
private final long mUncompressedSize;
//本地文件头的相对位移
private final long mLocalFileHeaderOffset;
//目录文件名
private final String mName;
//文件名长度
private final int mNameSizeBytes;
}
文件数据区
struct Record
{
ui32 signature; // 文件头标识,值固定(0x04034b50)
ui16 frVersion; // 解压文件所需 pkware最低版本
ui16 frFlags; // 通用比特标志位(置比特0位=加密)
ui16 frCompression; // 压缩方式
ui16 frFileTime; // 文件最后修改时间
ui16 frFileDate; //文件最后修改日期
ui32 frCrc; // CRC-32校验码
ui32 frCompressedSize; // 压缩后的大小
ui32 frUncompressedSize; // 未压缩的大小
ui16 frFileNameLength; // 文件名长度
ui16 frExtraFieldLength; // 扩展区长度
char* frFileName; // 文件名
char* frExtraField; // 扩展区
char* frData; // 压缩数据
};
//根据List 的每个中央目录 获取单个文件数据
//inputApkLfhSection 为文件数据区数据
DataSource inputApkLfhSection = inputApk.slice(0L, inputApkSigningBlockOffset != -1L ? inputApkSigningBlockOffset : inputZipSections.getZipCentralDirectoryOffset());
//inputApkLfhSection 为 为文件数据区数据
//inputCdRecord为单个CentralDirectoryRecord
//inputApkLfhSection.size为 为文件数据区数据的大小
//getRecord 按照文件格式解析文件区
LocalFileRecord inputLocalFileRecord = LocalFileRecord.getRecord(inputApkLfhSection, inputCdRecord, inputApkLfhSection.size());
解析后的单个文件数据为:
public class LocalFileRecord {
private static final int RECORD_SIGNATURE = 67324752;
private static final int HEADER_SIZE_BYTES = 30;
private static final int GP_FLAGS_OFFSET = 6;
private static final int CRC32_OFFSET = 14;
private static final int COMPRESSED_SIZE_OFFSET = 18;
private static final int UNCOMPRESSED_SIZE_OFFSET = 22;
private static final int NAME_LENGTH_OFFSET = 26;
private static final int EXTRA_LENGTH_OFFSET = 28;
private static final int NAME_OFFSET = 30;
private static final int DATA_DESCRIPTOR_SIZE_BYTES_WITHOUT_SIGNATURE = 12;
private static final int DATA_DESCRIPTOR_SIGNATURE = 134695760;
//文件名字
private final String mName;
//文件名大小
private final int mNameSizeBytes;
//文件数据
private final ByteBuffer mExtra;
//本地文件头的相对位移
private final long mStartOffsetInArchive;
//数据大小
private final long mSize;
//开始位置
private final int mDataStartOffset;
//长度
private final long mDataSize;
//是否压缩过
private final boolean mDataCompressed;
//未压缩数据长度
private final long mUncompressedDataSize;
private static final ByteBuffer EMPTY_BYTE_BUFFER = ByteBuffer.allocate(0);
}
V2签名原理:
1.将zip区块中的中央数据区,中央目录区,中央目录结尾添加到摘要算法中
static Map computeContentDigests(Set digestAlgorithms, DataSource beforeCentralDir, DataSource centralDir, DataSource eocd) throws IOException, NoSuchAlgorithmException, DigestException {
Map contentDigests = new HashMap();
Set oneMbChunkBasedAlgorithm = (Set)digestAlgorithms.stream().filter((a) -> {
return a == ContentDigestAlgorithm.CHUNKED_SHA256 || a == ContentDigestAlgorithm.CHUNKED_SHA512;
}).collect(Collectors.toSet());
//进行1M分块数据摘要
//将zip区块中的中央数据区,中央目录区,中央目录结尾添加到摘要算法中
computeOneMbChunkContentDigests(oneMbChunkBasedAlgorithm, new DataSource[]{beforeCentralDir, centralDir, eocd}, contentDigests);
if (digestAlgorithms.contains(ContentDigestAlgorithm.VERITY_CHUNKED_SHA256)) {
computeApkVerityDigest(beforeCentralDir, centralDir, eocd, contentDigests);
}
return contentDigests;
}
2.拆分chunk
将每个部分拆分成多个大小为 1 MB大小的chunk,最后一个chunk可能小于1M。之所以分块,是为了可以通过并行计算摘要以加快计算速度
3.计算chunk摘要字节 0xa5 + 块的长度(字节数) + 块的内容 进行计算;
4.计算整体摘要字节 0x5a + chunk数 + 块的摘要的连接(按块在 APK 中的顺序)进行计算。
这里要注意的是:中央目录结尾记录中包含了中央目录的起始偏移量,插入APK签名分块后,中央目录的起始偏移量将发生变化。故在校验签名计算摘要时,需要把中央目录的起始偏移量当作APK签名分块的起始偏移量。
private static void computeOneMbChunkContentDigests(Set digestAlgorithms, DataSource[] contents, Map outputContentDigests) throws IOException, NoSuchAlgorithmException, DigestException {
long chunkCountLong = 0L;
DataSource[] var5 = contents;
int var6 = contents.length;
for(int var7 = 0; var7 2147483647L) {
throw new DigestException("Input too long: " + chunkCountLong + " chunks");
} else {
//记录chunkCount
int chunkCount = (int)chunkCountLong;
ContentDigestAlgorithm[] digestAlgorithmsArray = (ContentDigestAlgorithm[])digestAlgorithms.toArray(new ContentDigestAlgorithm[digestAlgorithms.size()]);
MessageDigest[] mds = new MessageDigest[digestAlgorithmsArray.length];
byte[][] digestsOfChunks = new byte[digestAlgorithmsArray.length][];
int[] digestOutputSizes = new int[digestAlgorithmsArray.length];
int chunkIndex;
for(int i = 0; i < digestAlgorithmsArray.length; ++i) {
ContentDigestAlgorithm digestAlgorithm = digestAlgorithmsArray[i];
chunkIndex = digestAlgorithm.getChunkDigestOutputSizeBytes();
digestOutputSizes[i] = chunkIndex;
byte[] concatenationOfChunkCountAndChunkDigests = new byte[5 + chunkCount * chunkIndex];
//[0]=90 即 0x5a
concatenationOfChunkCountAndChunkDigests[0] = 90;
//字节 0x5a + chunk数 + 块的摘要的连接 用来计算整体摘要
setUnsignedInt32LittleEndian(chunkCount, concatenationOfChunkCountAndChunkDigests, 1);
digestsOfChunks[i] = concatenationOfChunkCountAndChunkDigests;
String jcaAlgorithm = digestAlgorithm.getJcaMessageDigestAlgorithm();
mds[i] = MessageDigest.getInstance(jcaAlgorithm);
}
//mdSink用来进行md update 输入等待加密的数据
DataSink mdSink = DataSinks.asDataSink(mds);
byte[] chunkContentPrefix = new byte[5];
chunkContentPrefix[0] = -91;
chunkIndex = 0;
DataSource[] var34 = contents;
int var36 = contents.length;
for(int var15 = 0; var15 0L; ++chunkIndex) {
//获取当前操作块的chunk大小
int chunkSize = (int)Math.min(inputRemaining, 1048576L);
setUnsignedInt32LittleEndian(chunkSize, chunkContentPrefix, 1);
int i;
for(i = 0; i < mds.length; ++i) {
mds[i].update(chunkContentPrefix);
}
try {
//内部通过mdSink进行当前块摘要数据的update 及内部调用了md.update
input.feed(inputOffset, (long)chunkSize, mdSink);
} catch (IOException var27) {
throw new IOException("Failed to read chunk #" + chunkIndex, var27);
}
for(i = 0; i < digestAlgorithmsArray.length; ++i) {
MessageDigest md = mds[i];
byte[] concatenationOfChunkCountAndChunkDigests = digestsOfChunks[i];
int expectedDigestSizeBytes = digestOutputSizes[i];
//当前块的数据摘要
int actualDigestSizeBytes = md.digest(concatenationOfChunkCountAndChunkDigests, 5 + chunkIndex * expectedDigestSizeBytes, expectedDigestSizeBytes);
if (actualDigestSizeBytes != expectedDigestSizeBytes) {
throw new RuntimeException("Unexpected output size of " + md.getAlgorithm() + " digest: " + actualDigestSizeBytes);
}
}
inputOffset += (long)chunkSize;
inputRemaining -= (long)chunkSize;
}
}
for(int i = 0; i < digestAlgorithmsArray.length; ++i) {
ContentDigestAlgorithm digestAlgorithm = digestAlgorithmsArray[i];
byte[] concatenationOfChunkCountAndChunkDigests = digestsOfChunks[i];
MessageDigest md = mds[i];
//计算整体摘要 concatenationOfChunkCountAndChunkDigests的值上面计算过
byte[] digest = md.digest(concatenationOfChunkCountAndChunkDigests);
outputContentDigests.put(digestAlgorithm, digest);
}
}
开辟apkSignBlock空间
ByteBuffer outputEocd = EocdRecord.createWithModifiedCentralDirectoryInfo(inputZipSections.getZipEndOfCentralDirectory(), outputCentralDirRecordCount, outputCentralDirDataSource.size(), outputOffset);
//数据摘要
OutputApkSigningBlockRequest2 outputApkSigningBlockRequest = ((ApkSignerEngine)signerEngine).outputZipSections2(outputApkIn, outputCentralDirDataSource, DataSources.asDataSource(outputEocd));
if (outputApkSigningBlockRequest != null) {
//回写开辟空间
int padding = outputApkSigningBlockRequest.getPaddingSizeBeforeApkSigningBlock();
outputApkOut.consume(ByteBuffer.allocate(padding));
byte[] outputApkSigningBlock = outputApkSigningBlockRequest.getApkSigningBlock();
outputApkOut.consume(outputApkSigningBlock, 0, outputApkSigningBlock.length);
ZipUtils.setZipEocdCentralDirectoryOffset(outputEocd, outputOffset + (long)padding + (long)outputApkSigningBlock.length);
outputApkSigningBlockRequest.done();
}
作者:hfyd_
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341