mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-03 09:49:23 +02:00
GP-5429: Support for Mach-O LC_REEXPORT_DYLIB
This commit is contained in:
parent
136a944796
commit
17910774cd
10 changed files with 514 additions and 189 deletions
|
@ -217,6 +217,30 @@ public class MachHeader implements StructConverter {
|
|||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses only this {@link MachHeader}'s {@link DynamicLibraryCommand reexport load commands}
|
||||
*
|
||||
* @return A {@link List} of this {@link MachHeader}'s
|
||||
* {@link DynamicLibraryCommand reexport load commands}
|
||||
* @throws IOException If there was an IO-related error
|
||||
*/
|
||||
public List<DynamicLibraryCommand> parseReexports() throws IOException {
|
||||
List<DynamicLibraryCommand> cmds = new ArrayList<>();
|
||||
_reader.setPointerIndex(_commandIndex);
|
||||
for (int i = 0; i < nCmds; ++i) {
|
||||
int type = _reader.peekNextInt();
|
||||
if (type == LoadCommandTypes.LC_REEXPORT_DYLIB) {
|
||||
DynamicLibraryCommand cmd = new DynamicLibraryCommand(_reader);
|
||||
cmds.add(cmd);
|
||||
_reader.setPointerIndex(cmd.getStartIndex());
|
||||
}
|
||||
type = _reader.readNextInt();
|
||||
long size = _reader.readNextUnsignedInt();
|
||||
_reader.setPointerIndex(_reader.getPointerIndex() + size - 8);
|
||||
}
|
||||
return cmds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses only this {@link MachHeader}'s {@link LoadCommand}s to check to see if one of the
|
||||
* given type exists
|
||||
|
|
|
@ -34,7 +34,7 @@ import ghidra.util.task.TaskMonitor;
|
|||
public class DynamicLibraryCommand extends LoadCommand {
|
||||
private DynamicLibrary dylib;
|
||||
|
||||
DynamicLibraryCommand(BinaryReader reader) throws IOException {
|
||||
public DynamicLibraryCommand(BinaryReader reader) throws IOException {
|
||||
super(reader);
|
||||
dylib = new DynamicLibrary(reader, this);
|
||||
}
|
||||
|
|
|
@ -122,6 +122,7 @@ public class ExportTrie {
|
|||
if ((flags & EXPORT_SYMBOL_FLAGS_REEXPORT) != 0) {
|
||||
ulebOffsets.add(reader.getPointerIndex() - base);
|
||||
other = reader.readNext(LEB128::unsigned); // dylib ordinal
|
||||
stringOffsets.add(reader.getPointerIndex() - base);
|
||||
importName = reader.readNextAsciiString();
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -20,6 +20,7 @@ import java.io.IOException;
|
|||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
|
@ -154,28 +155,47 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
log.appendMsg("--------------------------------------------------------------------\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* Fix up program's external library entries so that they point to a path in the project.
|
||||
*/
|
||||
@Override
|
||||
protected void postLoadProgramFixups(List<Loaded<Program>> loadedPrograms, Project project,
|
||||
List<Option> options, MessageLog messageLog, TaskMonitor monitor)
|
||||
throws CancelledException, IOException {
|
||||
if (loadedPrograms.isEmpty()) {
|
||||
if (loadedPrograms.isEmpty() ||
|
||||
(!isLinkExistingLibraries(options) && !isLoadLibraries(options))) {
|
||||
return;
|
||||
}
|
||||
if (isLinkExistingLibraries(options) || isLoadLibraries(options)) {
|
||||
String projectFolderPath = loadedPrograms.get(0).getProjectFolderPath();
|
||||
List<DomainFolder> searchFolders = new ArrayList<>();
|
||||
String destPath = getLibraryDestinationFolderPath(project, projectFolderPath, options);
|
||||
DomainFolder destSearchFolder =
|
||||
getLibraryDestinationSearchFolder(project, destPath, options);
|
||||
DomainFolder linkSearchFolder =
|
||||
getLinkSearchFolder(project, projectFolderPath, options);
|
||||
if (destSearchFolder != null) {
|
||||
searchFolders.add(destSearchFolder);
|
||||
|
||||
List<DomainFolder> searchFolders =
|
||||
getLibrarySearchFolders(loadedPrograms, project, options);
|
||||
|
||||
List<Loaded<Program>> saveablePrograms =
|
||||
loadedPrograms.stream().filter(Predicate.not(Loaded::shouldDiscard)).toList();
|
||||
|
||||
monitor.initialize(saveablePrograms.size());
|
||||
for (Loaded<Program> loadedProgram : saveablePrograms) {
|
||||
monitor.increment();
|
||||
|
||||
Program program = loadedProgram.getDomainObject();
|
||||
ExternalManager extManager = program.getExternalManager();
|
||||
String[] extLibNames = extManager.getExternalLibraryNames();
|
||||
if (extLibNames.length == 0 ||
|
||||
(extLibNames.length == 1 && Library.UNKNOWN.equals(extLibNames[0]))) {
|
||||
continue; // skip program if no libraries defined
|
||||
}
|
||||
if (linkSearchFolder != null) {
|
||||
searchFolders.add(linkSearchFolder);
|
||||
|
||||
monitor.setMessage("Resolving..." + program.getName());
|
||||
int id = program.startTransaction("Resolving external references");
|
||||
try {
|
||||
resolveExternalLibraries(program, saveablePrograms, searchFolders, monitor,
|
||||
messageLog);
|
||||
}
|
||||
finally {
|
||||
program.endTransaction(id, true);
|
||||
}
|
||||
fixupExternalLibraries(loadedPrograms, searchFolders, messageLog, monitor);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -262,15 +282,16 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* Gets the {@link DomainFolder project folder} to search for existing libraries
|
||||
*
|
||||
* @param project The {@link Project}. Could be null if there is no project.
|
||||
* @param program The {@link Program} being loaded
|
||||
* @param projectFolderPath The project folder path the program will get saved to. Could be null
|
||||
* if the program is not getting saved to the project.
|
||||
* @param options a {@link List} of {@link Option}s
|
||||
* @return The path of the project folder to search for existing libraries, or null if no
|
||||
* project folders can be or should be searched
|
||||
*/
|
||||
protected DomainFolder getLinkSearchFolder(Project project, String projectFolderPath,
|
||||
List<Option> options) {
|
||||
if (!shouldSearchAllPaths(options) && !isLinkExistingLibraries(options)) {
|
||||
protected DomainFolder getLinkSearchFolder(Project project, Program program,
|
||||
String projectFolderPath, List<Option> options) {
|
||||
if (!shouldSearchAllPaths(program, options) && !isLinkExistingLibraries(options)) {
|
||||
return null;
|
||||
}
|
||||
if (project == null) {
|
||||
|
@ -373,14 +394,39 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
return project.getProjectData().getFolder(libraryDestinationFolderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a {@link List} of library search {@link DomainFolder folders} based on the current
|
||||
* options
|
||||
*
|
||||
* @param loadedPrograms the list of {@link Loaded} {@link Program}s
|
||||
* @param project The {@link Project} to load into. Could be null if there is no project.
|
||||
* @param options The {@link List} of {@link Option}s
|
||||
* @return A {@link List} of library search {@link DomainFolder folders} based on the current
|
||||
* options
|
||||
*/
|
||||
protected List<DomainFolder> getLibrarySearchFolders(List<Loaded<Program>> loadedPrograms,
|
||||
Project project, List<Option> options) {
|
||||
List<DomainFolder> searchFolders = new ArrayList<>();
|
||||
String projectFolderPath = loadedPrograms.get(0).getProjectFolderPath();
|
||||
String destPath = getLibraryDestinationFolderPath(project, projectFolderPath, options);
|
||||
DomainFolder destSearchFolder =
|
||||
getLibraryDestinationSearchFolder(project, destPath, options);
|
||||
DomainFolder linkSearchFolder = getLinkSearchFolder(project,
|
||||
loadedPrograms.getFirst().getDomainObject(), projectFolderPath, options);
|
||||
Optional.ofNullable(destSearchFolder).ifPresent(searchFolders::add);
|
||||
Optional.ofNullable(linkSearchFolder).ifPresent(searchFolders::add);
|
||||
return searchFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not to search for libraries using all possible search paths, regardless
|
||||
* of what options are set
|
||||
*
|
||||
* @param program The {@link Program} being loaded
|
||||
* @param options a {@link List} of {@link Option}s
|
||||
* @return True if all possible search paths should be used, regardless of what options are set
|
||||
*/
|
||||
protected boolean shouldSearchAllPaths(List<Option> options) {
|
||||
protected boolean shouldSearchAllPaths(Program program, List<Option> options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -433,6 +479,8 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* @param libraryName The name of the library
|
||||
* @param libraryFsrl The library {@link FSRL}
|
||||
* @param provider The library bytes
|
||||
* @param unprocessed The {@link Queue} of {@link UnprocessedLibrary unprocessed libraries}
|
||||
* @param depth The load depth of the library to load
|
||||
* @param loadSpec The {@link LoadSpec} used for the load
|
||||
* @param options The options
|
||||
* @param log The log
|
||||
|
@ -441,8 +489,9 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* @throws CancelledException If the user cancelled the action
|
||||
*/
|
||||
protected void processLibrary(Program library, String libraryName, FSRL libraryFsrl,
|
||||
ByteProvider provider, LoadSpec loadSpec, List<Option> options, MessageLog log,
|
||||
TaskMonitor monitor) throws IOException, CancelledException {
|
||||
ByteProvider provider, Queue<UnprocessedLibrary> unprocessed, int depth,
|
||||
LoadSpec loadSpec, List<Option> options, MessageLog log, TaskMonitor monitor)
|
||||
throws IOException, CancelledException {
|
||||
// Default behavior is to do nothing
|
||||
}
|
||||
|
||||
|
@ -478,7 +527,8 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
getCustomLibrarySearchPaths(provider, options, log, monitor);
|
||||
List<FileSystemSearchPath> searchPaths =
|
||||
getLibrarySearchPaths(provider, program, options, log, monitor);
|
||||
DomainFolder linkSearchFolder = getLinkSearchFolder(project, projectFolderPath, options);
|
||||
DomainFolder linkSearchFolder =
|
||||
getLinkSearchFolder(project, program, projectFolderPath, options);
|
||||
String libraryDestFolderPath =
|
||||
getLibraryDestinationFolderPath(project, projectFolderPath, options);
|
||||
DomainFolder libraryDestFolder =
|
||||
|
@ -490,6 +540,7 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
monitor.checkCancelled();
|
||||
UnprocessedLibrary unprocessedLibrary = unprocessed.remove();
|
||||
String libraryName = unprocessedLibrary.name();
|
||||
boolean discard = unprocessedLibrary.discard();
|
||||
int depth = unprocessedLibrary.depth();
|
||||
if (depth == 0 || processed.contains(libraryName)) {
|
||||
continue;
|
||||
|
@ -519,7 +570,8 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
provider, customSearchPaths, libraryDestFolderPath, unprocessed, depth,
|
||||
desiredLoadSpec, options, log, consumer, monitor);
|
||||
if (loadedLibrary != null) {
|
||||
loaded = true;
|
||||
loaded = loadLibraries && !discard;
|
||||
loadedLibrary.setDiscard(!loaded);
|
||||
loadedPrograms.add(loadedLibrary);
|
||||
}
|
||||
}
|
||||
|
@ -530,16 +582,11 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
provider, searchPaths, libraryDestFolderPath, unprocessed, depth,
|
||||
desiredLoadSpec, options, log, consumer, monitor);
|
||||
if (loadedLibrary != null) {
|
||||
if (loadLibraries) {
|
||||
loaded = true;
|
||||
loaded = loadLibraries && !discard;
|
||||
loadedLibrary.setDiscard(!loaded);
|
||||
loadedPrograms.add(loadedLibrary);
|
||||
}
|
||||
else {
|
||||
loadedLibrary.release(consumer);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (loaded) {
|
||||
log.appendMsg("Saving library to: " +
|
||||
loadedPrograms.get(loadedPrograms.size() - 1).toString());
|
||||
|
@ -547,7 +594,6 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
else {
|
||||
log.appendMsg("Library not saved to project.");
|
||||
}
|
||||
}
|
||||
log.appendMsg("------------------------------------------------\n");
|
||||
}
|
||||
}
|
||||
|
@ -624,13 +670,13 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
library = loadLibrary(simpleLibraryName, candidateLibraryFsrl,
|
||||
desiredLoadSpec, newLibraryList, options, consumer, log, monitor);
|
||||
for (String newLibraryName : newLibraryList) {
|
||||
unprocessed.add(new UnprocessedLibrary(newLibraryName, depth - 1));
|
||||
unprocessed.add(new UnprocessedLibrary(newLibraryName, depth - 1, false));
|
||||
}
|
||||
if (library == null) {
|
||||
continue;
|
||||
}
|
||||
processLibrary(library, libraryName, candidateLibraryFsrl, provider,
|
||||
desiredLoadSpec, options, log, monitor);
|
||||
processLibrary(library, libraryName, candidateLibraryFsrl, provider, unprocessed,
|
||||
depth, desiredLoadSpec, options, log, monitor);
|
||||
success = true;
|
||||
return new Loaded<Program>(library, simpleLibraryName, libraryDestFolderPath);
|
||||
}
|
||||
|
@ -664,7 +710,7 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* If null this method will return null.
|
||||
* @return The found {@link DomainFile} or null if not found
|
||||
*/
|
||||
private DomainFile findLibrary(String libraryPath, DomainFolder folder) {
|
||||
protected DomainFile findLibrary(String libraryPath, DomainFolder folder) {
|
||||
if (folder == null) {
|
||||
return null;
|
||||
}
|
||||
|
@ -897,66 +943,6 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
return libraryNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each {@link Loaded} {@link Program} in the given list, fix up its external library
|
||||
* entries so that they point to a path in the project.
|
||||
* <p>
|
||||
* Other {@link Program}s in the given list are matched first, then the given
|
||||
* {@link DomainFolder search folder} is searched for matches.
|
||||
*
|
||||
* @param loadedPrograms the list of {@link Loaded} {@link Program}s
|
||||
* @param searchFolders an ordered list of {@link DomainFolder}s which imported libraries will
|
||||
* be searched. These folders will be searched if a library is not found within the list of
|
||||
* programs supplied.
|
||||
* @param messageLog log for messages.
|
||||
* @param monitor the task monitor
|
||||
* @throws IOException if there was an IO-related problem resolving.
|
||||
* @throws CancelledException if the user cancelled the load.
|
||||
*/
|
||||
private void fixupExternalLibraries(List<Loaded<Program>> loadedPrograms,
|
||||
List<DomainFolder> searchFolders, MessageLog messageLog, TaskMonitor monitor)
|
||||
throws CancelledException, IOException {
|
||||
|
||||
monitor.initialize(loadedPrograms.size());
|
||||
for (Loaded<Program> loadedProgram : loadedPrograms) {
|
||||
monitor.increment();
|
||||
|
||||
Program program = loadedProgram.getDomainObject();
|
||||
ExternalManager extManager = program.getExternalManager();
|
||||
String[] extLibNames = extManager.getExternalLibraryNames();
|
||||
if (extLibNames.length == 0 ||
|
||||
(extLibNames.length == 1 && Library.UNKNOWN.equals(extLibNames[0]))) {
|
||||
continue; // skip program if no libraries defined
|
||||
}
|
||||
|
||||
monitor.setMessage("Resolving..." + program.getName());
|
||||
int id = program.startTransaction("Resolving external references");
|
||||
try {
|
||||
resolveExternalLibraries(program, loadedPrograms, searchFolders, monitor,
|
||||
messageLog);
|
||||
}
|
||||
finally {
|
||||
program.endTransaction(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix up program's external library entries so that they point to a path in the project.
|
||||
* <p>
|
||||
* Other programs in the map are matched first, then the ghidraLibSearchFolders
|
||||
* are searched for matches.
|
||||
*
|
||||
* @param program the program whose Library entries are to be resolved. An open
|
||||
* transaction on program is required.
|
||||
* @param loadedPrograms the list of {@link Loaded} {@link Program}s
|
||||
* @param searchFolders an order list of {@link DomainFolder}s which imported libraries will be
|
||||
* searched. These folders will be searched if a library is not found within the list of
|
||||
* programs supplied.
|
||||
* @param messageLog log for messages.
|
||||
* @param monitor the task monitor
|
||||
* @throws CancelledException if the user cancelled the load.
|
||||
*/
|
||||
private void resolveExternalLibraries(Program program,
|
||||
List<Loaded<Program>> loadedPrograms, List<DomainFolder> searchFolders,
|
||||
TaskMonitor monitor, MessageLog messageLog) throws CancelledException {
|
||||
|
@ -1009,8 +995,9 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* @param name The name of the library
|
||||
* @param depth The recursive load depth of the library (based on the original binary being
|
||||
* loaded)
|
||||
* @param discard True if the library should be discarded (not saved) after processing
|
||||
*/
|
||||
private record UnprocessedLibrary(String name, int depth) {/**/}
|
||||
protected record UnprocessedLibrary(String name, int depth, boolean discard) {/**/}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Queue} of {@link UnprocessedLibrary}s, initialized filled with the
|
||||
|
@ -1022,7 +1009,7 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
*/
|
||||
private Queue<UnprocessedLibrary> createUnprocessedQueue(List<String> libraryNames, int depth) {
|
||||
return libraryNames.stream()
|
||||
.map(name -> new UnprocessedLibrary(name, depth))
|
||||
.map(name -> new UnprocessedLibrary(name, depth, false))
|
||||
.collect(Collectors.toCollection(LinkedList::new));
|
||||
}
|
||||
|
||||
|
@ -1067,7 +1054,7 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
*/
|
||||
private List<FileSystemSearchPath> getLibrarySearchPaths(ByteProvider provider, Program program,
|
||||
List<Option> options, MessageLog log, TaskMonitor monitor) throws CancelledException {
|
||||
if (!isLoadLibraries(options) && !shouldSearchAllPaths(options)) {
|
||||
if (!isLoadLibraries(options) && !shouldSearchAllPaths(program, options)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
|
@ -1119,7 +1106,8 @@ public abstract class AbstractLibrarySupportLoader extends AbstractProgramLoader
|
|||
* be a simple filename or an absolute path.
|
||||
* @return The found {@link Loaded} {@link Program} or null if not found
|
||||
*/
|
||||
private Loaded<Program> findLibrary(List<Loaded<Program>> loadedPrograms, String libraryName) {
|
||||
protected Loaded<Program> findLibrary(List<Loaded<Program>> loadedPrograms,
|
||||
String libraryName) {
|
||||
Comparator<String> comparator = getLibraryNameComparator();
|
||||
boolean noExtension = FilenameUtils.getExtension(libraryName).equals("");
|
||||
boolean absolute = libraryName.startsWith("/");
|
||||
|
|
|
@ -17,8 +17,8 @@ package ghidra.app.util.opinion;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import generic.jar.ResourceFile;
|
||||
import ghidra.app.util.Option;
|
||||
|
@ -71,13 +71,14 @@ public abstract class AbstractOrdinalSupportLoader extends AbstractLibrarySuppor
|
|||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldSearchAllPaths(List<Option> options) {
|
||||
protected boolean shouldSearchAllPaths(Program program, List<Option> options) {
|
||||
return shouldPerformOrdinalLookup(options);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void processLibrary(Program lib, String libName, FSRL libFsrl, ByteProvider provider,
|
||||
LoadSpec loadSpec, List<Option> options, MessageLog log, TaskMonitor monitor)
|
||||
Queue<UnprocessedLibrary> unprocessed, int depth, LoadSpec loadSpec,
|
||||
List<Option> options, MessageLog log, TaskMonitor monitor)
|
||||
throws IOException, CancelledException {
|
||||
int size = loadSpec.getLanguageCompilerSpec().getLanguageDescription().getSize();
|
||||
ResourceFile existingExportsFile = LibraryLookupTable.getExistingExportsFile(libName, size);
|
||||
|
@ -128,10 +129,12 @@ public abstract class AbstractOrdinalSupportLoader extends AbstractLibrarySuppor
|
|||
protected void postLoadProgramFixups(List<Loaded<Program>> loadedPrograms, Project project,
|
||||
List<Option> options, MessageLog messageLog, TaskMonitor monitor)
|
||||
throws CancelledException, IOException {
|
||||
monitor.initialize(loadedPrograms.size());
|
||||
|
||||
if (shouldPerformOrdinalLookup(options)) {
|
||||
for (Loaded<Program> loadedProgram : loadedPrograms) {
|
||||
List<Loaded<Program>> saveablePrograms =
|
||||
loadedPrograms.stream().filter(Predicate.not(Loaded::shouldDiscard)).toList();
|
||||
monitor.initialize(saveablePrograms.size());
|
||||
for (Loaded<Program> loadedProgram : saveablePrograms) {
|
||||
monitor.checkCancelled();
|
||||
Program program = loadedProgram.getDomainObject();
|
||||
int id = program.startTransaction("Ordinal fixups");
|
||||
|
|
|
@ -17,8 +17,7 @@ package ghidra.app.util.opinion;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
import ghidra.app.plugin.processors.generic.MemoryBlockDefinition;
|
||||
import ghidra.app.util.Option;
|
||||
|
@ -141,6 +140,16 @@ public abstract class AbstractProgramLoader implements Loader {
|
|||
// Subclasses can perform custom post-load fix-ups
|
||||
postLoadProgramFixups(loadedPrograms, project, options, messageLog, monitor);
|
||||
|
||||
// Discard unneeded programs
|
||||
Iterator<Loaded<Program>> iter = loadedPrograms.iterator();
|
||||
while (iter.hasNext()) {
|
||||
Loaded<Program> loaded = iter.next();
|
||||
if (loaded.shouldDiscard()) {
|
||||
iter.remove();
|
||||
loaded.release(consumer);
|
||||
}
|
||||
}
|
||||
|
||||
success = true;
|
||||
return new LoadResults<Program>(loadedPrograms);
|
||||
}
|
||||
|
@ -513,7 +522,7 @@ public abstract class AbstractProgramLoader implements Loader {
|
|||
Namespace namespace = program.getGlobalNamespace();
|
||||
s = symTable.createLabel(addr, labelname, namespace, SourceType.IMPORTED);
|
||||
if (comment != null) {
|
||||
program.getListing().setComment(address, CodeUnit.EOL_COMMENT, comment);
|
||||
program.getListing().setComment(address, CommentType.EOL, comment);
|
||||
}
|
||||
if (isEntry) {
|
||||
symTable.addExternalEntryPoint(addr);
|
||||
|
|
|
@ -28,6 +28,9 @@ import ghidra.util.task.TaskMonitor;
|
|||
* A loaded {@link DomainObject} produced by a {@link Loader}. In addition to storing the loaded
|
||||
* {@link DomainObject}, it also stores the {@link Loader}'s desired name and project folder path
|
||||
* for the loaded {@link DomainObject}, should it get saved to a project.
|
||||
* <p>
|
||||
* NOTE: If an object of this type is marked as {@link #setDiscard(boolean) discardable}, it should
|
||||
* be {@link #release(Object) released} and not saved.
|
||||
*
|
||||
* @param <T> The type of {@link DomainObject} that was loaded
|
||||
*/
|
||||
|
@ -39,6 +42,7 @@ public class Loaded<T extends DomainObject> {
|
|||
|
||||
private DomainFile domainFile;
|
||||
private boolean ignoreSave;
|
||||
private boolean discard;
|
||||
|
||||
/**
|
||||
* Creates a new {@link Loaded} object
|
||||
|
@ -213,6 +217,27 @@ public class Loaded<T extends DomainObject> {
|
|||
return domainFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if this {@link Loaded} {@link DomainObject} should be discarded (not saved)
|
||||
*
|
||||
* @return True if this {@link Loaded} {@link DomainObject} should be discarded; otherwise,
|
||||
* false
|
||||
*/
|
||||
public boolean shouldDiscard() {
|
||||
return discard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether or not this {@link Loaded} {@link DomainObject} should be discarded (not saved)
|
||||
*
|
||||
* @param discard True if this {@link Loaded} {@link DomainObject} should be discarded;
|
||||
* otherwise, false
|
||||
*/
|
||||
public void setDiscard(boolean discard) {
|
||||
this.discard = discard;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deletes the loaded {@link DomainObject}'s associated {@link DomainFile} that was
|
||||
* {@link #save(Project, MessageLog, TaskMonitor) saved}. This method has no effect if it was
|
||||
|
|
|
@ -19,22 +19,28 @@ import java.io.IOException;
|
|||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
import ghidra.app.util.MemoryBlockUtils;
|
||||
import ghidra.app.util.Option;
|
||||
import ghidra.app.util.bin.ByteProvider;
|
||||
import ghidra.app.util.bin.ByteProviderWrapper;
|
||||
import ghidra.app.util.*;
|
||||
import ghidra.app.util.bin.*;
|
||||
import ghidra.app.util.bin.format.golang.GoConstants;
|
||||
import ghidra.app.util.bin.format.golang.rtti.GoRttiMapper;
|
||||
import ghidra.app.util.bin.format.macho.*;
|
||||
import ghidra.app.util.bin.format.macho.commands.*;
|
||||
import ghidra.app.util.bin.format.swift.SwiftUtils;
|
||||
import ghidra.app.util.bin.format.ubi.*;
|
||||
import ghidra.app.util.importer.MessageLog;
|
||||
import ghidra.formats.gfilesystem.*;
|
||||
import ghidra.framework.model.*;
|
||||
import ghidra.program.database.mem.FileBytes;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressSet;
|
||||
import ghidra.program.model.listing.Function;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.symbol.*;
|
||||
import ghidra.util.LittleEndianDataConverter;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import util.CollectionUtils;
|
||||
|
||||
/**
|
||||
* A {@link Loader} for Mach-O files.
|
||||
|
@ -44,6 +50,9 @@ public class MachoLoader extends AbstractLibrarySupportLoader {
|
|||
public final static String MACH_O_NAME = "Mac OS X Mach-O";
|
||||
private static final long MIN_BYTE_LENGTH = 4;
|
||||
|
||||
public static final String REEXPORT_OPTION_NAME = "Perform Reexports";
|
||||
static final boolean REEXPORT_OPTION_DEFAULT = true;
|
||||
|
||||
@Override
|
||||
public Collection<LoadSpec> findSupportedLoadSpecs(ByteProvider provider) throws IOException {
|
||||
List<LoadSpec> loadSpecs = new ArrayList<>();
|
||||
|
@ -121,6 +130,34 @@ public class MachoLoader extends AbstractLibrarySupportLoader {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Option> getDefaultOptions(ByteProvider provider, LoadSpec loadSpec,
|
||||
DomainObject domainObject, boolean loadIntoProgram) {
|
||||
List<Option> list =
|
||||
super.getDefaultOptions(provider, loadSpec, domainObject, loadIntoProgram);
|
||||
if (!loadIntoProgram) {
|
||||
list.add(new Option(REEXPORT_OPTION_NAME, REEXPORT_OPTION_DEFAULT,
|
||||
Boolean.class, Loader.COMMAND_LINE_ARG_PREFIX + "-reexport"));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String validateOptions(ByteProvider provider, LoadSpec loadSpec, List<Option> options,
|
||||
Program program) {
|
||||
if (options != null) {
|
||||
for (Option option : options) {
|
||||
String name = option.getName();
|
||||
if (name.equals(REEXPORT_OPTION_NAME)) {
|
||||
if (!Boolean.class.isAssignableFrom(option.getValueClass())) {
|
||||
return "Invalid type for option: " + name + " - " + option.getValueClass();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.validateOptions(provider, loadSpec, options, program);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return MACH_O_NAME;
|
||||
|
@ -217,4 +254,191 @@ public class MachoLoader extends AbstractLibrarySupportLoader {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks to see if reexports should be performed
|
||||
*
|
||||
* @param options a {@link List} of {@link Option}s
|
||||
* @return True if reexports should be performed; otherwise, false
|
||||
*/
|
||||
private boolean shouldPerformReexports(List<Option> options) {
|
||||
return OptionUtils.getOption(REEXPORT_OPTION_NAME, options, REEXPORT_OPTION_DEFAULT);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* If we aren't loading libraries, we still want to search all paths if the reexport option is
|
||||
* set and the Mach-O actually has {@code LC_REEXPORT_DYLIB} entries.
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldSearchAllPaths(Program program, List<Option> options) {
|
||||
if (super.shouldSearchAllPaths(program, options)) {
|
||||
return true;
|
||||
}
|
||||
if (shouldPerformReexports(options)) {
|
||||
try {
|
||||
ByteProvider provider = new MemoryByteProvider(program.getMemory(),
|
||||
program.getImageBase());
|
||||
if (new MachHeader(provider).parseAndCheck(LoadCommandTypes.LC_REEXPORT_DYLIB)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (IOException | MachException e) {
|
||||
Msg.error(this, "Failed to parse Mach-O header for: " + program.getName());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* The goal here is to add each reexported library to the {@code unprocessed} list at the
|
||||
* current {@code depth} to be sure they get loaded. However, if the current depth is 1, we
|
||||
* need to marked them as "discard" so we know not to save them in the end (since their actual
|
||||
* depth would have prevented their save as a normal library)
|
||||
*/
|
||||
@Override
|
||||
protected void processLibrary(Program lib, String libName, FSRL libFsrl, ByteProvider provider,
|
||||
Queue<UnprocessedLibrary> unprocessed, int depth, LoadSpec loadSpec,
|
||||
List<Option> options, MessageLog log, TaskMonitor monitor)
|
||||
throws IOException, CancelledException {
|
||||
|
||||
if (!shouldPerformReexports(options)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (String path : getReexportPaths(lib)) {
|
||||
unprocessed.add(new UnprocessedLibrary(path, depth, depth == 1));
|
||||
}
|
||||
}
|
||||
catch (MachException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a {@link List} of reexport library paths from the given {@link Program}
|
||||
*
|
||||
* @param program The {@link Program}
|
||||
* @return A {@link List} of reexport library paths from the given {@link Program}
|
||||
* @throws MachException if there was a problem parsing the Mach-O {@link Program}
|
||||
* @throws IOException if there was an IO-related error
|
||||
*/
|
||||
private List<String> getReexportPaths(Program program) throws MachException, IOException {
|
||||
ByteProvider p = new MemoryByteProvider(program.getMemory(), program.getImageBase());
|
||||
return new MachHeader(p).parseReexports()
|
||||
.stream()
|
||||
.map(DynamicLibraryCommand::getDynamicLibrary)
|
||||
.map(DynamicLibrary::getName)
|
||||
.map(LoadCommandString::getString)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
* <p>
|
||||
* Adds reexported symbols to each {@link Loaded} {@link Program}.
|
||||
*/
|
||||
@Override
|
||||
protected void postLoadProgramFixups(List<Loaded<Program>> loadedPrograms, Project project,
|
||||
List<Option> options, MessageLog messageLog, TaskMonitor monitor)
|
||||
throws CancelledException, IOException {
|
||||
|
||||
if (shouldPerformReexports(options)) {
|
||||
|
||||
List<DomainFolder> searchFolders =
|
||||
getLibrarySearchFolders(loadedPrograms, project, options);
|
||||
|
||||
monitor.initialize(loadedPrograms.size());
|
||||
for (Loaded<Program> loadedProgram : loadedPrograms) {
|
||||
monitor.increment();
|
||||
|
||||
Program program = loadedProgram.getDomainObject();
|
||||
int id = program.startTransaction("Reexporting");
|
||||
try {
|
||||
reexport(program, loadedPrograms, searchFolders, monitor, messageLog);
|
||||
}
|
||||
catch (Exception e) {
|
||||
messageLog.appendException(e);
|
||||
}
|
||||
finally {
|
||||
program.endTransaction(id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.postLoadProgramFixups(loadedPrograms, project, options, messageLog, monitor);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Reexports" symbols from to a {@link Program}
|
||||
*
|
||||
* @param program The {@link Program} to receive the reexports
|
||||
* @param loadedPrograms A {@link List} of {@link Loaded} {@link Program}s to find get the
|
||||
* reexportable symbols from
|
||||
* @param searchFolders A {@link List} of project folders that may contain already-loaded
|
||||
* {@link Program}s with reexportable symbols
|
||||
* @param monitor A cancelable task monitor
|
||||
* @param messageLog The log
|
||||
* @throws CancelledException if the user cancelled the load operation
|
||||
* @throws IOException if there was an IO-related error during the load
|
||||
*/
|
||||
private void reexport(Program program, List<Loaded<Program>> loadedPrograms,
|
||||
List<DomainFolder> searchFolders, TaskMonitor monitor, MessageLog messageLog)
|
||||
throws CancelledException, Exception {
|
||||
|
||||
for (String path : getReexportPaths(program)) {
|
||||
Program programToRelease = null;
|
||||
try {
|
||||
Loaded<Program> match = findLibrary(loadedPrograms, path);
|
||||
Program lib = null;
|
||||
if (match != null) {
|
||||
lib = match.getDomainObject();
|
||||
}
|
||||
if (lib == null) {
|
||||
for (DomainFolder searchFolder : searchFolders) {
|
||||
DomainFile df = findLibrary(path, searchFolder);
|
||||
if (df != null) {
|
||||
DomainObject obj = df.getDomainObject(this, true, true, monitor);
|
||||
if (obj instanceof Program p) {
|
||||
lib = p;
|
||||
programToRelease = p;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lib == null) {
|
||||
continue;
|
||||
}
|
||||
List<Symbol> reexportedSymbols = CollectionUtils
|
||||
.asStream(lib.getSymbolTable().getExternalEntryPointIterator())
|
||||
.map(lib.getSymbolTable()::getPrimarySymbol)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Address addr = MachoProgramUtils.addExternalBlock(program,
|
||||
reexportedSymbols.size() * 8, messageLog);
|
||||
for (Symbol symbol : reexportedSymbols) {
|
||||
String name = SymbolUtilities.replaceInvalidChars(symbol.getName(), true);
|
||||
program.getSymbolTable().addExternalEntryPoint(addr);
|
||||
program.getSymbolTable().createLabel(addr, name, SourceType.IMPORTED);
|
||||
Function function = program.getFunctionManager()
|
||||
.createFunction(name, addr, new AddressSet(addr), SourceType.IMPORTED);
|
||||
ExternalLocation loc = program.getExternalManager()
|
||||
.addExtLocation(path, name, null, SourceType.IMPORTED);
|
||||
function.setThunkedFunction(loc.createFunction());
|
||||
|
||||
addr = addr.add(8);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (programToRelease != null) {
|
||||
programToRelease.release(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,10 +66,7 @@ import ghidra.util.task.TaskMonitor;
|
|||
*/
|
||||
public class MachoProgramBuilder {
|
||||
|
||||
public static final String BLOCK_SOURCE_NAME = "Mach-O Loader";
|
||||
|
||||
protected MachHeader machoHeader;
|
||||
|
||||
protected Program program;
|
||||
protected ByteProvider provider;
|
||||
protected FileBytes fileBytes;
|
||||
|
@ -738,23 +735,9 @@ public class MachoProgramBuilder {
|
|||
if (undefinedSymbols.size() == 0) {
|
||||
return;
|
||||
}
|
||||
Address start = getAddress();
|
||||
try {
|
||||
MemoryBlock block = memory.createUninitializedBlock(MemoryBlock.EXTERNAL_BLOCK_NAME,
|
||||
start, undefinedSymbols.size() * machoHeader.getAddressSize(), false);
|
||||
// assume any value in external is writable.
|
||||
block.setWrite(true);
|
||||
|
||||
// Mark block as an artificial fabrication
|
||||
block.setArtificial(true);
|
||||
|
||||
block.setSourceName(BLOCK_SOURCE_NAME);
|
||||
block.setComment(
|
||||
"NOTE: This block is artificial and is used to make relocations work correctly");
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.appendMsg("Unable to create undefined memory block: " + e.getMessage());
|
||||
}
|
||||
Address addr = MachoProgramUtils.addExternalBlock(program,
|
||||
undefinedSymbols.size() * machoHeader.getAddressSize(), log);
|
||||
for (NList symbol : undefinedSymbols) {
|
||||
if (monitor.isCancelled()) {
|
||||
return;
|
||||
|
@ -762,15 +745,19 @@ public class MachoProgramBuilder {
|
|||
try {
|
||||
String name = SymbolUtilities.replaceInvalidChars(symbol.getString(), true);
|
||||
if (name != null && name.length() > 0) {
|
||||
program.getSymbolTable().createLabel(start, name, SourceType.IMPORTED);
|
||||
program.getSymbolTable().createLabel(addr, name, SourceType.IMPORTED);
|
||||
program.getExternalManager()
|
||||
.addExtLocation(Library.UNKNOWN, name, start, SourceType.IMPORTED);
|
||||
.addExtLocation(Library.UNKNOWN, name, addr, SourceType.IMPORTED);
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.appendMsg("Unable to create undefined symbol: " + e.getMessage());
|
||||
}
|
||||
start = start.add(machoHeader.getAddressSize());
|
||||
addr = addr.add(machoHeader.getAddressSize());
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.appendMsg("Unable to create undefined memory block: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -802,14 +789,10 @@ public class MachoProgramBuilder {
|
|||
if (absoluteSymbols.size() == 0) {
|
||||
return;
|
||||
}
|
||||
Address start = getAddress();
|
||||
Address start = MachoProgramUtils.getNextAvailableAddress(program);
|
||||
try {
|
||||
memory.createUninitializedBlock("ABSOLUTE", start,
|
||||
absoluteSymbols.size() * machoHeader.getAddressSize(), false);
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.appendMsg("Unable to create absolute memory block: " + e.getMessage());
|
||||
}
|
||||
for (NList symbol : absoluteSymbols) {
|
||||
try {
|
||||
String name = SymbolUtilities.replaceInvalidChars(symbol.getString(), true);
|
||||
|
@ -823,6 +806,10 @@ public class MachoProgramBuilder {
|
|||
start = start.add(machoHeader.getAddressSize());
|
||||
}
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.appendMsg("Unable to create absolute memory block: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<Address> processChainedFixups(List<String> libraryPaths) throws Exception {
|
||||
monitor.setMessage("Fixing up chained pointers...");
|
||||
|
@ -1497,24 +1484,6 @@ public class MachoProgramBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
private Address getAddress() {
|
||||
Address maxAddress = null;
|
||||
for (MemoryBlock block : program.getMemory().getBlocks()) {
|
||||
if (block.isOverlay()) {
|
||||
continue;
|
||||
}
|
||||
if (maxAddress == null || block.getEnd().compareTo(maxAddress) > 0) {
|
||||
maxAddress = block.getEnd();
|
||||
}
|
||||
}
|
||||
if (maxAddress == null) {
|
||||
return space.getAddress(0x1000);
|
||||
}
|
||||
long maxAddr = maxAddress.getOffset();
|
||||
long remainder = maxAddr % 0x1000;
|
||||
return maxAddress.getNewAddress(maxAddr + 0x1000 - remainder);
|
||||
}
|
||||
|
||||
private MemoryBlock getMemoryBlock(Section section) {
|
||||
Address blockAddress = space.getAddress(section.getAddress());
|
||||
return memory.getBlock(blockAddress);
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
/* ###
|
||||
* IP: GHIDRA
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package ghidra.app.util.opinion;
|
||||
|
||||
import ghidra.app.util.importer.MessageLog;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.program.model.mem.Memory;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
|
||||
public class MachoProgramUtils {
|
||||
|
||||
/**
|
||||
* Gets the next available {@link Address} in the {@link Program}
|
||||
*
|
||||
* @param program The {@link Program}
|
||||
* @return The next available {@link Address} in the {@link Program}
|
||||
*/
|
||||
public static Address getNextAvailableAddress(Program program) {
|
||||
Address maxAddress = null;
|
||||
for (MemoryBlock block : program.getMemory().getBlocks()) {
|
||||
if (block.isOverlay()) {
|
||||
continue;
|
||||
}
|
||||
if (maxAddress == null || block.getEnd().compareTo(maxAddress) > 0) {
|
||||
maxAddress = block.getEnd();
|
||||
}
|
||||
}
|
||||
if (maxAddress == null) {
|
||||
return program.getAddressFactory().getDefaultAddressSpace().getAddress(0x1000);
|
||||
}
|
||||
long maxAddr = maxAddress.getOffset();
|
||||
long remainder = maxAddr % 0x1000;
|
||||
return maxAddress.getNewAddress(maxAddr + 0x1000 - remainder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@link MemoryBlock#EXTERNAL_BLOCK_NAME EXERNAL block} to memory, or adds to an
|
||||
* existing one
|
||||
*
|
||||
* @param program The {@link Program}
|
||||
* @param size The desired size of the new EXTERNAL block
|
||||
* @param log The {@link MessageLog}
|
||||
* @return The {@link Address} of the new (or new piece) of EXTERNAL block
|
||||
* @throws Exception if there was an issue creating or adding to the EXTERNAL block
|
||||
*/
|
||||
public static Address addExternalBlock(Program program, long size, MessageLog log)
|
||||
throws Exception {
|
||||
Memory mem = program.getMemory();
|
||||
MemoryBlock externalBlock = mem.getBlock(MemoryBlock.EXTERNAL_BLOCK_NAME);
|
||||
Address ret;
|
||||
if (externalBlock != null) {
|
||||
ret = externalBlock.getEnd().add(1);
|
||||
MemoryBlock newBlock = mem.createBlock(externalBlock, "REEXPORTS", ret, size);
|
||||
mem.join(externalBlock, newBlock);
|
||||
//joinedBlock.setName(MemoryBlock.EXTERNAL_BLOCK_NAME);
|
||||
}
|
||||
else {
|
||||
ret = MachoProgramUtils.getNextAvailableAddress(program);
|
||||
externalBlock =
|
||||
mem.createUninitializedBlock(MemoryBlock.EXTERNAL_BLOCK_NAME, ret, size, false);
|
||||
externalBlock.setWrite(true);
|
||||
externalBlock.setArtificial(true);
|
||||
externalBlock.setComment(
|
||||
"NOTE: This block is artificial and is used to make relocations work correctly");
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue